Compare commits
8
Commits
055d3cca0d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ce665aec | ||
|
|
240ede9769 | ||
|
|
a72e2e6ff5 | ||
|
|
eb0ddafc64 | ||
|
|
6d888ddaee | ||
|
|
dbb3fedb97 | ||
|
|
ac9a7f31a3 | ||
|
|
bb0e34940a |
Vendored
+16
-17
@@ -1,19 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>设备管理系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-D7ykTT7f.js"></script>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>设备管理系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-BZChQzGB.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-DZ3WZ9y9.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/message-ddUl4JIs.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BpBnxyAz.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Vite 入口,指向 src/main.js,不要删除 -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
<link rel="modulepreload" crossorigin href="/assets/request-D1PLBK09.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C42Vprib.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Vite 入口,指向 src/main.js,不要删除 -->␍
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+11
-11
@@ -1,49 +1,49 @@
|
||||
{
|
||||
"hash": "a6f0f194",
|
||||
"configHash": "147f2811",
|
||||
"lockfileHash": "77198382",
|
||||
"browserHash": "ee71f75c",
|
||||
"hash": "25a37344",
|
||||
"configHash": "ec38f6fa",
|
||||
"lockfileHash": "74076ca5",
|
||||
"browserHash": "8bfb7e71",
|
||||
"optimized": {
|
||||
"@element-plus/icons-vue": {
|
||||
"src": "../../@element-plus/icons-vue/dist/index.js",
|
||||
"file": "@element-plus_icons-vue.js",
|
||||
"fileHash": "9553abbf",
|
||||
"fileHash": "53709748",
|
||||
"needsInterop": false
|
||||
},
|
||||
"axios": {
|
||||
"src": "../../axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "3e018dd2",
|
||||
"fileHash": "8bc11b32",
|
||||
"needsInterop": false
|
||||
},
|
||||
"echarts": {
|
||||
"src": "../../echarts/index.js",
|
||||
"file": "echarts.js",
|
||||
"fileHash": "275a5510",
|
||||
"fileHash": "bf5b1683",
|
||||
"needsInterop": false
|
||||
},
|
||||
"element-plus": {
|
||||
"src": "../../element-plus/es/index.mjs",
|
||||
"file": "element-plus.js",
|
||||
"fileHash": "cc57a904",
|
||||
"fileHash": "6f7bcfbf",
|
||||
"needsInterop": false
|
||||
},
|
||||
"pinia": {
|
||||
"src": "../../pinia/dist/pinia.js",
|
||||
"file": "pinia.js",
|
||||
"fileHash": "0a5304b8",
|
||||
"fileHash": "c89feaca",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../vue-router/dist/vue-router.js",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "0ef5b6fe",
|
||||
"fileHash": "a9a2c9fa",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue": {
|
||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "52e89e85",
|
||||
"fileHash": "53d5f754",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,116 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 后端路由前缀(AlertMessageController / AlertNotifyController)
|
||||
const msgBase = '/inspection/alert-message'
|
||||
const notifyBase = '/inspection/alert-notify'
|
||||
|
||||
// ============ 枚举常量(与后端 Model.Entity.Inspection.AlertEnums 一一对应) ============
|
||||
|
||||
// 告警级别(AlertLevelEnum)
|
||||
export const levelOptions = [
|
||||
{ value: 1, label: '信息' },
|
||||
{ value: 2, label: '警告' },
|
||||
{ value: 3, label: '紧急' }
|
||||
]
|
||||
export const levelLabel = (v) => levelOptions.find(i => i.value === v)?.label || '-'
|
||||
export const levelTag = (v) => ({ 3: 'danger', 2: 'warning' }[v] || 'info')
|
||||
|
||||
// 告警状态(AlertStatusEnum)
|
||||
export const statusOptions = [
|
||||
{ value: 1, label: '未确认' },
|
||||
{ value: 2, label: '已确认' },
|
||||
{ value: 3, label: '已处理' },
|
||||
{ value: 4, label: '已关闭' },
|
||||
{ value: 5, label: '已忽略' },
|
||||
{ value: 6, label: '已转工单' }
|
||||
]
|
||||
export const statusLabel = (v) => statusOptions.find(i => i.value === v)?.label || '-'
|
||||
export const statusTag = (v) => ({ 1: 'danger', 2: 'warning', 3: 'success', 4: 'info', 5: 'info', 6: 'info' }[v] || 'info')
|
||||
|
||||
// 告警类型(AlertTypeEnum)
|
||||
export const typeOptions = [
|
||||
{ value: 1, label: '阈值超限' },
|
||||
{ value: 2, label: '设备离线' },
|
||||
{ value: 3, label: '通讯故障' },
|
||||
{ value: 4, label: '巡检异常' },
|
||||
{ value: 99, label: '自定义' }
|
||||
]
|
||||
export const typeLabel = (v) => typeOptions.find(i => i.value === v)?.label || '-'
|
||||
|
||||
// 告警来源(AlertSourceEnum)
|
||||
export const sourceOptions = [
|
||||
{ value: 1, label: '规则引擎' },
|
||||
{ value: 2, label: '自动巡检' },
|
||||
{ value: 3, label: '数据看板' },
|
||||
{ value: 4, label: '数据采集' },
|
||||
{ value: 5, label: '手动上报' },
|
||||
{ value: 99, label: '其它' }
|
||||
]
|
||||
export const sourceLabel = (v) => sourceOptions.find(i => i.value === v)?.label || '-'
|
||||
|
||||
// 通知渠道类型(NotifyChannelTypeEnum)
|
||||
export const channelTypeOptions = [
|
||||
{ value: 1, label: '飞书' },
|
||||
{ value: 2, label: '钉钉' },
|
||||
{ value: 3, label: '企业微信' }
|
||||
]
|
||||
export const channelLabel = (v) => channelTypeOptions.find(i => i.value === v)?.label || '-'
|
||||
|
||||
// 推送模式(NotifyPushModeEnum)
|
||||
export const pushModeOptions = [
|
||||
{ value: 1, label: '直连推送' },
|
||||
{ value: 2, label: 'Outbox 中转' }
|
||||
]
|
||||
export const pushModeLabel = (v) => pushModeOptions.find(i => i.value === v)?.label || '-'
|
||||
|
||||
// ============ 告警消息 ============
|
||||
|
||||
// 告警列表(分页;level/status/type/source 筛选 + 时间段)
|
||||
export const getAlertList = (params) => request.get(`${msgBase}/list`, { params })
|
||||
|
||||
// 告警详情
|
||||
export const getAlertDetail = (id) => request.get(`${msgBase}/${id}`)
|
||||
|
||||
// 告警操作(确认/处理/关闭/忽略):body = { Operator, Remark }
|
||||
export const ackAlert = (id, data) => request.post(`${msgBase}/${id}/ack`, data)
|
||||
export const handleAlert = (id, data) => request.post(`${msgBase}/${id}/handle`, data)
|
||||
export const closeAlert = (id, data) => request.post(`${msgBase}/${id}/close`, data)
|
||||
export const ignoreAlert = (id, data) => request.post(`${msgBase}/${id}/ignore`, data)
|
||||
|
||||
// 告警概览(未确认数/今日新增/今日紧急/近7日总数)
|
||||
export const getAlertOverview = () => request.get(`${msgBase}/statistics/overview`)
|
||||
|
||||
// 告警趋势(granularity: day/week/month)
|
||||
export const getAlertTrend = (params) => request.get(`${msgBase}/statistics/trend`, { params })
|
||||
|
||||
// 告警分布(dimension: device/type/level/source)
|
||||
export const getAlertDistribution = (params) => request.get(`${msgBase}/statistics/distribution`, { params })
|
||||
|
||||
// 高频告警 TOP
|
||||
export const getAlertTop = (params) => request.get(`${msgBase}/statistics/top`, { params })
|
||||
|
||||
// 处理及时率
|
||||
export const getAlertTimeliness = (params) => request.get(`${msgBase}/statistics/timeliness`, { params })
|
||||
|
||||
// ============ 通知渠道 ============
|
||||
export const getChannels = () => request.get(`${notifyBase}/channel/list`)
|
||||
export const addChannel = (data) => request.post(`${notifyBase}/channel`, data)
|
||||
export const updateChannel = (id, data) => request.put(`${notifyBase}/channel/${id}`, data)
|
||||
export const deleteChannel = (id) => request.delete(`${notifyBase}/channel/${id}`)
|
||||
export const testChannel = (id) => request.post(`${notifyBase}/channel/${id}/test`)
|
||||
|
||||
// ============ 通知规则 ============
|
||||
export const getNotifyRules = () => request.get(`${notifyBase}/rule/list`)
|
||||
export const addNotifyRule = (data) => request.post(`${notifyBase}/rule`, data)
|
||||
export const updateNotifyRule = (id, data) => request.put(`${notifyBase}/rule/${id}`, data)
|
||||
export const deleteNotifyRule = (id) => request.delete(`${notifyBase}/rule/${id}`)
|
||||
export const setRuleEnabled = (id, enabled) => request.put(`${notifyBase}/rule/${id}/enabled`, null, { params: { enabled } })
|
||||
|
||||
// ============ 通知日志 ============
|
||||
export const getNotifyLogs = (params) => request.get(`${notifyBase}/log/list`, { params })
|
||||
|
||||
// ============ 值班排班 ============
|
||||
export const getDuties = (params) => request.get(`${notifyBase}/duty/list`, { params })
|
||||
export const addDuty = (data) => request.post(`${notifyBase}/duty`, data)
|
||||
export const updateDuty = (id, data) => request.put(`${notifyBase}/duty/${id}`, data)
|
||||
export const deleteDuty = (id) => request.delete(`${notifyBase}/duty/${id}`)
|
||||
@@ -1,10 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 告警规则列表
|
||||
// 告警规则列表(全局,不含产品/设备归属规则)
|
||||
export function getAlertRuleList() {
|
||||
return request.get('/inspection/alert-rule/list')
|
||||
}
|
||||
|
||||
// 某归属对象(产品/设备)下的规则列表;pointCode 可选按点过滤
|
||||
export function getOwnerAlertRules(ownerType, ownerId, pointCode) {
|
||||
return request.get('/inspection/alert-rule/owner', {
|
||||
params: { ownerType, ownerId, pointCode: pointCode || undefined }
|
||||
})
|
||||
}
|
||||
|
||||
// 新增告警规则
|
||||
export function addAlertRule(data) {
|
||||
return request.post('/inspection/alert-rule/add', data)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 审计日志接口(AuditController: api/system/audit,需 user:manage 权限)
|
||||
const base = '/system/audit'
|
||||
|
||||
// 审计日志列表(分页;筛选:关键字/操作类型/操作对象/时间段;X-Total-Count)
|
||||
export const getAuditLogList = (params) => request.get(`${base}/list`, { params })
|
||||
@@ -0,0 +1,16 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 认证接口(AuthController: api/system/auth)
|
||||
const base = '/system/auth'
|
||||
|
||||
// 登录:{ UserName, Password } → { Token, User, Permissions }
|
||||
export const login = (data) => request.post(`${base}/login`, data)
|
||||
|
||||
// 退出登录(记录审计日志)
|
||||
export const logout = () => request.post(`${base}/logout`)
|
||||
|
||||
// 当前登录用户信息 + 权限列表
|
||||
export const getMe = () => request.get(`${base}/me`)
|
||||
|
||||
// 修改自己的密码:{ OldPassword, NewPassword }
|
||||
export const changePassword = (data) => request.post(`${base}/change-password`, data)
|
||||
@@ -0,0 +1,50 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 后端路由前缀(IotDeviceController: [Route("api/config/device")])
|
||||
const base = '/config/device'
|
||||
|
||||
// ============ 枚举常量(与后端 Model.Entity.Config 枚举一一对应) ============
|
||||
|
||||
// 通讯协议(IotDeviceProtocolEnum)
|
||||
export const PROTOCOL_TYPES = {
|
||||
1: 'Modbus TCP',
|
||||
2: 'Modbus RTU',
|
||||
3: 'S7',
|
||||
4: 'TCP',
|
||||
5: '串口'
|
||||
}
|
||||
export const protocolOptions = Object.entries(PROTOCOL_TYPES).map(([value, label]) => ({ value: Number(value), label }))
|
||||
export const protocolLabel = (v) => PROTOCOL_TYPES[v] || '-'
|
||||
|
||||
// 在线状态(IotDeviceOnlineStatusEnum)
|
||||
export const ONLINE_STATUS = {
|
||||
0: { label: '离线', tag: 'info' },
|
||||
1: { label: '在线', tag: 'success' },
|
||||
2: { label: '连接中', tag: 'warning' },
|
||||
3: { label: '通讯异常', tag: 'danger' }
|
||||
}
|
||||
export const onlineStatusLabel = (s) => ONLINE_STATUS[s]?.label || '离线'
|
||||
export const onlineStatusTag = (s) => ONLINE_STATUS[s]?.tag || 'info'
|
||||
|
||||
// ============ 设备接口 ============
|
||||
|
||||
// 设备列表(服务端分页;结果数组上附带 total 总记录数,来自响应头 X-Total-Count)
|
||||
export const getDeviceList = (params) => request.get(`${base}/list`, { params })
|
||||
|
||||
// 设备详情
|
||||
export const getDeviceDetail = (id) => request.get(`${base}/${id}`)
|
||||
|
||||
// 新增设备(后端校验编号唯一性,并在数据库建记录)
|
||||
export const addDevice = (data) => request.post(base, data)
|
||||
|
||||
// 修改设备
|
||||
export const updateDevice = (data) => request.put(base, data)
|
||||
|
||||
// 删除设备(软删除)
|
||||
export const deleteDevice = (id) => request.delete(`${base}/${id}`)
|
||||
|
||||
// 设备日志(分页;结果数组上附带 total)
|
||||
export const getDeviceLogs = (id, params) => request.get(`${base}/${id}/logs`, { params })
|
||||
|
||||
// 按物模型点下发指令:{ PointId, Value, IsSimulated }
|
||||
export const sendDeviceCommand = (id, data) => request.post(`${base}/${id}/command`, data)
|
||||
@@ -0,0 +1,54 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 后端路由前缀(GatewayController: [Route("api/config/gateway")])
|
||||
const base = '/config/gateway'
|
||||
|
||||
// ============ 枚举常量(与后端 IotDeviceProtocolEnum 一一对应,复用 device.js) ============
|
||||
export const PROTOCOL_TYPES = {
|
||||
1: 'Modbus TCP',
|
||||
2: 'Modbus RTU',
|
||||
3: 'S7',
|
||||
4: 'TCP',
|
||||
5: '串口'
|
||||
}
|
||||
export const protocolOptions = Object.entries(PROTOCOL_TYPES).map(([value, label]) => ({ value: Number(value), label }))
|
||||
export const protocolLabel = (v) => PROTOCOL_TYPES[v] || '-'
|
||||
|
||||
// 在线状态
|
||||
export const ONLINE_STATUS = {
|
||||
0: { label: '离线', tag: 'info' },
|
||||
1: { label: '在线', tag: 'success' },
|
||||
3: { label: '异常', tag: 'danger' }
|
||||
}
|
||||
export const onlineStatusLabel = (s) => ONLINE_STATUS[s]?.label || '离线'
|
||||
export const onlineStatusTag = (s) => ONLINE_STATUS[s]?.tag || 'info'
|
||||
|
||||
// 校验位
|
||||
export const PARITY_OPTIONS = [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 1, label: 'Odd' },
|
||||
{ value: 2, label: 'Even' }
|
||||
]
|
||||
|
||||
// ============ 网关接口 ============
|
||||
|
||||
// 网关列表(分页;X-Total-Count)
|
||||
export const getGatewayList = (params) => request.get(`${base}/list`, { params })
|
||||
|
||||
// 网关详情
|
||||
export const getGatewayDetail = (id) => request.get(`${base}/${id}`)
|
||||
|
||||
// 新增网关
|
||||
export const addGateway = (data) => request.post(base, data)
|
||||
|
||||
// 修改网关
|
||||
export const updateGateway = (data) => request.put(base, data)
|
||||
|
||||
// 删除网关
|
||||
export const deleteGateway = (id) => request.delete(`${base}/${id}`)
|
||||
|
||||
// 测试连接
|
||||
export const testGatewayConnection = (id) => request.post(`${base}/${id}/test-connection`)
|
||||
|
||||
// 下拉选项(设备表单用)
|
||||
export const getGatewayOptions = () => request.get(`${base}/options`)
|
||||
@@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 组织架构接口(OrganizationController: api/system/organization,需 user:manage 权限)
|
||||
const base = '/system/organization'
|
||||
|
||||
// 组织类型(OrgType)
|
||||
export const ORG_TYPES = {
|
||||
1: { label: '公司', tag: 'danger', icon: 'OfficeBuilding' },
|
||||
2: { label: '实验室', tag: 'warning', icon: 'School' },
|
||||
3: { label: '部门', tag: 'primary', icon: 'Files' },
|
||||
4: { label: '班组', tag: 'success', icon: 'UserFilled' }
|
||||
}
|
||||
export const orgTypeLabel = (t) => ORG_TYPES[t]?.label || '-'
|
||||
export const orgTypeTag = (t) => ORG_TYPES[t]?.tag || 'info'
|
||||
export const orgTypeOptions = Object.entries(ORG_TYPES).map(([value, o]) => ({ value: Number(value), label: o.label }))
|
||||
|
||||
// 班组班次(ShiftType)
|
||||
export const SHIFT_TYPES = {
|
||||
0: '非班组',
|
||||
1: '白班',
|
||||
2: '夜班'
|
||||
}
|
||||
export const shiftLabel = (s) => SHIFT_TYPES[s] || '-'
|
||||
export const shiftOptions = [
|
||||
{ value: 1, label: '白班' },
|
||||
{ value: 2, label: '夜班' }
|
||||
]
|
||||
|
||||
// 完整组织树(嵌套子级)
|
||||
export const getOrgTree = () => request.get(`${base}/tree`)
|
||||
|
||||
// 组织下拉选项(平铺)
|
||||
export const getOrgOptions = () => request.get(`${base}/options`)
|
||||
|
||||
// 新增组织节点(ParentId 传父级 Id,根节点传 "0")
|
||||
export const addOrg = (data) => request.post(base, data)
|
||||
|
||||
// 修改组织节点
|
||||
export const updateOrg = (data) => request.put(base, data)
|
||||
|
||||
// 删除组织节点(有子级或挂靠用户时不可删)
|
||||
export const deleteOrg = (id) => request.delete(`${base}/${id}`)
|
||||
@@ -0,0 +1,44 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 后端路由前缀(ProductController: [Route("api/config/product")])
|
||||
const base = '/config/product'
|
||||
|
||||
// ============ 枚举常量(与后端 Model.Entity.Config 枚举一一对应) ============
|
||||
|
||||
// 通讯协议(IotDeviceProtocolEnum)
|
||||
export const PROTOCOL_TYPES = {
|
||||
1: 'Modbus TCP',
|
||||
2: 'Modbus RTU',
|
||||
3: 'S7',
|
||||
4: 'TCP',
|
||||
5: '串口'
|
||||
}
|
||||
export const protocolOptions = Object.entries(PROTOCOL_TYPES).map(([value, label]) => ({ value: Number(value), label }))
|
||||
export const protocolLabel = (v) => PROTOCOL_TYPES[v] || '-'
|
||||
|
||||
// ============ 产品分类接口 ============
|
||||
|
||||
export const getProductCategories = () => request.get(`${base}/category/list`)
|
||||
export const addProductCategory = (data) => request.post(`${base}/category/add`, data)
|
||||
export const updateProductCategory = (data) => request.put(`${base}/category/update`, data)
|
||||
export const deleteProductCategory = (id) => request.delete(`${base}/category/${id}`)
|
||||
|
||||
// ============ 产品接口 ============
|
||||
|
||||
// 产品下拉选项(设备选择所属产品用)
|
||||
export const getProductOptions = () => request.get(`${base}/options`)
|
||||
|
||||
// 产品列表(服务端分页;结果数组上附带 total,来自响应头 X-Total-Count)
|
||||
export const getProductList = (params) => request.get(`${base}/list`, { params })
|
||||
|
||||
// 产品详情
|
||||
export const getProductDetail = (id) => request.get(`${base}/${id}`)
|
||||
|
||||
// 新增产品(后端校验型号唯一)
|
||||
export const addProduct = (data) => request.post(base, data)
|
||||
|
||||
// 修改产品
|
||||
export const updateProduct = (data) => request.put(base, data)
|
||||
|
||||
// 删除产品(软删除)
|
||||
export const deleteProduct = (id) => request.delete(`${base}/${id}`)
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 角色权限管理接口(RoleController: api/system/role,需 user:manage 权限)
|
||||
const base = '/system/role'
|
||||
|
||||
// 角色列表(分页)
|
||||
export const getRoleList = (params) => request.get(`${base}/list`, { params })
|
||||
|
||||
// 角色下拉选项
|
||||
export const getRoleOptions = () => request.get(`${base}/options`)
|
||||
|
||||
// 全部权限列表(分配权限用)
|
||||
export const getPermissionList = () => request.get(`${base}/permissions`)
|
||||
|
||||
// 角色详情(含权限Id列表)
|
||||
export const getRoleDetail = (id) => request.get(`${base}/${id}`)
|
||||
|
||||
// 新增角色
|
||||
export const addRole = (data) => request.post(base, data)
|
||||
|
||||
// 修改角色
|
||||
export const updateRole = (data) => request.put(base, data)
|
||||
|
||||
// 删除角色(系统内置角色不可删)
|
||||
export const deleteRole = (id) => request.delete(`${base}/${id}`)
|
||||
|
||||
// 分配权限(全量覆盖)
|
||||
export const assignRolePermissions = (id, permissionIds) => request.post(`${base}/${id}/permissions`, permissionIds)
|
||||
|
||||
// 数据范围常量(DataScope:1=全部, 2=本组织及下级)
|
||||
export const DATA_SCOPES = {
|
||||
1: { label: '全部', tag: 'danger' },
|
||||
2: { label: '本组织及下级', tag: 'warning' }
|
||||
}
|
||||
export const dataScopeLabel = (s) => DATA_SCOPES[s]?.label || '-'
|
||||
export const dataScopeTag = (s) => DATA_SCOPES[s]?.tag || 'info'
|
||||
@@ -0,0 +1,52 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ==================== 设备统计报表 ====================
|
||||
|
||||
// 设备统计概览
|
||||
export function getDeviceStatistics() {
|
||||
return request.get('/inspection/report/device/statistics')
|
||||
}
|
||||
|
||||
// 按分类统计
|
||||
export function getCategoryStatistics() {
|
||||
return request.get('/inspection/report/device/category')
|
||||
}
|
||||
|
||||
// 按部门统计
|
||||
export function getDepartmentStatistics() {
|
||||
return request.get('/inspection/report/device/department')
|
||||
}
|
||||
|
||||
// ==================== 设备维护报表 ====================
|
||||
|
||||
// 维护统计概览
|
||||
export function getMaintenanceStatistics() {
|
||||
return request.get('/inspection/report/maintenance/statistics')
|
||||
}
|
||||
|
||||
// 维护超期明细
|
||||
export function getMaintenanceOverdueDetails() {
|
||||
return request.get('/inspection/report/maintenance/overdue')
|
||||
}
|
||||
|
||||
// ==================== 维修类报表 ====================
|
||||
|
||||
// 维修统计概览
|
||||
export function getRepairStatistics() {
|
||||
return request.get('/inspection/report/repair/statistics')
|
||||
}
|
||||
|
||||
// 故障类型分布
|
||||
export function getFaultTypeDistribution() {
|
||||
return request.get('/inspection/report/repair/fault-type')
|
||||
}
|
||||
|
||||
// 维修工时分析
|
||||
export function getRepairHoursAnalysis() {
|
||||
return request.get('/inspection/report/repair/hours-analysis')
|
||||
}
|
||||
|
||||
// 维修记录明细
|
||||
export function getRepairRecordDetails() {
|
||||
return request.get('/inspection/report/repair/records')
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 后端路由前缀(ThingPointController: [Route("api/config/thing-point")])
|
||||
const base = '/config/thing-point'
|
||||
|
||||
// ============ 枚举常量(与后端 Model.Entity.Config.ThingEnums 对应) ============
|
||||
|
||||
// 归属类型(ThingOwnerTypeEnum)
|
||||
export const OWNER_TYPES = { 1: '产品', 2: '设备' }
|
||||
|
||||
// 读写权限(ThingRwEnum)
|
||||
export const RW_TYPES = {
|
||||
1: { label: '只读' },
|
||||
2: { label: '只写' },
|
||||
3: { label: '读写' }
|
||||
}
|
||||
export const rwOptions = Object.entries(RW_TYPES).map(([value, v]) => ({ value: Number(value), label: v.label }))
|
||||
export const rwLabel = (v) => RW_TYPES[v]?.label || '-'
|
||||
|
||||
// 寄存器类型(ThingRegisterTypeEnum)
|
||||
export const REGISTER_TYPES = {
|
||||
1: { label: '线圈', canWrite: true },
|
||||
2: { label: '保持寄存器', canWrite: true },
|
||||
3: { label: '输入寄存器', canWrite: false }
|
||||
}
|
||||
export const registerOptions = Object.entries(REGISTER_TYPES).map(([value, v]) => ({ value: Number(value), label: v.label }))
|
||||
export const registerLabel = (v) => REGISTER_TYPES[v]?.label || '-'
|
||||
|
||||
// 数据类型(ThingDataTypeEnum)
|
||||
export const DATA_TYPES = {
|
||||
1: { label: 'Bool' },
|
||||
2: { label: 'Int16' },
|
||||
3: { label: 'UInt16' },
|
||||
4: { label: 'Int32' },
|
||||
5: { label: 'Float' }
|
||||
}
|
||||
export const dataOptions = Object.entries(DATA_TYPES).map(([value, v]) => ({ value: Number(value), label: v.label }))
|
||||
export const dataLabel = (v) => DATA_TYPES[v]?.label || '-'
|
||||
|
||||
// ============ 物模型点接口 ============
|
||||
|
||||
// 点列表(ownerType: 1=产品 2=设备)
|
||||
export const getThingPoints = (ownerType, ownerId, keyword) => request.get(`${base}/list`, {
|
||||
params: { ownerType, ownerId, keyword: keyword || undefined }
|
||||
})
|
||||
|
||||
// 点详情
|
||||
export const getThingPoint = (id) => request.get(`${base}/${id}`)
|
||||
|
||||
// 新增点
|
||||
export const addThingPoint = (data) => request.post(base, data)
|
||||
|
||||
// 修改点
|
||||
export const updateThingPoint = (data) => request.put(base, data)
|
||||
|
||||
// 删除点
|
||||
export const deleteThingPoint = (id) => request.delete(`${base}/${id}`)
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 用户管理接口(UserController: api/system/user,需 user:manage 权限)
|
||||
const base = '/system/user'
|
||||
|
||||
// 用户列表(分页;关键字/角色筛选;X-Total-Count)
|
||||
export const getUserList = (params) => request.get(`${base}/list`, { params })
|
||||
|
||||
// 用户下拉选项(启用中的用户)
|
||||
export const getUserOptions = () => request.get(`${base}/options`)
|
||||
|
||||
// 用户详情
|
||||
export const getUserDetail = (id) => request.get(`${base}/${id}`)
|
||||
|
||||
// 新增用户:{ UserName, RealName, InitialPassword, RoleIds, ... }
|
||||
export const addUser = (data) => request.post(base, data)
|
||||
|
||||
// 修改用户(RoleIds=null 不改角色)
|
||||
export const updateUser = (data) => request.put(base, data)
|
||||
|
||||
// 删除用户(软删除)
|
||||
export const deleteUser = (id) => request.delete(`${base}/${id}`)
|
||||
|
||||
// 分配角色(全量覆盖)
|
||||
export const assignUserRoles = (id, roleIds) => request.post(`${base}/${id}/roles`, roleIds)
|
||||
|
||||
// 重置密码
|
||||
export const resetUserPassword = (id, newPassword) => request.post(`${base}/${id}/reset-password`, { NewPassword: newPassword })
|
||||
|
||||
// 启用/禁用
|
||||
export const setUserEnabled = (id, enabled) => request.post(`${base}/${id}/enabled`, { Enabled: enabled })
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="point-send-form">
|
||||
<div class="ps-toolbar">
|
||||
<span class="ps-count">
|
||||
共 {{ points.length }} 个点,可下发 {{ writablePoints.length }} 个
|
||||
<span v-if="points.length" class="ps-tip">(只读点不支持下发;Int32/Float 从地址起连写 2 个寄存器)</span>
|
||||
</span>
|
||||
<el-button link type="primary" size="small" :loading="loading" @click="fetchPoints">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="points" v-loading="loading" size="small" border stripe>
|
||||
<el-table-column prop="Code" label="编码" width="120" />
|
||||
<el-table-column prop="Name" label="名称" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="寄存器" width="90">
|
||||
<template #default="{ row }">{{ registerLabel(row.RegisterType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="地址" width="70" align="center">
|
||||
<template #default="{ row }">0x{{ dec2hex(row.Address) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="读写" width="60" align="center">
|
||||
<template #default="{ row }">{{ rwLabel(row.Rw) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="84" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.Rw === 1" size="small" type="info">只读</el-tag>
|
||||
<el-tag v-else size="small" type="success">可下发</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下发值" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="row.DataType === 1"
|
||||
:model-value="row._sendValue === 1"
|
||||
active-text="开"
|
||||
@change="row._sendValue = $event ? 1 : 0"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="row._sendValue"
|
||||
:precision="row.DataType === 5 ? 1 : 0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="60" align="center">
|
||||
<template #default="{ row }">{{ row.Unit || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link type="primary"
|
||||
:disabled="row.Rw === 1"
|
||||
@click="onSend(row)"
|
||||
>下发</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 空状态区分:无点 / 点均不可下发 -->
|
||||
<el-empty
|
||||
v-if="!loading && points.length === 0"
|
||||
description="该设备暂无物模型点,请先在「物模型点」标签页为该设备添加数据点"
|
||||
:image-size="60"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="!loading && writablePoints.length === 0"
|
||||
title="已添加的点均为只读,暂无可下发点(可将点读写属性改为「只写/读写」)"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="ps-feedback"
|
||||
/>
|
||||
|
||||
<!-- 下发反馈 -->
|
||||
<el-alert v-if="feedback.text" :title="feedback.text" :type="feedback.type" :closable="false" class="ps-feedback" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getThingPoints, registerLabel, rwLabel } from '@/api/thingPoint'
|
||||
import { sendDeviceCommand } from '@/api/device'
|
||||
|
||||
const props = defineProps({
|
||||
deviceId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const points = ref([])
|
||||
const feedback = ref({ text: '', type: 'success' })
|
||||
|
||||
// 只读(1)的点不能下发;只写/读写都可以(Int32/Float 走 FC16 连写 2 个寄存器,已支持)
|
||||
const writablePoints = computed(() => points.value.filter(p => p.Rw !== 1))
|
||||
|
||||
function dec2hex(n) {
|
||||
return (Number(n) || 0).toString(16).toUpperCase().padStart(4, '0')
|
||||
}
|
||||
|
||||
async function fetchPoints() {
|
||||
loading.value = true
|
||||
try {
|
||||
const pts = (await getThingPoints(2, props.deviceId)) || []
|
||||
// 显示设备全部点(含只读),便于与「物模型点」标签页对照
|
||||
points.value = pts.map(p => ({ ...p, _sendValue: p.DataType === 1 ? 1 : 0 }))
|
||||
} catch (e) {
|
||||
points.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSend(row) {
|
||||
try {
|
||||
await sendDeviceCommand(props.deviceId, {
|
||||
Id: props.deviceId,
|
||||
PointId: row.Id,
|
||||
Value: row._sendValue ?? 0,
|
||||
IsSimulated: false
|
||||
})
|
||||
// res 为后端返回的 Data(无数据时返回 undefined),消息提示走统一处理
|
||||
feedback.value = { text: `已下发至「${row.Name || row.Code}」`, type: 'success' }
|
||||
ElMessage.success(`指令已下发:${row.Name || row.Code}`)
|
||||
} catch (e) {
|
||||
feedback.value = { text: e?.message || '下发失败', type: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.deviceId, (val) => { if (val) fetchPoints() })
|
||||
onMounted(() => { if (props.deviceId) fetchPoints() })
|
||||
|
||||
defineExpose({ refresh: fetchPoints })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ps-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ps-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.ps-tip {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.ps-feedback {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="thing-alarm-panel">
|
||||
<div class="tp-toolbar">
|
||||
<el-button type="primary" size="small" icon="Plus" @click="openAdd">新增告警规则</el-button>
|
||||
<span v-if="rules.length" class="tp-count">共 {{ rules.length }} 条规则</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="rules" v-loading="loading" size="small" border stripe>
|
||||
<el-table-column prop="RuleName" label="规则名称" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="绑定点" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.PointCode" size="small" type="info">{{ row.PointCode }}</el-tag>
|
||||
<span v-else>整{{ ownerTypeLabel }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="触发条件" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ metricLabel(row.Metric) }} {{ row.Operator }} {{ row.Threshold }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="级别" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="levelTagType(row.AlarmLevel)" size="small">{{ row.AlarmLevel }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.Enabled" size="small" @change="onToggle(row, $event)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '编辑告警规则' : '新增告警规则'" width="520px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rulesForm" label-width="100px">
|
||||
<el-form-item label="规则名称" prop="RuleName">
|
||||
<el-input v-model="form.RuleName" placeholder="如:温度超限报警" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="监测指标" prop="Metric">
|
||||
<el-select v-model="form.Metric" placeholder="请选择该归属下的点" style="width: 100%">
|
||||
<el-option v-for="p in pointOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="触发条件" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-select v-model="form.Operator" placeholder="运算符" style="width: 45%">
|
||||
<el-option v-for="op in operators" :key="op" :label="op" :value="op" />
|
||||
</el-select>
|
||||
<el-input-number v-model="form.Threshold" :precision="1" :step="1" style="width: 55%" placeholder="阈值" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="告警级别" prop="AlarmLevel">
|
||||
<el-select v-model="form.AlarmLevel" style="width: 100%">
|
||||
<el-option v-for="l in levels" :key="l" :label="l" :value="l" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.Enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getOwnerAlertRules, addAlertRule, updateAlertRule, deleteAlertRule, setAlertRuleEnabled } from '@/api/alertRule'
|
||||
import { getThingPoints } from '@/api/thingPoint'
|
||||
|
||||
const props = defineProps({
|
||||
ownerType: { type: Number, required: true }, // 1=产品 2=设备
|
||||
ownerId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const ownerTypeLabel = computed(() => (props.ownerType === 1 ? '产品' : '设备'))
|
||||
|
||||
const operators = ['>', '>=', '<', '<=', '=', '≠']
|
||||
const levels = ['信息', '警告', '紧急']
|
||||
const levelTagType = (l) => ({ '信息': 'info', '警告': 'warning', '紧急': 'danger' }[l] || 'info')
|
||||
|
||||
const loading = ref(false)
|
||||
const rules = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
const pointOptions = ref([])
|
||||
|
||||
const defaultForm = () => ({
|
||||
Id: '', RuleName: '', DeviceType: '', Metric: '', Operator: '>',
|
||||
Threshold: null, AlarmLevel: '警告', Enabled: true, Remark: '',
|
||||
OwnerType: props.ownerType, OwnerId: props.ownerId, PointCode: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const rulesForm = {
|
||||
RuleName: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
|
||||
Metric: [{ required: true, message: '请选择监测指标', trigger: 'change' }],
|
||||
AlarmLevel: [{ required: true, message: '请选择告警级别', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const metricLabel = (m) => pointOptions.value.find(p => p.value === m)?.label || m || '-'
|
||||
|
||||
async function fetchRules() {
|
||||
loading.value = true
|
||||
try {
|
||||
rules.value = (await getOwnerAlertRules(props.ownerType, props.ownerId)) || []
|
||||
} catch (e) {
|
||||
rules.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPoints() {
|
||||
try {
|
||||
const pts = (await getThingPoints(props.ownerType, props.ownerId)) || []
|
||||
pointOptions.value = pts.map(p => ({ value: p.Code, label: `${p.Name || p.Code} (${p.Code})` }))
|
||||
} catch (e) {
|
||||
pointOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, defaultForm())
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
OwnerType: props.ownerType,
|
||||
OwnerId: Number(props.ownerId) || 0,
|
||||
// 从点选 Metric 时同步 PointCode(点编码即绑定编码)
|
||||
PointCode: form.Metric || null
|
||||
}
|
||||
if (payload.Id) {
|
||||
await updateAlertRule(payload)
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
delete payload.Id
|
||||
await addAlertRule(payload)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchRules()
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(row, enabled) {
|
||||
try {
|
||||
await setAlertRuleEnabled(row.Id, enabled)
|
||||
row.Enabled = enabled
|
||||
ElMessage.success(enabled ? '已启用' : '已停用')
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除规则「${row.RuleName}」吗?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
await deleteAlertRule(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchRules()
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
watch(() => props.ownerId, (val) => { if (val) { fetchPoints(); fetchRules() } })
|
||||
onMounted(() => { if (props.ownerId) { fetchPoints(); fetchRules() } })
|
||||
|
||||
defineExpose({ refresh: fetchRules })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tp-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tp-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="thing-point-table">
|
||||
<div class="tp-toolbar">
|
||||
<el-button type="primary" size="small" icon="Plus" @click="openAdd">新增点</el-button>
|
||||
<span v-if="points.length" class="tp-count">共 {{ points.length }} 个点</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="points" v-loading="loading" size="small" border stripe>
|
||||
<el-table-column prop="Code" label="编码" width="120" />
|
||||
<el-table-column prop="Name" label="名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="寄存器" width="90">
|
||||
<template #default="{ row }">{{ registerLabel(row.RegisterType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="读写" width="70" align="center">
|
||||
<template #default="{ row }">{{ rwLabel(row.Rw) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template #default="{ row }">{{ dataLabel(row.DataType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="地址" width="80" align="center">
|
||||
<template #default="{ row }">0x{{ dec2hex(row.Address) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="倍率" width="70" align="center">
|
||||
<template #default="{ row }">{{ row.Scale }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Unit" label="单位" width="60" align="center">
|
||||
<template #default="{ row }">{{ row.Unit || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.Enabled ? 'success' : 'info'" size="small">{{ row.Enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '编辑点' : '新增点'" width="560px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="点编码" prop="Code">
|
||||
<el-input v-model="form.Code" placeholder="如 TempPV / PowerOn" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="点名称" prop="Name">
|
||||
<el-input v-model="form.Name" placeholder="如 温度PV" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="寄存器类型" prop="RegisterType">
|
||||
<el-select v-model="form.RegisterType" style="width: 100%">
|
||||
<el-option v-for="o in registerOptions" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="读写权限" prop="Rw">
|
||||
<el-select v-model="form.Rw" style="width: 100%">
|
||||
<el-option v-for="o in rwOptions" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据类型" prop="DataType">
|
||||
<el-select v-model="form.DataType" style="width: 100%">
|
||||
<el-option v-for="o in dataOptions" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="寄存器地址" prop="Address">
|
||||
<el-input-number v-model="form.Address" :min="0" :max="65535" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="倍率 Scale">
|
||||
<el-input-number v-model="form.Scale" :min="0.0001" :precision="4" :step="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="偏移 Offset">
|
||||
<el-input-number v-model="form.Offset" :precision="4" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="form.Unit" placeholder="如 ℃ / %" maxlength="16" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序号">
|
||||
<el-input-number v-model="form.Sort" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="是否启用">
|
||||
<el-switch v-model="form.Enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getThingPoints, addThingPoint, updateThingPoint, deleteThingPoint,
|
||||
rwOptions, rwLabel, registerOptions, registerLabel, dataOptions, dataLabel
|
||||
} from '@/api/thingPoint'
|
||||
|
||||
const props = defineProps({
|
||||
ownerType: { type: Number, required: true }, // 1=产品 2=设备
|
||||
ownerId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const points = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
Id: '', Code: '', Name: '', OwnerType: props.ownerType, OwnerId: props.ownerId,
|
||||
RegisterType: 2, Rw: 1, DataType: 3, Address: 0,
|
||||
Scale: 1, Offset: 0, Unit: '', Enabled: true, Sort: 0, Remark: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const rules = {
|
||||
Code: [{ required: true, message: '请输入点编码', trigger: 'blur' }],
|
||||
Name: [{ required: true, message: '请输入点名称', trigger: 'blur' }],
|
||||
RegisterType: [{ required: true, message: '请选择寄存器类型', trigger: 'change' }],
|
||||
Rw: [{ required: true, message: '请选择读写权限', trigger: 'change' }],
|
||||
DataType: [{ required: true, message: '请选择数据类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function dec2hex(n) {
|
||||
return (Number(n) || 0).toString(16).toUpperCase().padStart(4, '0')
|
||||
}
|
||||
|
||||
async function fetchPoints() {
|
||||
loading.value = true
|
||||
try {
|
||||
points.value = (await getThingPoints(props.ownerType, props.ownerId)) || []
|
||||
} catch (e) {
|
||||
points.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, defaultForm(), { OwnerType: props.ownerType, OwnerId: props.ownerId })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id, Code: row.Code, Name: row.Name,
|
||||
RegisterType: row.RegisterType, Rw: row.Rw, DataType: row.DataType,
|
||||
Address: row.Address, Scale: row.Scale, Offset: row.Offset,
|
||||
Unit: row.Unit || '', Enabled: row.Enabled, Sort: row.Sort, Remark: row.Remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form, OwnerType: props.ownerType, OwnerId: props.ownerId }
|
||||
if (payload.Id) {
|
||||
await updateThingPoint(payload)
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
delete payload.Id
|
||||
await addThingPoint(payload)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchPoints()
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除点「${row.Code}」吗?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
await deleteThingPoint(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchPoints()
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
watch(() => props.ownerId, (val) => { if (val) fetchPoints() })
|
||||
onMounted(() => { if (props.ownerId) fetchPoints() })
|
||||
|
||||
defineExpose({ refresh: fetchPoints })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tp-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tp-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
/**
|
||||
* 按钮级权限指令:v-permission="'device:edit'" 或 v-permission="['device:edit','device:control']"
|
||||
* 无权限时移除元素(与 v-if 语义一致)
|
||||
*/
|
||||
export const permission = {
|
||||
mounted(el, binding) {
|
||||
const userStore = useUserStore()
|
||||
const value = binding.value
|
||||
const required = Array.isArray(value) ? value : [value]
|
||||
if (!userStore.hasAnyPermission(required)) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupDirectives(app) {
|
||||
app.directive('permission', permission)
|
||||
}
|
||||
+94
-6
@@ -13,34 +13,113 @@
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown>
|
||||
<el-dropdown @command="onCommand">
|
||||
<span class="user-info">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>管理员</span>
|
||||
<span>{{ userStore.displayName }}</span>
|
||||
<el-tag v-if="userStore.roleNames" size="small" type="info" class="role-tag">{{ userStore.roleNames }}</el-tag>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>个人中心</el-dropdown-item>
|
||||
<el-dropdown-item divided>退出登录</el-dropdown-item>
|
||||
<el-dropdown-item disabled>
|
||||
<span class="dropdown-username">{{ userStore.userInfo?.UserName }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="change-password">修改密码</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog v-model="pwdVisible" title="修改密码" width="420px" destroy-on-close>
|
||||
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="80px">
|
||||
<el-form-item label="原密码" prop="OldPassword">
|
||||
<el-input v-model="pwdForm.OldPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="NewPassword">
|
||||
<el-input v-model="pwdForm.NewPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="Confirm">
|
||||
<el-input v-model="pwdForm.Confirm" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="pwdVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pwdSaving" @click="onChangePassword">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Fold, Expand, User } from '@element-plus/icons-vue'
|
||||
import { useAppStore } from '@/store/app'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { changePassword } from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return route.matched.filter(item => item.meta?.title)
|
||||
})
|
||||
|
||||
// ---------- 下拉命令 ----------
|
||||
function onCommand(cmd) {
|
||||
if (cmd === 'logout') onLogout()
|
||||
else if (cmd === 'change-password') pwdVisible.value = true
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await userStore.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
// ---------- 修改密码 ----------
|
||||
const pwdVisible = ref(false)
|
||||
const pwdSaving = ref(false)
|
||||
const pwdFormRef = ref(null)
|
||||
const pwdForm = reactive({ OldPassword: '', NewPassword: '', Confirm: '' })
|
||||
|
||||
const pwdRules = {
|
||||
OldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
NewPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '新密码长度不能少于 6 位', trigger: 'blur' }
|
||||
],
|
||||
Confirm: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== pwdForm.NewPassword) callback(new Error('两次输入的密码不一致'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function onChangePassword() {
|
||||
await pwdFormRef.value.validate()
|
||||
pwdSaving.value = true
|
||||
try {
|
||||
await changePassword({ OldPassword: pwdForm.OldPassword, NewPassword: pwdForm.NewPassword })
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
pwdVisible.value = false
|
||||
await userStore.logout()
|
||||
router.replace('/login')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
pwdSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -84,4 +163,13 @@ const breadcrumbs = computed(() => {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.dropdown-username {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
+14
-4
@@ -39,8 +39,7 @@
|
||||
<template #title>{{ child.meta?.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
</el-menu>
|
||||
</template> </el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,20 +48,31 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/store/app'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
// 按权限过滤菜单:meta.permissions 任一满足才显示;无权限标记的菜单登录即可见
|
||||
function hasPerm(meta) {
|
||||
const required = meta?.permissions
|
||||
if (!required || required.length === 0) return true
|
||||
return userStore.hasAnyPermission(required)
|
||||
}
|
||||
|
||||
const menuRoutes = computed(() => {
|
||||
return router.options.routes
|
||||
.filter(r => r.component && r.path !== '/:pathMatch(.*)*')
|
||||
.filter(r => r.component && r.path !== '/:pathMatch(.*)*' && !r.meta?.public)
|
||||
.map(r => ({
|
||||
...r,
|
||||
children: (r.children || []).filter(c => !c.path.includes(':'))
|
||||
children: (r.children || []).filter(c => !c.path.includes(':') && hasPerm(c.meta))
|
||||
}))
|
||||
// 父级自身也要过权限;子菜单全被过滤掉的分组隐藏
|
||||
.filter(r => hasPerm(r.meta) && (!r.children || r.children.length > 0))
|
||||
})
|
||||
|
||||
function resolvePath(basePath, childPath) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import { setupDirectives } from './directives/permission'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -14,6 +15,7 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
setupDirectives(app)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
|
||||
+70
-8
@@ -2,6 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Layout from '@/layout/Layout.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/Index.vue'),
|
||||
meta: { title: '登录', public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
@@ -109,6 +115,24 @@ const routes = [
|
||||
component: () => import('@/views/inspection/report/Index.vue'),
|
||||
meta: { title: '统计报表', icon: 'DataLine' }
|
||||
},
|
||||
{
|
||||
path: 'report/device',
|
||||
name: 'DeviceStatistics',
|
||||
component: () => import('@/views/inspection/report/DeviceStatistics.vue'),
|
||||
meta: { title: '设备统计报表', icon: 'Monitor' }
|
||||
},
|
||||
{
|
||||
path: 'report/maintenance',
|
||||
name: 'MaintenanceReport',
|
||||
component: () => import('@/views/inspection/report/MaintenanceReport.vue'),
|
||||
meta: { title: '设备维护报表', icon: 'Setting' }
|
||||
},
|
||||
{
|
||||
path: 'report/repair',
|
||||
name: 'RepairReport',
|
||||
component: () => import('@/views/inspection/report/RepairReport.vue'),
|
||||
meta: { title: '维修类报表', icon: 'Tools' }
|
||||
},
|
||||
{
|
||||
path: 'bigscreen',
|
||||
name: 'BigScreen',
|
||||
@@ -125,7 +149,7 @@ const routes = [
|
||||
path: 'patrol',
|
||||
name: 'PatrolManage',
|
||||
component: () => import('@/views/inspection/patrol/Index.vue'),
|
||||
meta: { title: '巡检管理', icon: 'Guide' }
|
||||
meta: { title: '巡检管理', icon: 'Guide', permissions: ['inspection:manage'] }
|
||||
},
|
||||
{
|
||||
path: 'patrol/template',
|
||||
@@ -182,25 +206,25 @@ const routes = [
|
||||
path: 'product',
|
||||
name: 'ProductManage',
|
||||
component: () => import('@/views/config/product/Index.vue'),
|
||||
meta: { title: '产品管理', icon: 'Goods' }
|
||||
meta: { title: '产品管理', icon: 'Goods', permissions: ['device:view'] }
|
||||
},
|
||||
{
|
||||
path: 'product/category',
|
||||
name: 'ProductCategory',
|
||||
component: () => import('@/views/config/product/Category.vue'),
|
||||
meta: { title: '产品分类', icon: 'Menu' }
|
||||
meta: { title: '产品分类', icon: 'Menu', permissions: ['device:view'] }
|
||||
},
|
||||
{
|
||||
path: 'device',
|
||||
name: 'DeviceManage',
|
||||
component: () => import('@/views/config/device/Index.vue'),
|
||||
meta: { title: '设备管理', icon: 'Cpu' }
|
||||
meta: { title: '设备管理', icon: 'Cpu', permissions: ['device:view'] }
|
||||
},
|
||||
{
|
||||
path: 'gateway',
|
||||
name: 'GatewayManage',
|
||||
component: () => import('@/views/config/gateway/Index.vue'),
|
||||
meta: { title: '网关管理', icon: 'Connection' }
|
||||
meta: { title: '网关管理', icon: 'Connection', permissions: ['device:view'] }
|
||||
},
|
||||
{
|
||||
path: 'protocol/certificate',
|
||||
@@ -269,13 +293,13 @@ const routes = [
|
||||
path: 'role',
|
||||
name: 'RoleManage',
|
||||
component: () => import('@/views/system/role/Index.vue'),
|
||||
meta: { title: '角色权限管理', icon: 'Lock' }
|
||||
meta: { title: '角色权限管理', icon: 'Lock', permissions: ['user:manage'] }
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'AuditLog',
|
||||
component: () => import('@/views/system/audit/Index.vue'),
|
||||
meta: { title: '操作审计日志', icon: 'Notebook' }
|
||||
meta: { title: '操作审计日志', icon: 'Notebook', permissions: ['user:manage'] }
|
||||
},
|
||||
{
|
||||
path: 'organization',
|
||||
@@ -287,7 +311,7 @@ const routes = [
|
||||
path: 'user',
|
||||
name: 'UserManage',
|
||||
component: () => import('@/views/system/user/Index.vue'),
|
||||
meta: { title: '用户管理', icon: 'User' }
|
||||
meta: { title: '用户管理', icon: 'User', permissions: ['user:manage'] }
|
||||
},
|
||||
{
|
||||
path: 'dict',
|
||||
@@ -334,4 +358,42 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
// ---------- 全局守卫:登录校验 + 权限校验 ----------
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 公开页面(登录页)直接放行;已登录访问登录页则回首页
|
||||
if (to.meta.public) {
|
||||
if (userStore.isLoggedIn && to.path === '/login') return '/'
|
||||
return true
|
||||
}
|
||||
|
||||
// 未登录 → 跳登录页(带上回跳地址)
|
||||
if (!userStore.isLoggedIn) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// 已登录但权限未加载(页面刷新场景)→ 拉取当前用户
|
||||
if (userStore.permissions.length === 0 && !userStore.userInfo) {
|
||||
try {
|
||||
await userStore.fetchMe()
|
||||
} catch {
|
||||
userStore.reset()
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
|
||||
// 路由级权限校验(meta.permissions 任一满足即放行)
|
||||
const required = to.meta.permissions
|
||||
if (required && required.length > 0 && !userStore.hasAnyPermission(required)) {
|
||||
ElMessage.warning('没有访问该页面的权限')
|
||||
return '/'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi, logout as logoutApi, getMe } from '@/api/auth'
|
||||
|
||||
/**
|
||||
* 认证状态:token / 用户信息 / 权限列表(登录成功后由后端签发,权限随 JWT Claims 校验)
|
||||
*/
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const userInfo = ref(null)
|
||||
const permissions = ref([])
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const displayName = computed(() => {
|
||||
const u = userInfo.value
|
||||
return u?.RealName || u?.UserName || '未登录'
|
||||
})
|
||||
const roleNames = computed(() => userInfo.value?.RoleNames || '')
|
||||
|
||||
function hasPermission(code) {
|
||||
return permissions.value.includes(code) || permissions.value.includes('*')
|
||||
}
|
||||
function hasAnyPermission(codes) {
|
||||
if (!codes || codes.length === 0) return true
|
||||
return codes.some((c) => hasPermission(c))
|
||||
}
|
||||
|
||||
async function login(form) {
|
||||
const res = await loginApi(form)
|
||||
token.value = res.Token
|
||||
userInfo.value = res.User
|
||||
permissions.value = res.Permissions || []
|
||||
localStorage.setItem('token', res.Token)
|
||||
return res
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const res = await getMe()
|
||||
userInfo.value = res.User
|
||||
permissions.value = res.Permissions || []
|
||||
return res
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await logoutApi() } catch { /* 审计失败不影响退出 */ }
|
||||
reset()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
permissions.value = []
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, userInfo, permissions, isLoggedIn, displayName, roleNames, hasPermission, hasAnyPermission, login, fetchMe, logout, reset }
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 通用格式化工具(asset 台账视图与告警页共用)
|
||||
*/
|
||||
|
||||
// 日期:YYYY-MM-DD(兼容 ISO 字符串 / Date)
|
||||
export function fmtDate(value) {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return String(value)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
// 日期时间:YYYY-MM-DD HH:mm:ss
|
||||
export function fmtDateTime(value) {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return String(value)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${fmtDate(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
// 文件大小(字节 → 可读单位)
|
||||
export function fmtFileSize(bytes) {
|
||||
if (bytes == null || bytes === '' || Number.isNaN(Number(bytes))) return '-'
|
||||
const n = Number(bytes)
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
// 是否已逾期(日期早于今天且非空)
|
||||
export function isOverdue(value) {
|
||||
if (!value) return false
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return false
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.getTime() < today.getTime()
|
||||
}
|
||||
+19
-1
@@ -34,7 +34,25 @@ request.interceptors.response.use(
|
||||
return data
|
||||
},
|
||||
error => {
|
||||
ElMessage.error(error.message || '网络错误')
|
||||
const status = error.response?.status
|
||||
if (status === 401) {
|
||||
// 未登录 / Token 失效:清凭证并跳登录页(避免死循环用 sessionStorage 标记只提示一次)
|
||||
if (!sessionStorage.getItem('authRedirecting')) {
|
||||
sessionStorage.setItem('authRedirecting', '1')
|
||||
ElMessage.error('登录已失效,请重新登录')
|
||||
localStorage.removeItem('token')
|
||||
const redirect = window.location.pathname + window.location.search
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(redirect)}`
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (status === 403) {
|
||||
ElMessage.error(error.response?.data?.Msg || '没有操作权限')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
// 后端统一返回 { Code, Msg, Data },HTTP 200 但业务失败时错误体也带 Msg
|
||||
const msg = error.response?.data?.Msg || error.message || '网络错误'
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,13 +2,629 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>设备管理</span>
|
||||
<div class="card-header">
|
||||
<span>设备管理({{ total }} 台)</span>
|
||||
<el-button type="primary" icon="Plus" @click="openAdd">添加设备</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="设备管理 - 功能开发中" />
|
||||
|
||||
<!-- 查找设备输入框 -->
|
||||
<div class="search-bar">
|
||||
<el-select v-model="searchProductId" placeholder="所属产品(全部)" clearable style="width: 200px" @change="onSearch">
|
||||
<el-option v-for="p in productOptions" :key="p.Id" :label="`${p.Model}${p.Name ? ' - ' + p.Name : ''}`" :value="p.Id" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="请输入设备编号 / 名称 / 类型查找设备"
|
||||
clearable
|
||||
style="width: 320px"
|
||||
@keyup.enter="onSearch"
|
||||
@clear="onSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 设备表格(右键弹菜单) -->
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
row-key="Id"
|
||||
@row-contextmenu="onRowContextmenu"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="Code" label="设备编号" width="130" />
|
||||
<el-table-column prop="Name" label="设备名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="DeviceType" label="设备类型" width="110" />
|
||||
<el-table-column label="所属产品" width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.ProductName" size="small" type="info">{{ row.ProductName }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属网关" width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.GatewayName || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="SlaveId" label="从站地址" width="90" align="center" />
|
||||
<el-table-column label="协议" width="110">
|
||||
<template #default="{ row }">{{ protocolLabel(row.ProtocolType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="onlineStatusTag(row.OnlineStatus)" size="small">
|
||||
{{ onlineStatusLabel(row.OnlineStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后采集" width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.LastCollectTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click.stop="openDetail(row)">详情</el-button>
|
||||
<el-button v-permission="'device:edit'" link type="primary" @click.stop="openEdit(row)">编辑</el-button>
|
||||
<el-dropdown v-permission="'device:edit'" trigger="click" @command="(cmd) => onMoreCommand(cmd, row)">
|
||||
<el-button link type="primary" @click.stop>
|
||||
更多<el-icon class="more-arrow"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logs"><el-icon><Document /></el-icon>设备日志</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided class="more-danger"><el-icon><Delete /></el-icon>删除设备</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<div
|
||||
v-show="ctxMenu.visible"
|
||||
class="ctx-menu"
|
||||
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="ctx-menu__item" @click="ctxDetail">
|
||||
<el-icon><View /></el-icon><span>设备详情</span>
|
||||
</div>
|
||||
<div class="ctx-menu__item" v-permission="'device:edit'" @click="ctxEdit">
|
||||
<el-icon><Edit /></el-icon><span>修改设备</span>
|
||||
</div>
|
||||
<div class="ctx-menu__item" @click="ctxLogs">
|
||||
<el-icon><Document /></el-icon><span>设备日志</span>
|
||||
</div>
|
||||
<div class="ctx-menu__item danger" v-permission="'device:edit'" @click="ctxDelete">
|
||||
<el-icon><Delete /></el-icon><span>删除设备</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<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, jumper"
|
||||
@current-change="fetchList"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑设备对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '修改设备' : '添加设备'" width="560px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="设备编号" prop="Code">
|
||||
<el-input v-model="form.Code" placeholder="请输入唯一设备编号" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备名称" prop="Name">
|
||||
<el-input v-model="form.Name" placeholder="请输入设备名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="DeviceType">
|
||||
<el-select v-model="form.DeviceType" placeholder="请选择设备类型" style="width: 100%">
|
||||
<el-option v-for="d in deviceTypes" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属产品">
|
||||
<el-select v-model="form.ProductId" placeholder="未分类" clearable style="width: 100%">
|
||||
<el-option v-for="p in productOptions" :key="p.Id" :label="`${p.Model}${p.Name ? ' - ' + p.Name : ''}`" :value="p.Id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属网关">
|
||||
<el-select v-model="form.GatewayId" placeholder="未关联" clearable style="width: 100%">
|
||||
<el-option v-for="g in gatewayOptions" :key="g.Id" :label="`${g.Code} - ${g.Name}`" :value="g.Id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="从站地址" prop="SlaveId">
|
||||
<el-input-number v-model="form.SlaveId" :min="1" :max="247" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="通讯协议" prop="ProtocolType">
|
||||
<el-select v-model="form.ProtocolType" style="width: 100%">
|
||||
<el-option v-for="p in protocolOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="制造商">
|
||||
<el-input v-model="form.Manufacturer" placeholder="请输入制造商" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用采集">
|
||||
<el-switch v-model="form.IsEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 设备详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="`设备详情 - ${detailDevice?.Name || ''}`" size="720px">
|
||||
<el-tabs v-if="detailDevice" v-model="detailTab">
|
||||
<el-tab-pane label="基本信息" name="info">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="设备编号">{{ detailDevice.Code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="设备名称">{{ detailDevice.Name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="设备类型">{{ detailDevice.DeviceType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属产品">{{ detailDevice.ProductName || '未分类' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属网关">{{ detailDevice.GatewayName || '未关联' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="从站地址">{{ detailDevice.SlaveId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="通讯协议">{{ protocolLabel(detailDevice.ProtocolType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在线状态">
|
||||
<el-tag :type="onlineStatusTag(detailDevice.OnlineStatus)" size="small">{{ onlineStatusLabel(detailDevice.OnlineStatus) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="制造商">{{ detailDevice.Manufacturer || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后采集">{{ fmtTime(detailDevice.LastCollectTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近错误" :span="2">{{ detailDevice.LastError || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detailDevice.Remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="物模型点" name="points">
|
||||
<ThingPointTable :owner-type="2" :owner-id="detailDevice.Id" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="告警" name="alarm">
|
||||
<ThingAlarmPanel :owner-type="2" :owner-id="detailDevice.Id" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-permission="'device:control'" label="下发指令" name="command">
|
||||
<PointSendForm ref="pointSendRef" :device-id="detailDevice.Id" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="看板" name="board">
|
||||
<div class="board-body">
|
||||
<div class="board-chart" ref="boardChartRef"></div>
|
||||
<el-empty v-if="!boardLoading && boardEmpty" description="该设备无采集数据(当前仅 BOX-xxx 模拟器设备有曲线)" :image-size="70" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 设备日志抽屉 -->
|
||||
<el-drawer v-model="logVisible" :title="`设备日志 - ${logDevice?.Name || ''}`" size="520px">
|
||||
<div class="log-toolbar">
|
||||
<el-button link type="primary" size="small" @click="fetchLogs(true)">刷新</el-button>
|
||||
<span class="log-count">共 {{ logTotal }} 条</span>
|
||||
</div>
|
||||
<el-timeline v-loading="logLoading" class="log-timeline">
|
||||
<el-timeline-item
|
||||
v-for="log in logList"
|
||||
:key="log.Id"
|
||||
:timestamp="fmtTime(log.LogTime)"
|
||||
:type="logTagType(log.Level)"
|
||||
>
|
||||
<el-tag :type="logTagType(log.Level)" size="small">{{ log.Level }}</el-tag>
|
||||
<span class="log-type">{{ log.LogType }}</span>
|
||||
<div class="log-message">{{ log.Message }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-if="!logLoading && logList.length === 0" description="暂无日志" />
|
||||
<div class="log-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="logPage"
|
||||
v-model:page-size="logPageSize"
|
||||
:total="logTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchLogs"
|
||||
@size-change="onLogSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现设备管理功能
|
||||
import { ref, reactive, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as echarts from 'echarts'
|
||||
import {
|
||||
getDeviceList, addDevice, updateDevice, deleteDevice, getDeviceLogs,
|
||||
protocolOptions, protocolLabel, onlineStatusLabel, onlineStatusTag
|
||||
} from '@/api/device'
|
||||
import { getProductOptions } from '@/api/product'
|
||||
import { getGatewayOptions } from '@/api/gateway'
|
||||
import { getCurve } from '@/api/dashboard'
|
||||
import ThingPointTable from '@/components/thing/ThingPointTable.vue'
|
||||
import ThingAlarmPanel from '@/components/thing/ThingAlarmPanel.vue'
|
||||
import PointSendForm from '@/components/thing/PointSendForm.vue'
|
||||
|
||||
// ---------- 列表 ----------
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const keyword = ref('')
|
||||
const searchProductId = ref('')
|
||||
const productOptions = ref([])
|
||||
const gatewayOptions = ref([])
|
||||
|
||||
async function fetchProductOptions() {
|
||||
try {
|
||||
productOptions.value = (await getProductOptions()) || []
|
||||
} catch (e) {
|
||||
productOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGatewayOptions() {
|
||||
try {
|
||||
gatewayOptions.value = (await getGatewayOptions()) || []
|
||||
} catch (e) {
|
||||
gatewayOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = (await getDeviceList({
|
||||
pageIndex: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value || undefined,
|
||||
productId: searchProductId.value || 0
|
||||
})) || []
|
||||
total.value = list.value.total || 0
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; fetchList() }
|
||||
function onReset() { keyword.value = ''; searchProductId.value = ''; page.value = 1; fetchList() }
|
||||
function onSizeChange() { page.value = 1; fetchList() }
|
||||
|
||||
// ---------- 新增 / 修改 ----------
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const deviceTypes = [
|
||||
{ value: 'TemperatureBox', label: '温度箱' },
|
||||
{ value: 'ChargeCabinet', label: '充放电柜' }
|
||||
]
|
||||
|
||||
const defaultForm = () => ({
|
||||
Id: '', Code: '', Name: '', DeviceType: '', ProductId: '', GatewayId: '',
|
||||
SlaveId: 1, ProtocolType: 1, Manufacturer: '', IsEnabled: true, Remark: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const formRules = {
|
||||
Code: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
|
||||
Name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
DeviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, defaultForm())
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id,
|
||||
Code: row.Code,
|
||||
Name: row.Name,
|
||||
DeviceType: row.DeviceType,
|
||||
ProductId: row.ProductId && row.ProductId !== '0' ? row.ProductId : '',
|
||||
GatewayId: row.GatewayId && row.GatewayId !== '0' ? row.GatewayId : '',
|
||||
SlaveId: row.SlaveId,
|
||||
ProtocolType: row.ProtocolType,
|
||||
Manufacturer: row.Manufacturer || '',
|
||||
IsEnabled: row.IsEnabled,
|
||||
Remark: row.Remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (form.Id) {
|
||||
await updateDevice({ ...form })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
const payload = { ...form }
|
||||
delete payload.Id
|
||||
await addDevice(payload)
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除设备「${row.Name}」吗?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
await deleteDevice(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
// ---------- 右键菜单 ----------
|
||||
const ctxMenu = reactive({ visible: false, x: 0, y: 0, row: null })
|
||||
|
||||
function onRowContextmenu(row, column, event) {
|
||||
event.preventDefault()
|
||||
ctxMenu.row = row
|
||||
ctxMenu.x = event.clientX
|
||||
ctxMenu.y = event.clientY
|
||||
ctxMenu.visible = true
|
||||
}
|
||||
|
||||
// 点击表格其它区域时收起右键菜单
|
||||
function onRowClick() { ctxMenu.visible = false }
|
||||
|
||||
function closeCtxMenu() { ctxMenu.visible = false }
|
||||
function ctxDetail() { closeCtxMenu(); openDetail(ctxMenu.row) }
|
||||
function ctxEdit() { closeCtxMenu(); openEdit(ctxMenu.row) }
|
||||
function ctxLogs() { closeCtxMenu(); openLogs(ctxMenu.row) }
|
||||
function ctxDelete() { closeCtxMenu(); onDelete(ctxMenu.row) }
|
||||
|
||||
// ---------- 操作列「更多」下拉 ----------
|
||||
function onMoreCommand(cmd, row) {
|
||||
if (cmd === 'logs') openLogs(row)
|
||||
else if (cmd === 'delete') onDelete(row)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
fetchProductOptions()
|
||||
fetchGatewayOptions()
|
||||
window.addEventListener('click', closeCtxMenu)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', closeCtxMenu)
|
||||
disposeBoard()
|
||||
})
|
||||
|
||||
// ---------- 设备详情 ----------
|
||||
const detailVisible = ref(false)
|
||||
const detailTab = ref('info')
|
||||
const detailDevice = ref(null)
|
||||
const pointSendRef = ref(null)
|
||||
|
||||
function openDetail(row) {
|
||||
detailDevice.value = row
|
||||
detailTab.value = 'info'
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 切到「下发指令」时重新拉取物模型点(在物模型点 tab 新增/修改后能看到最新数据)
|
||||
watch(detailTab, (tab) => {
|
||||
if (tab === 'board') nextTick(fetchBoard)
|
||||
if (tab === 'command') nextTick(() => pointSendRef.value?.refresh())
|
||||
})
|
||||
|
||||
// ---------- 看板(复用 dashboard/curve 接口) ----------
|
||||
const boardChartRef = ref(null)
|
||||
const boardLoading = ref(false)
|
||||
const boardEmpty = ref(true)
|
||||
let boardChart = null
|
||||
let boardTimer = null
|
||||
|
||||
function disposeBoard() {
|
||||
if (boardTimer) { clearInterval(boardTimer); boardTimer = null }
|
||||
if (boardChart) { boardChart.dispose(); boardChart = null }
|
||||
}
|
||||
|
||||
function initBoardChart() {
|
||||
if (!boardChartRef.value || boardChart) return
|
||||
boardChart = echarts.init(boardChartRef.value)
|
||||
boardChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['温度(℃)', '湿度(%)'] },
|
||||
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: [] },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '℃' },
|
||||
{ type: 'value', name: '%' }
|
||||
],
|
||||
series: [
|
||||
{ name: '温度(℃)', type: 'line', showSymbol: false, smooth: true, data: [], itemStyle: { color: '#f56c6c' } },
|
||||
{ name: '湿度(%)', type: 'line', showSymbol: false, smooth: true, yAxisIndex: 1, data: [], itemStyle: { color: '#409eff' } }
|
||||
]
|
||||
})
|
||||
window.addEventListener('resize', resizeBoard)
|
||||
}
|
||||
|
||||
function resizeBoard() { boardChart && boardChart.resize() }
|
||||
|
||||
async function fetchBoard() {
|
||||
if (!detailDevice.value) return
|
||||
boardLoading.value = true
|
||||
try {
|
||||
const rows = (await getCurve(detailDevice.value.Code, 5)) || []
|
||||
boardEmpty.value = rows.length === 0
|
||||
if (rows.length && boardChartRef.value) {
|
||||
initBoardChart()
|
||||
boardChart.setOption({
|
||||
xAxis: { data: rows.map(d => fmtTime(d.CreateTime)) },
|
||||
series: [
|
||||
{ data: rows.map(d => d.DeviceTemperature) },
|
||||
{ data: rows.map(d => d.DeviceHumidity) }
|
||||
]
|
||||
})
|
||||
} else {
|
||||
boardEmpty.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
boardEmpty.value = true
|
||||
} finally {
|
||||
boardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(detailVisible, () => disposeBoard())
|
||||
|
||||
// ---------- 设备日志 ----------
|
||||
const logVisible = ref(false)
|
||||
const logLoading = ref(false)
|
||||
const logList = ref([])
|
||||
const logTotal = ref(0)
|
||||
const logPage = ref(1)
|
||||
const logPageSize = ref(10)
|
||||
const logDevice = ref(null)
|
||||
|
||||
async function openLogs(row) {
|
||||
logDevice.value = row
|
||||
logPage.value = 1
|
||||
logVisible.value = true
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
async function fetchLogs(reset = false) {
|
||||
if (reset) logPage.value = 1
|
||||
if (!logDevice.value) return
|
||||
logLoading.value = true
|
||||
try {
|
||||
logList.value = (await getDeviceLogs(logDevice.value.Id, {
|
||||
pageIndex: logPage.value,
|
||||
pageSize: logPageSize.value
|
||||
})) || []
|
||||
logTotal.value = logList.value.total || 0
|
||||
} catch (e) {
|
||||
logList.value = []
|
||||
logTotal.value = 0
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onLogSizeChange() { logPage.value = 1; fetchLogs() }
|
||||
|
||||
// ---------- 格式化 ----------
|
||||
const logTagType = (level) => ({ Error: 'danger', Warn: 'warning' }[level] || 'primary')
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* 右键菜单 */
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
.ctx-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-menu__item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.ctx-menu__item.danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.ctx-menu__item.danger:hover {
|
||||
background: #fef0f0;
|
||||
}
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.log-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.log-timeline {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.log-type {
|
||||
margin-left: 6px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
.log-message {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.more-arrow {
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 「更多」下拉挂载在 body 下(teleport),scoped 样式覆盖不到,这里用全局样式标红删除项 -->
|
||||
<style>
|
||||
.el-dropdown-menu__item.more-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.el-dropdown-menu__item.more-danger:hover {
|
||||
color: #f56c6c;
|
||||
background: #fef0f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,314 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>网关管理</span>
|
||||
<div class="card-header">
|
||||
<span>网关管理({{ total }} 个)</span>
|
||||
<el-button type="primary" icon="Plus" @click="openAdd">添加网关</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="网关管理 - 功能开发中" />
|
||||
|
||||
<div class="search-bar">
|
||||
<el-select v-model="searchProtocol" placeholder="协议类型(全部)" clearable style="width: 160px" @change="onSearch">
|
||||
<el-option v-for="p in protocolOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
<el-input v-model="keyword" placeholder="网关编码 / 名称" clearable style="width: 260px" @keyup.enter="onSearch" @clear="onSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe border row-key="Id">
|
||||
<el-table-column prop="Code" label="网关编码" width="130" />
|
||||
<el-table-column prop="Name" label="网关名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="协议" width="110">
|
||||
<template #default="{ row }">{{ protocolLabel(row.ProtocolType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="连接地址" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ connAddress(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="onlineStatusTag(row.OnlineStatus)" size="small">{{ onlineStatusLabel(row.OnlineStatus) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后连接" width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.LastConnectedTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70" align="center">
|
||||
<template #default="{ row }"><el-switch :model-value="row.IsEnabled" disabled size="small" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="onTest(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<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, jumper"
|
||||
@current-change="fetchList" @size-change="onSizeChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑网关 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '修改网关' : '添加网关'" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="网关编码" prop="Code">
|
||||
<el-input v-model="form.Code" placeholder="唯一编码,如 GW-ENV-01" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关名称" prop="Name">
|
||||
<el-input v-model="form.Name" placeholder="请输入网关名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="协议类型" prop="ProtocolType">
|
||||
<el-select v-model="form.ProtocolType" style="width: 100%" @change="onProtocolChange">
|
||||
<el-option v-for="p in protocolOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- TCP 参数组 -->
|
||||
<template v-if="isTcpProtocol">
|
||||
<el-form-item label="IP 地址" prop="Host">
|
||||
<el-input v-model="form.Host" placeholder="如 192.168.1.10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="端口号" prop="Port">
|
||||
<el-input-number v-model="form.Port" :min="1" :max="65535" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 串口参数组 -->
|
||||
<template v-if="isSerialProtocol">
|
||||
<el-form-item label="串口号" prop="ComPort">
|
||||
<el-input v-model="form.ComPort" placeholder="如 COM3" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="波特率" prop="BaudRate">
|
||||
<el-select v-model="form.BaudRate" style="width: 100%">
|
||||
<el-option v-for="b in [9600,19200,38400,57600,115200]" :key="b" :label="b" :value="b" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据位">
|
||||
<el-select v-model="form.DataBits" style="width: 100%">
|
||||
<el-option v-for="d in [7,8]" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="停止位">
|
||||
<el-select v-model="form.StopBits" style="width: 100%">
|
||||
<el-option :value="1" /><el-option :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="校验位">
|
||||
<el-select v-model="form.Parity" style="width: 100%">
|
||||
<el-option v-for="p in PARITY_OPTIONS" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- S7 额外参数 -->
|
||||
<template v-if="form.ProtocolType === 3">
|
||||
<el-form-item label="CPU 型号">
|
||||
<el-select v-model="form.CpuType" style="width: 100%">
|
||||
<el-option v-for="c in ['S71200','S71500','S7300','S7400']" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Rack">
|
||||
<el-input-number v-model="form.Rack" :min="0" :max="7" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Slot">
|
||||
<el-input-number v-model="form.Slot" :min="0" :max="31" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.IsEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 网关详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="`网关详情 - ${detailRow?.Name || ''}`" size="720px">
|
||||
<el-tabs v-if="detailRow" v-model="detailTab">
|
||||
<el-tab-pane label="基本信息" name="info">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="网关编码">{{ detailRow.Code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网关名称">{{ detailRow.Name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="协议类型">{{ protocolLabel(detailRow.ProtocolType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="连接地址">{{ connAddress(detailRow) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在线状态">
|
||||
<el-tag :type="onlineStatusTag(detailRow.OnlineStatus)" size="small">{{ onlineStatusLabel(detailRow.OnlineStatus) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最后连接">{{ fmtTime(detailRow.LastConnectedTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近错误" :span="2">{{ detailRow.LastError || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="启用">{{ detailRow.IsEnabled ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detailRow.Remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="下挂设备" name="devices">
|
||||
<el-table :data="gatewayDevices" v-loading="devicesLoading" size="small" border stripe max-height="400">
|
||||
<el-table-column prop="Code" label="设备编号" width="130" />
|
||||
<el-table-column prop="Name" label="设备名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="SlaveId" label="从站号" width="80" align="center" />
|
||||
<el-table-column label="在线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.OnlineStatus === 1 ? 'success' : 'info'" size="small">{{ row.OnlineStatus === 1 ? '在线' : '离线' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!devicesLoading && gatewayDevices.length === 0" description="该网关下暂无设备" :image-size="60" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现网关管理功能
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getGatewayList, addGateway, updateGateway, deleteGateway, testGatewayConnection,
|
||||
protocolOptions, protocolLabel, onlineStatusLabel, onlineStatusTag, PARITY_OPTIONS
|
||||
} from '@/api/gateway'
|
||||
import { getDeviceList } from '@/api/device'
|
||||
|
||||
// ---------- 列表 ----------
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const keyword = ref('')
|
||||
const searchProtocol = ref(null)
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = (await getGatewayList({
|
||||
pageIndex: page.value, pageSize: pageSize.value,
|
||||
keyword: keyword.value || undefined,
|
||||
protocolType: searchProtocol.value || undefined
|
||||
})) || []
|
||||
total.value = list.value.total || 0
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; fetchList() }
|
||||
function onReset() { keyword.value = ''; searchProtocol.value = null; page.value = 1; fetchList() }
|
||||
function onSizeChange() { page.value = 1; fetchList() }
|
||||
|
||||
function connAddress(row) {
|
||||
if ([1, 3, 4].includes(row.ProtocolType)) return row.Host && row.Port ? `${row.Host}:${row.Port}` : '-'
|
||||
if ([2, 5].includes(row.ProtocolType)) return row.ComPort ? `${row.ComPort} @${row.BaudRate || 9600}` : '-'
|
||||
return '-'
|
||||
}
|
||||
|
||||
// ---------- 测试连接 ----------
|
||||
async function onTest(row) {
|
||||
row._testing = true
|
||||
try {
|
||||
const res = await testGatewayConnection(row.Id)
|
||||
ElMessage({ type: res.Success ? 'success' : 'error', message: res.Message || (res.Success ? '连接成功' : '连接失败') })
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ } finally { row._testing = false }
|
||||
}
|
||||
|
||||
// ---------- 新增/修改 ----------
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
Id: '', Code: '', Name: '', ProtocolType: 1,
|
||||
Host: '', Port: 502,
|
||||
ComPort: 'COM1', BaudRate: 9600, DataBits: 8, StopBits: 1, Parity: 0,
|
||||
CpuType: 'S71200', Rack: 0, Slot: 1,
|
||||
IsEnabled: true, Remark: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const isTcpProtocol = computed(() => [1, 3, 4].includes(form.ProtocolType))
|
||||
const isSerialProtocol = computed(() => [2, 5].includes(form.ProtocolType))
|
||||
|
||||
function onProtocolChange() {
|
||||
// 切协议时重置连接参数
|
||||
form.Host = ''; form.Port = 502
|
||||
form.ComPort = 'COM1'; form.BaudRate = 9600
|
||||
form.CpuType = 'S71200'; form.Rack = 0; form.Slot = 1
|
||||
}
|
||||
|
||||
const formRules = {
|
||||
Code: [{ required: true, message: '请输入网关编码', trigger: 'blur' }],
|
||||
Name: [{ required: true, message: '请输入网关名称', trigger: 'blur' }],
|
||||
ProtocolType: [{ required: true, message: '请选择协议类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function openAdd() { Object.assign(form, defaultForm()); dialogVisible.value = true }
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id, Code: row.Code, Name: row.Name, ProtocolType: row.ProtocolType,
|
||||
Host: row.Host || '', Port: row.Port || 502,
|
||||
ComPort: row.ComPort || 'COM1', BaudRate: row.BaudRate || 9600,
|
||||
DataBits: row.DataBits || 8, StopBits: row.StopBits || 1, Parity: row.Parity || 0,
|
||||
CpuType: row.CpuType || 'S71200', Rack: row.Rack ?? 0, Slot: row.Slot ?? 1,
|
||||
IsEnabled: row.IsEnabled, Remark: row.Remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (form.Id) { await updateGateway({ ...form }); ElMessage.success('修改成功') }
|
||||
else { const payload = { ...form }; delete payload.Id; await addGateway(payload); ElMessage.success('添加成功') }
|
||||
dialogVisible.value = false; fetchList()
|
||||
} catch {} finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除网关「${row.Name}」吗?该网关下设备的网关关联将被清除。`, '提示', { type: 'warning' })
|
||||
try { await deleteGateway(row.Id); ElMessage.success('删除成功'); fetchList() } catch {}
|
||||
}
|
||||
|
||||
// ---------- 详情 ----------
|
||||
const detailVisible = ref(false)
|
||||
const detailTab = ref('info')
|
||||
const detailRow = ref(null)
|
||||
const gatewayDevices = ref([])
|
||||
const devicesLoading = ref(false)
|
||||
|
||||
function openDetail(row) {
|
||||
detailRow.value = row
|
||||
detailTab.value = 'info'
|
||||
detailVisible.value = true
|
||||
fetchGatewayDevices(row.Id)
|
||||
}
|
||||
|
||||
async function fetchGatewayDevices(gatewayId) {
|
||||
devicesLoading.value = true
|
||||
try {
|
||||
// 复用设备列表接口,按 gatewayId 筛选(需后端支持)
|
||||
gatewayDevices.value = (await getDeviceList({ pageIndex: 1, pageSize: 200, gatewayId })) || []
|
||||
} catch { gatewayDevices.value = [] } finally { devicesLoading.value = false }
|
||||
}
|
||||
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
|
||||
|
||||
onMounted(() => fetchList())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-bar { display: flex; gap: 10px; margin-bottom: 16px; }
|
||||
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,125 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>产品分类</span>
|
||||
<div class="card-header">
|
||||
<span>产品分类</span>
|
||||
<el-button type="primary" icon="Plus" @click="openAdd">新增分类</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="产品分类 - 功能开发中" />
|
||||
|
||||
<el-table :data="categories" v-loading="loading" border stripe>
|
||||
<el-table-column prop="Code" label="分类编码" width="180" />
|
||||
<el-table-column prop="Name" label="分类名称" min-width="180" />
|
||||
<el-table-column prop="Sort" label="排序" width="90" align="center" />
|
||||
<el-table-column prop="Remark" label="备注" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '编辑分类' : '新增分类'" width="460px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="分类编码" prop="Code">
|
||||
<el-input v-model="form.Code" placeholder="如 TBOX / CDC" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类名称" prop="Name">
|
||||
<el-input v-model="form.Name" placeholder="如 温度箱" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序号">
|
||||
<el-input-number v-model="form.Sort" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现产品分类功能
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getProductCategories, addProductCategory, updateProductCategory, deleteProductCategory } from '@/api/product'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const categories = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const defaultForm = () => ({ Id: '', Code: '', Name: '', Sort: 0, Remark: '' })
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const formRules = {
|
||||
Code: [{ required: true, message: '请输入分类编码', trigger: 'blur' }],
|
||||
Name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = (await getProductCategories()) || []
|
||||
} catch (e) {
|
||||
categories.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, defaultForm())
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (form.Id) {
|
||||
await updateProductCategory({ ...form })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
const payload = { ...form }
|
||||
delete payload.Id
|
||||
await addProductCategory(payload)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除分类「${row.Name || row.Code}」吗?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
await deleteProductCategory(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,291 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>产品管理</span>
|
||||
<div class="card-header">
|
||||
<span>产品管理({{ total }} 项)</span>
|
||||
<el-button type="primary" icon="Plus" @click="openAdd">新增产品</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="产品管理 - 功能开发中" />
|
||||
|
||||
<!-- 搜索 + 分类筛选 -->
|
||||
<div class="search-bar">
|
||||
<el-select v-model="search.categoryId" placeholder="全部分类" clearable style="width: 180px" @change="onSearch">
|
||||
<el-option v-for="c in categories" :key="c.Id" :label="c.Name" :value="c.Id" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="search.keyword"
|
||||
placeholder="请输入型号 / 名称查找产品"
|
||||
clearable
|
||||
style="width: 280px"
|
||||
@keyup.enter="onSearch"
|
||||
@clear="onSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="Model" label="型号" width="140" />
|
||||
<el-table-column prop="Name" label="产品名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="分类" width="130">
|
||||
<template #default="{ row }">{{ categoryLabel(row.CategoryId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通讯协议" width="110">
|
||||
<template #default="{ row }">{{ protocolLabel(row.ProtocolType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="MessageModel" label="消息模型" width="100" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.IsEnabled ? 'success' : 'info'" size="small">{{ row.IsEnabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.CreateTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<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, jumper"
|
||||
@current-change="fetchList"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑产品 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '编辑产品' : '新增产品'" width="560px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="110px">
|
||||
<el-form-item label="型号" prop="Model">
|
||||
<el-input v-model="form.Model" placeholder="如 GT-100(唯一)" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品名称" prop="Name">
|
||||
<el-input v-model="form.Name" placeholder="如 高低温试验箱" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品分类">
|
||||
<el-select v-model="form.CategoryId" placeholder="未分类" clearable style="width: 100%">
|
||||
<el-option v-for="c in categories" :key="c.Id" :label="c.Name" :value="c.Id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="通讯协议" prop="ProtocolType">
|
||||
<el-select v-model="form.ProtocolType" style="width: 100%">
|
||||
<el-option v-for="p in protocolOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="消息模型">
|
||||
<el-input v-model="form.MessageModel" placeholder="如 JSON / 透传" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="制造商">
|
||||
<el-input v-model="form.Manufacturer" placeholder="请输入制造商" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.IsEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.Description" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 产品详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="`产品详情 - ${detailProduct?.Model || ''}`" size="720px">
|
||||
<el-tabs v-if="detailProduct" v-model="activeTab">
|
||||
<el-tab-pane label="基本信息" name="info">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="型号">{{ detailProduct.Model }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品名称">{{ detailProduct.Name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ categoryLabel(detailProduct.CategoryId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="通讯协议">{{ protocolLabel(detailProduct.ProtocolType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="消息模型">{{ detailProduct.MessageModel || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="制造商">{{ detailProduct.Manufacturer || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ detailProduct.IsEnabled ? '启用' : '停用' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ fmtTime(detailProduct.CreateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ detailProduct.Description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detailProduct.Remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="物模型点" name="points">
|
||||
<ThingPointTable v-if="detailProduct" :owner-type="1" :owner-id="detailProduct.Id" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="告警" name="alarm">
|
||||
<ThingAlarmPanel v-if="detailProduct" :owner-type="1" :owner-id="detailProduct.Id" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现产品管理功能
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getProductList, getProductCategories, addProduct, updateProduct, deleteProduct, protocolOptions, protocolLabel } from '@/api/product'
|
||||
import ThingPointTable from '@/components/thing/ThingPointTable.vue'
|
||||
import ThingAlarmPanel from '@/components/thing/ThingAlarmPanel.vue'
|
||||
|
||||
// ---------- 列表 ----------
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
const categories = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const search = reactive({ keyword: '', categoryId: '' })
|
||||
const categoryMap = ref({})
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
categories.value = (await getProductCategories()) || []
|
||||
const map = {}
|
||||
categories.value.forEach(c => { map[c.Id] = c.Name })
|
||||
categoryMap.value = map
|
||||
} catch (e) {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const categoryLabel = (id) => categoryMap.value[id] || '未分类'
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = (await getProductList({
|
||||
pageIndex: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: search.keyword || undefined,
|
||||
categoryId: search.categoryId || 0
|
||||
})) || []
|
||||
total.value = list.value.total || 0
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; fetchList() }
|
||||
function onReset() { search.keyword = ''; search.categoryId = ''; page.value = 1; fetchList() }
|
||||
function onSizeChange() { page.value = 1; fetchList() }
|
||||
|
||||
// ---------- 新增 / 编辑 ----------
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
const defaultForm = () => ({
|
||||
Id: '', Model: '', Name: '', CategoryId: '', Manufacturer: '', Description: '',
|
||||
ProtocolType: 1, MessageModel: '', IsEnabled: true, Remark: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const formRules = {
|
||||
Model: [{ required: true, message: '请输入型号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, defaultForm())
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id, Model: row.Model, Name: row.Name || '', CategoryId: row.CategoryId || '',
|
||||
Manufacturer: row.Manufacturer || '', Description: row.Description || '',
|
||||
ProtocolType: row.ProtocolType, MessageModel: row.MessageModel || '',
|
||||
IsEnabled: row.IsEnabled, Remark: row.Remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (form.Id) {
|
||||
await updateProduct({ ...form })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
const payload = { ...form }
|
||||
delete payload.Id
|
||||
await addProduct(payload)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除产品「${row.Model}」吗?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
await deleteProduct(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
// ---------- 详情 ----------
|
||||
const detailVisible = ref(false)
|
||||
const activeTab = ref('info')
|
||||
const detailProduct = ref(null)
|
||||
|
||||
function openDetail(row) {
|
||||
detailProduct.value = row
|
||||
activeTab.value = 'info'
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,757 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>消息告警</span>
|
||||
<div class="card-header">
|
||||
<span>消息告警</span>
|
||||
<el-button @click="loadAlerts"><el-icon><Refresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="消息告警 - 功能开发中" />
|
||||
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- ==================== 告警列表 ==================== -->
|
||||
<el-tab-pane label="告警列表" name="list">
|
||||
<!-- 概览卡片 -->
|
||||
<el-row :gutter="16" class="summary-row">
|
||||
<el-col :span="6">
|
||||
<div class="summary-card danger">
|
||||
<div class="summary-count">{{ overview.UnacknowledgedCount || 0 }}</div>
|
||||
<div class="summary-label">未确认</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-count">{{ overview.TodayCount || 0 }}</div>
|
||||
<div class="summary-label">今日新增</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card danger">
|
||||
<div class="summary-count">{{ overview.TodayUrgentCount || 0 }}</div>
|
||||
<div class="summary-label">今日紧急</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-count">{{ overview.WeekCount || 0 }}</div>
|
||||
<div class="summary-label">近7日总数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="query.level" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option v-for="i in levelOptions" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="i in statusOptions" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="query.type" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="i in typeOptions" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="来源">
|
||||
<el-select v-model="query.source" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="i in sourceOptions" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键字">
|
||||
<el-input v-model="query.keyword" placeholder="设备/内容" clearable style="width: 160px" @keyup.enter="searchAlerts" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间">
|
||||
<el-date-picker v-model="query.dateRange" type="datetimerange" value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
start-placeholder="开始" end-placeholder="结束" style="width: 320px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="searchAlerts">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 告警表格 -->
|
||||
<el-table :data="alerts" border stripe v-loading="listLoading">
|
||||
<el-table-column label="最近告警" width="160">
|
||||
<template #default="{ row }">{{ fmtDateTime(row.LastAlertTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.DeviceName || '-' }}({{ row.DeviceCode || '-' }})</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Metric" label="测点" width="100" />
|
||||
<el-table-column label="级别" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="levelTag(row.AlertLevel)">{{ levelLabel(row.AlertLevel) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前值/阈值" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.CurrentValue ?? '-' }} / {{ row.ThresholdValue ?? '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Content" label="告警内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="95">
|
||||
<template #default="{ row }">{{ typeLabel(row.AlertType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="95">
|
||||
<template #default="{ row }">{{ sourceLabel(row.Source) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="TriggerCount" label="触发" width="65" />
|
||||
<el-table-column label="状态" width="95">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.AlertStatus)">{{ statusLabel(row.AlertStatus) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.AlertStatus === 1" link type="warning" size="small" @click="openOperate(row, 'ack')">确认</el-button>
|
||||
<el-button v-if="row.AlertStatus <= 2" link type="success" size="small" @click="openOperate(row, 'handle')">处理</el-button>
|
||||
<el-button v-if="row.AlertStatus === 3" link type="info" size="small" @click="openOperate(row, 'close')">关闭</el-button>
|
||||
<el-button v-if="row.AlertStatus <= 2" link type="danger" size="small" @click="openOperate(row, 'ignore')">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total"
|
||||
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadAlerts" @current-change="loadAlerts" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ==================== 统计分析 ==================== -->
|
||||
<el-tab-pane label="统计分析" name="stats">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker v-model="statsRange" type="daterange" value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" style="width: 260px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="趋势粒度">
|
||||
<el-radio-group v-model="granularity">
|
||||
<el-radio-button value="day">日</el-radio-button>
|
||||
<el-radio-button value="week">周</el-radio-button>
|
||||
<el-radio-button value="month">月</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadStats">统计</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 及时率卡片 -->
|
||||
<el-row :gutter="16" class="summary-row">
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-count">{{ timeliness.TimelyRate ?? 0 }}%</div>
|
||||
<div class="summary-label">确认及时率(30分钟内)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-count">{{ timeliness.AvgAckMinutes ?? '-' }}</div>
|
||||
<div class="summary-label">平均确认时长(分钟)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-count">{{ timeliness.AvgHandleMinutes ?? '-' }}</div>
|
||||
<div class="summary-label">平均处理时长(分钟)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-count">{{ timeliness.UnhandledCount ?? 0 }}</div>
|
||||
<div class="summary-label">未闭环数量</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header><span>告警趋势</span></template>
|
||||
<div ref="trendChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" class="mt-16">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header><span>级别分布</span></template>
|
||||
<div ref="levelChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header><span>来源分布</span></template>
|
||||
<div ref="sourceChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header><span>高频设备 TOP10</span></template>
|
||||
<div ref="topChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ==================== 通知配置 ==================== -->
|
||||
<el-tab-pane label="通知配置" name="notify">
|
||||
<el-tabs v-model="notifyTab" type="border-card">
|
||||
<!-- 渠道管理 -->
|
||||
<el-tab-pane label="通知渠道" name="channel">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="openChannelDialog()">新增渠道</el-button>
|
||||
</div>
|
||||
<el-table :data="channels" border stripe>
|
||||
<el-table-column prop="Name" label="渠道名称" min-width="140" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }"><el-tag>{{ channelLabel(row.ChannelType) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="WebhookUrl" label="Webhook" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="加签" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.HasSecret ? 'success' : 'info'" size="small">{{ row.HasSecret ? '已配置' : '未配置' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="推送模式" width="140">
|
||||
<template #default="{ row }">{{ pushModeLabel(row.PushMode) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.IsEnabled ? 'success' : 'info'" size="small">{{ row.IsEnabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleTestChannel(row)">测试</el-button>
|
||||
<el-button link type="primary" size="small" @click="openChannelDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteChannel(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 通知规则 -->
|
||||
<el-tab-pane label="通知规则" name="rule">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="openRuleDialog()">新增规则</el-button>
|
||||
</div>
|
||||
<el-table :data="notifyRules" border stripe>
|
||||
<el-table-column prop="RuleName" label="规则名称" min-width="140" />
|
||||
<el-table-column label="告警级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="levelTag(row.AlertLevel)">{{ levelLabel(row.AlertLevel) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知渠道" min-width="140">
|
||||
<template #default="{ row }">{{ channelNameOf(row.ChannelId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Receivers" label="接收人(@手机号)" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="通知值班人" width="100">
|
||||
<template #default="{ row }">{{ row.NotifyDutyPerson ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="SilenceMinutes" label="静默期(分)" width="95" />
|
||||
<el-table-column label="启用" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.IsEnabled" @change="(v) => handleToggleRule(row, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openRuleDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 值班排班 -->
|
||||
<el-tab-pane label="值班排班" name="duty">
|
||||
<div class="toolbar">
|
||||
<el-date-picker v-model="dutyRange" type="daterange" value-format="YYYY-MM-DD" size="small"
|
||||
start-placeholder="开始" end-placeholder="结束" style="width: 240px" @change="loadDuties" />
|
||||
<el-button type="primary" size="small" @click="openDutyDialog()">新增排班</el-button>
|
||||
</div>
|
||||
<el-table :data="duties" border stripe>
|
||||
<el-table-column label="值班日期" width="120">
|
||||
<template #default="{ row }">{{ fmtDate(row.DutyDate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="PersonName" label="值班人" width="120" />
|
||||
<el-table-column prop="Phone" label="联系电话" width="140" />
|
||||
<el-table-column prop="Remark" label="备注" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openDutyDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteDuty(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 推送日志 -->
|
||||
<el-tab-pane label="推送日志" name="log">
|
||||
<el-table :data="notifyLogs" border stripe>
|
||||
<el-table-column label="推送时间" width="160">
|
||||
<template #default="{ row }">{{ fmtDateTime(row.NotifyTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="渠道" width="90">
|
||||
<template #default="{ row }">{{ channelLabel(row.ChannelType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Target" label="目标" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="Content" label="内容" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.Success ? 'success' : 'danger'" size="small">{{ row.Success ? '成功' : '失败' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ErrorMsg" label="错误信息" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="RetryCount" label="重试" width="60" />
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination v-model:current-page="logQuery.pageIndex" v-model:page-size="logQuery.pageSize"
|
||||
:total="logTotal" layout="total, prev, pager, next" @current-change="loadLogs" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作对话框(确认/处理/关闭/忽略通用) -->
|
||||
<el-dialog v-model="operateVisible" :title="operateTitle" width="440px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="操作人"><el-input v-model="operateForm.Operator" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="operateForm.Remark" type="textarea" :rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="operateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitOperate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="告警详情" size="42%">
|
||||
<el-descriptions v-if="detail" :column="2" border size="small">
|
||||
<el-descriptions-item label="设备">{{ detail.DeviceName }}({{ detail.DeviceCode }})</el-descriptions-item>
|
||||
<el-descriptions-item label="设备类型">{{ detail.DeviceType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="测点">{{ detail.Metric || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="级别">
|
||||
<el-tag :type="levelTag(detail.AlertLevel)">{{ levelLabel(detail.AlertLevel) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前值">{{ detail.CurrentValue ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="阈值">{{ detail.ThresholdValue ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ typeLabel(detail.AlertType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源">{{ sourceLabel(detail.Source) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="首次告警">{{ fmtDateTime(detail.FirstAlertTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近告警">{{ fmtDateTime(detail.LastAlertTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="触发次数">{{ detail.TriggerCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTag(detail.AlertStatus)">{{ statusLabel(detail.AlertStatus) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="内容" :span="2">{{ detail.Content }}</el-descriptions-item>
|
||||
<el-descriptions-item label="确认人">{{ detail.AckBy || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="确认时间">{{ fmtDateTime(detail.AckTime) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人">{{ detail.HandleBy || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理时间">{{ fmtDateTime(detail.HandleTime) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理备注" :span="2">{{ detail.HandleRemark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 渠道对话框 -->
|
||||
<el-dialog v-model="channelVisible" :title="channelForm.Id ? '编辑渠道' : '新增渠道'" width="560px">
|
||||
<el-form :model="channelForm" label-width="110px">
|
||||
<el-form-item label="渠道名称" required><el-input v-model="channelForm.Name" /></el-form-item>
|
||||
<el-form-item label="渠道类型" required>
|
||||
<el-select v-model="channelForm.ChannelType" style="width: 100%">
|
||||
<el-option v-for="i in channelTypeOptions" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Webhook地址" required><el-input v-model="channelForm.WebhookUrl" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="加签密钥">
|
||||
<el-input v-model="channelForm.Secret" :placeholder="channelForm.Id ? '留空表示不修改' : '机器人开启加签时填写'" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="推送模式">
|
||||
<el-radio-group v-model="channelForm.PushMode">
|
||||
<el-radio v-for="i in pushModeOptions" :key="i.value" :value="i.value">{{ i.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="channelForm.IsEnabled" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="channelForm.Remark" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="channelVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitChannel">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 通知规则对话框 -->
|
||||
<el-dialog v-model="ruleVisible" :title="ruleForm.Id ? '编辑规则' : '新增规则'" width="520px">
|
||||
<el-form :model="ruleForm" label-width="110px">
|
||||
<el-form-item label="规则名称" required><el-input v-model="ruleForm.RuleName" /></el-form-item>
|
||||
<el-form-item label="告警级别" required>
|
||||
<el-select v-model="ruleForm.AlertLevel" style="width: 100%">
|
||||
<el-option v-for="i in levelOptions" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="通知渠道" required>
|
||||
<el-select v-model="ruleForm.ChannelId" style="width: 100%">
|
||||
<el-option v-for="c in channels" :key="c.Id" :label="`${c.Name}(${channelLabel(c.ChannelType)})`" :value="c.Id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="接收人">
|
||||
<el-input v-model="ruleForm.Receivers" :placeholder="receiversPlaceholder" />
|
||||
</el-form-item>
|
||||
<el-form-item label="通知值班人"><el-switch v-model="ruleForm.NotifyDutyPerson" /></el-form-item>
|
||||
<el-form-item label="静默期(分钟)">
|
||||
<el-input-number v-model="ruleForm.SilenceMinutes" :min="0" :max="1440" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="ruleForm.IsEnabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ruleVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitRule">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 排班对话框 -->
|
||||
<el-dialog v-model="dutyVisible" :title="dutyForm.Id ? '编辑排班' : '新增排班'" width="440px">
|
||||
<el-form :model="dutyForm" label-width="90px">
|
||||
<el-form-item label="值班日期" required>
|
||||
<el-date-picker v-model="dutyForm.DutyDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="值班人" required><el-input v-model="dutyForm.PersonName" /></el-form-item>
|
||||
<el-form-item label="联系电话"><el-input v-model="dutyForm.Phone" placeholder="用于钉钉/企微@提醒" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="dutyForm.Remark" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dutyVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitDuty">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现消息告警功能
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as echarts from 'echarts'
|
||||
import {
|
||||
getAlertList, getAlertDetail, ackAlert, handleAlert, closeAlert, ignoreAlert,
|
||||
getAlertOverview, getAlertTrend, getAlertDistribution, getAlertTop, getAlertTimeliness,
|
||||
getChannels, addChannel, updateChannel, deleteChannel, testChannel,
|
||||
getNotifyRules, addNotifyRule, updateNotifyRule, deleteNotifyRule, setRuleEnabled,
|
||||
getNotifyLogs, getDuties, addDuty, updateDuty, deleteDuty,
|
||||
levelOptions, statusOptions, typeOptions, sourceOptions, channelTypeOptions, pushModeOptions,
|
||||
levelLabel, levelTag, statusLabel, statusTag, typeLabel, sourceLabel, channelLabel, pushModeLabel
|
||||
} from '@/api/alert'
|
||||
import { fmtDate, fmtDateTime } from '@/utils/format'
|
||||
|
||||
const activeTab = ref('list')
|
||||
const saving = ref(false)
|
||||
|
||||
// ==================== 告警列表 ====================
|
||||
const listLoading = ref(false)
|
||||
const alerts = ref([])
|
||||
const total = ref(0)
|
||||
const overview = ref({})
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10, level: null, status: null, type: null, source: null, keyword: '', dateRange: null })
|
||||
|
||||
async function loadAlerts() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const list = await getAlertList({
|
||||
pageIndex: query.pageIndex, pageSize: query.pageSize,
|
||||
level: query.level ?? undefined, status: query.status ?? undefined,
|
||||
type: query.type ?? undefined, source: query.source ?? undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
startDate: query.dateRange?.[0] || undefined,
|
||||
endDate: query.dateRange?.[1] || undefined
|
||||
})
|
||||
alerts.value = list
|
||||
total.value = list.total || 0
|
||||
overview.value = await getAlertOverview()
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
function searchAlerts() { query.pageIndex = 1; loadAlerts() }
|
||||
function resetQuery() {
|
||||
Object.assign(query, { pageIndex: 1, level: null, status: null, type: null, source: null, keyword: '', dateRange: null })
|
||||
loadAlerts()
|
||||
}
|
||||
|
||||
// 详情
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
async function openDetail(row) {
|
||||
detail.value = await getAlertDetail(row.Id)
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 闭环操作
|
||||
const operateVisible = ref(false)
|
||||
const operateForm = reactive({ row: null, action: 'ack', Operator: '', Remark: '' })
|
||||
const OPERATE_TITLES = { ack: '确认告警', handle: '处理告警', close: '关闭告警', ignore: '忽略告警' }
|
||||
const OPERATE_APIS = { ack: ackAlert, handle: handleAlert, close: closeAlert, ignore: ignoreAlert }
|
||||
const operateTitle = computed(() => OPERATE_TITLES[operateForm.action])
|
||||
|
||||
function openOperate(row, action) {
|
||||
operateForm.row = row
|
||||
operateForm.action = action
|
||||
operateForm.Operator = ''
|
||||
operateForm.Remark = ''
|
||||
operateVisible.value = true
|
||||
}
|
||||
async function submitOperate() {
|
||||
saving.value = true
|
||||
try {
|
||||
await OPERATE_APIS[operateForm.action](operateForm.row.Id, {
|
||||
Operator: operateForm.Operator, Remark: operateForm.Remark
|
||||
})
|
||||
ElMessage.success('操作成功')
|
||||
operateVisible.value = false
|
||||
loadAlerts()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 统计分析 ====================
|
||||
const statsRange = ref(null)
|
||||
const granularity = ref('day')
|
||||
const timeliness = ref({})
|
||||
const trendChartRef = ref()
|
||||
const levelChartRef = ref()
|
||||
const sourceChartRef = ref()
|
||||
const topChartRef = ref()
|
||||
let charts = []
|
||||
|
||||
function makeChart(domRef) {
|
||||
let chart = charts.find(c => c.getDom() === domRef)
|
||||
if (!chart) { chart = echarts.init(domRef); charts.push(chart) }
|
||||
return chart
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const params = {
|
||||
startDate: statsRange.value?.[0] || undefined,
|
||||
endDate: statsRange.value?.[1] || undefined
|
||||
}
|
||||
const [trend, levels, sources, top, time] = await Promise.all([
|
||||
getAlertTrend({ ...params, granularity: granularity.value }),
|
||||
getAlertDistribution({ ...params, dimension: 'level' }),
|
||||
getAlertDistribution({ ...params, dimension: 'source' }),
|
||||
getAlertTop({ ...params, topN: 10 }),
|
||||
getAlertTimeliness(params)
|
||||
])
|
||||
timeliness.value = time || {}
|
||||
await nextTick()
|
||||
|
||||
makeChart(trendChartRef.value).setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { bottom: 0 },
|
||||
grid: { left: 45, right: 20, top: 20, bottom: 55 },
|
||||
xAxis: { type: 'category', data: trend.map(t => t.Bucket) },
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '总数', type: 'line', smooth: true, data: trend.map(t => t.Total) },
|
||||
{ name: '信息', type: 'line', stack: 'x', areaStyle: { opacity: 0.15 }, data: trend.map(t => t.InfoCount) },
|
||||
{ name: '警告', type: 'line', stack: 'x', areaStyle: { opacity: 0.15 }, data: trend.map(t => t.WarningCount) },
|
||||
{ name: '紧急', type: 'line', stack: 'x', areaStyle: { opacity: 0.15 }, data: trend.map(t => t.UrgentCount) }
|
||||
]
|
||||
}, true)
|
||||
|
||||
const pieOption = (data) => ({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c}({d}%)' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{ type: 'pie', radius: ['35%', '62%'], data, label: { formatter: '{b}\n{c}' } }]
|
||||
})
|
||||
makeChart(levelChartRef.value).setOption(pieOption(levels.map(l => ({ name: l.Name, value: l.Count }))), true)
|
||||
makeChart(sourceChartRef.value).setOption(pieOption(sources.map(s => ({ name: s.Name, value: s.Count }))), true)
|
||||
makeChart(topChartRef.value).setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 100, right: 30, top: 10, bottom: 30 },
|
||||
xAxis: { type: 'value', minInterval: 1 },
|
||||
yAxis: { type: 'category', data: top.map(t => t.DeviceName || t.DeviceCode).reverse(), axisLabel: { width: 90, overflow: 'truncate' } },
|
||||
series: [{ type: 'bar', data: top.map(t => t.Count).reverse(), barMaxWidth: 18, itemStyle: { color: '#F56C6C' } }]
|
||||
}, true)
|
||||
}
|
||||
|
||||
function handleResize() { charts.forEach(c => c.resize()) }
|
||||
function onTabChange(name) {
|
||||
if (name === 'stats') loadStats()
|
||||
if (name === 'notify') { loadChannels(); loadNotifyRules(); loadDuties(); loadLogs() }
|
||||
}
|
||||
|
||||
// ==================== 通知配置:渠道 ====================
|
||||
const notifyTab = ref('channel')
|
||||
const channels = ref([])
|
||||
const channelVisible = ref(false)
|
||||
const channelForm = ref({})
|
||||
|
||||
async function loadChannels() { channels.value = await getChannels() }
|
||||
function channelNameOf(id) { return channels.value.find(c => c.Id === id)?.Name || '-' }
|
||||
function openChannelDialog(row) {
|
||||
channelForm.value = row
|
||||
? { ...row, Secret: '' } // 编辑时 Secret 留空表示不修改
|
||||
: { Id: '', Name: '', ChannelType: 1, WebhookUrl: '', Secret: '', PushMode: 1, IsEnabled: true, Remark: '' }
|
||||
channelVisible.value = true
|
||||
}
|
||||
async function submitChannel() {
|
||||
if (!channelForm.value.Name || !channelForm.value.WebhookUrl) {
|
||||
ElMessage.warning('请填写渠道名称与 Webhook 地址')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (channelForm.value.Id) await updateChannel(channelForm.value.Id, channelForm.value)
|
||||
else await addChannel(channelForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
channelVisible.value = false
|
||||
loadChannels()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function handleTestChannel(row) {
|
||||
const res = await testChannel(row.Id)
|
||||
ElMessage.success(res ? '测试推送成功,请到群里查看' : '测试推送完成')
|
||||
loadLogs()
|
||||
}
|
||||
async function handleDeleteChannel(row) {
|
||||
await ElMessageBox.confirm(`确定删除渠道【${row.Name}】吗?`, '提示', { type: 'warning' })
|
||||
await deleteChannel(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
loadChannels()
|
||||
}
|
||||
|
||||
// ==================== 通知配置:规则 ====================
|
||||
const notifyRules = ref([])
|
||||
const ruleVisible = ref(false)
|
||||
const ruleForm = ref({})
|
||||
|
||||
// 接收人提示随所选渠道类型变化:飞书可填手机号(系统自动转 open_id)或 open_id,企微/钉钉填手机号
|
||||
const receiversPlaceholder = computed(() => {
|
||||
const ch = channels.value.find(c => c.Id === ruleForm.value.ChannelId)
|
||||
return ch && ch.ChannelType === 1
|
||||
? '手机号或 open_id(ou_ 开头),逗号分隔;@所有人填 all(填手机号系统自动转 open_id)'
|
||||
: '手机号,逗号分隔(企微/钉钉需为群成员才能 @ 到)'
|
||||
})
|
||||
|
||||
async function loadNotifyRules() { notifyRules.value = await getNotifyRules() }
|
||||
function openRuleDialog(row) {
|
||||
ruleForm.value = row
|
||||
? { ...row }
|
||||
: { Id: '', RuleName: '', AlertLevel: 3, ChannelId: '', Receivers: '', NotifyDutyPerson: false, SilenceMinutes: 5, IsEnabled: true }
|
||||
ruleVisible.value = true
|
||||
}
|
||||
async function submitRule() {
|
||||
if (!ruleForm.value.RuleName || !ruleForm.value.ChannelId) {
|
||||
ElMessage.warning('请填写规则名称并选择通知渠道')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (ruleForm.value.Id) await updateNotifyRule(ruleForm.value.Id, ruleForm.value)
|
||||
else await addNotifyRule(ruleForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
ruleVisible.value = false
|
||||
loadNotifyRules()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function handleToggleRule(row, enabled) {
|
||||
await setRuleEnabled(row.Id, enabled)
|
||||
row.IsEnabled = enabled
|
||||
ElMessage.success(enabled ? '已启用' : '已停用')
|
||||
}
|
||||
async function handleDeleteRule(row) {
|
||||
await ElMessageBox.confirm(`确定删除规则【${row.RuleName}】吗?`, '提示', { type: 'warning' })
|
||||
await deleteNotifyRule(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
loadNotifyRules()
|
||||
}
|
||||
|
||||
// ==================== 通知配置:值班 ====================
|
||||
const duties = ref([])
|
||||
const dutyRange = ref(null)
|
||||
const dutyVisible = ref(false)
|
||||
const dutyForm = ref({})
|
||||
|
||||
async function loadDuties() {
|
||||
duties.value = await getDuties({
|
||||
startDate: dutyRange.value?.[0] || undefined,
|
||||
endDate: dutyRange.value?.[1] || undefined
|
||||
})
|
||||
}
|
||||
function openDutyDialog(row) {
|
||||
dutyForm.value = row ? { ...row } : { Id: '', DutyDate: '', PersonName: '', Phone: '', Remark: '' }
|
||||
dutyVisible.value = true
|
||||
}
|
||||
async function submitDuty() {
|
||||
if (!dutyForm.value.DutyDate || !dutyForm.value.PersonName) {
|
||||
ElMessage.warning('请填写值班日期与值班人')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (dutyForm.value.Id) await updateDuty(dutyForm.value.Id, dutyForm.value)
|
||||
else await addDuty(dutyForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
dutyVisible.value = false
|
||||
loadDuties()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function handleDeleteDuty(row) {
|
||||
await ElMessageBox.confirm(`确定删除 ${fmtDate(row.DutyDate)} 的排班吗?`, '提示', { type: 'warning' })
|
||||
await deleteDuty(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
loadDuties()
|
||||
}
|
||||
|
||||
// ==================== 通知配置:日志 ====================
|
||||
const notifyLogs = ref([])
|
||||
const logTotal = ref(0)
|
||||
const logQuery = reactive({ pageIndex: 1, pageSize: 10 })
|
||||
|
||||
async function loadLogs() {
|
||||
const list = await getNotifyLogs({ pageIndex: logQuery.pageIndex, pageSize: logQuery.pageSize })
|
||||
notifyLogs.value = list
|
||||
logTotal.value = list.total || 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAlerts()
|
||||
loadChannels()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
charts.forEach(c => c.dispose())
|
||||
charts = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.summary-row { margin-bottom: 16px; }
|
||||
.summary-card { border: 1px solid var(--el-border-color-light); border-radius: 6px; padding: 14px; text-align: center; }
|
||||
.summary-card.danger .summary-count { color: var(--el-color-danger); }
|
||||
.summary-count { font-size: 24px; font-weight: 600; }
|
||||
.summary-label { color: var(--el-text-color-secondary); margin-top: 4px; font-size: 13px; }
|
||||
.search-form { margin-bottom: 4px; }
|
||||
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 12px; align-items: center; }
|
||||
.chart-card :deep(.el-card__header) { padding: 10px 16px; font-weight: 600; }
|
||||
.chart { height: 300px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="report-page">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" class="stat-cards">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ deviceStats.Total }}</div>
|
||||
<div class="stat-label">设备总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card in-use">
|
||||
<div class="stat-value">{{ deviceStats.InUse }}</div>
|
||||
<div class="stat-label">在用数量</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card idle">
|
||||
<div class="stat-value">{{ deviceStats.Idle }}</div>
|
||||
<div class="stat-label">闲置数量</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card scrapped">
|
||||
<div class="stat-value">{{ deviceStats.Scrapped }}</div>
|
||||
<div class="stat-label">报废数量</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card repairing">
|
||||
<div class="stat-value">{{ deviceStats.Repairing }}</div>
|
||||
<div class="stat-label">维修中</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card other">
|
||||
<div class="stat-value">{{ deviceStats.Other }}</div>
|
||||
<div class="stat-label">其他</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span>设备状态分布</span></template>
|
||||
<div ref="statusChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span>按分类统计</span></template>
|
||||
<div ref="categoryChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :span="24">
|
||||
<el-card>
|
||||
<template #header><span>按部门统计</span></template>
|
||||
<div ref="deptChartRef" class="chart-container" style="height: 320px;"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getDeviceStatistics, getCategoryStatistics, getDepartmentStatistics } from '@/api/statisticsReport'
|
||||
|
||||
const deviceStats = ref({ Total: 0, InUse: 0, Idle: 0, Scrapped: 0, Repairing: 0, Suspended: 0, Other: 0 })
|
||||
const statusChartRef = ref(null)
|
||||
const categoryChartRef = ref(null)
|
||||
const deptChartRef = ref(null)
|
||||
|
||||
let statusChart = null
|
||||
let categoryChart = null
|
||||
let deptChart = null
|
||||
|
||||
const statusColors = {
|
||||
InUse: '#409EFF',
|
||||
Idle: '#E6A23C',
|
||||
Scrapped: '#909399',
|
||||
Repairing: '#F56C6C',
|
||||
Suspended: '#606266',
|
||||
Other: '#909399'
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
InUse: '在用',
|
||||
Idle: '闲置',
|
||||
Scrapped: '报废',
|
||||
Repairing: '维修中',
|
||||
Suspended: '已停用',
|
||||
Other: '其他'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
deviceStats.value = await getDeviceStatistics() || deviceStats.value
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
|
||||
await nextTick()
|
||||
renderStatusChart()
|
||||
await loadCategoryData()
|
||||
await loadDepartmentData()
|
||||
}
|
||||
|
||||
function renderStatusChart() {
|
||||
if (!statusChartRef.value) return
|
||||
if (statusChart) statusChart.dispose()
|
||||
statusChart = echarts.init(statusChartRef.value)
|
||||
|
||||
const data = [
|
||||
{ value: deviceStats.value.InUse, name: '在用', itemStyle: { color: '#409EFF' } },
|
||||
{ value: deviceStats.value.Idle, name: '闲置', itemStyle: { color: '#E6A23C' } },
|
||||
{ value: deviceStats.value.Scrapped, name: '报废', itemStyle: { color: '#909399' } },
|
||||
{ value: deviceStats.value.Repairing, name: '维修中', itemStyle: { color: '#F56C6C' } },
|
||||
{ value: deviceStats.value.Suspended, name: '已停用', itemStyle: { color: '#606266' } },
|
||||
{ value: deviceStats.value.Other, name: '其他', itemStyle: { color: '#A8ABB2' } }
|
||||
].filter(d => d.value > 0)
|
||||
|
||||
statusChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{c}台' },
|
||||
data
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async function loadCategoryData() {
|
||||
try {
|
||||
const list = await getCategoryStatistics() || []
|
||||
if (!categoryChartRef.value) return
|
||||
if (categoryChart) categoryChart.dispose()
|
||||
categoryChart = echarts.init(categoryChartRef.value)
|
||||
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 80, right: 30, top: 20, bottom: 20 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: list.map(i => i.CategoryName || '未分类'), axisLabel: { width: 80, overflow: 'truncate' } },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: list.map(i => i.Count),
|
||||
itemStyle: { color: '#409EFF', borderRadius: [0, 4, 4, 0] },
|
||||
label: { show: true, position: 'right' }
|
||||
}]
|
||||
})
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
async function loadDepartmentData() {
|
||||
try {
|
||||
const list = await getDepartmentStatistics() || []
|
||||
if (!deptChartRef.value) return
|
||||
if (deptChart) deptChart.dispose()
|
||||
deptChart = echarts.init(deptChartRef.value)
|
||||
|
||||
deptChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 100, right: 30, top: 20, bottom: 20 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: list.map(i => i.Department || '未分配'), axisLabel: { width: 80, overflow: 'truncate' } },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: list.map(i => i.Count),
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{ offset: 0, color: '#409EFF' },
|
||||
{ offset: 1, color: '#79BBFF' }
|
||||
]),
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
},
|
||||
label: { show: true, position: 'right' }
|
||||
}]
|
||||
})
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
statusChart?.resize()
|
||||
categoryChart?.resize()
|
||||
deptChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
onMounted(() => window.addEventListener('resize', handleResize))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
statusChart?.dispose()
|
||||
categoryChart?.dispose()
|
||||
deptChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-page { padding: 0; }
|
||||
.stat-cards { margin-bottom: 16px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-card .stat-value { font-size: 28px; font-weight: bold; color: #303133; }
|
||||
.stat-card .stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||
.stat-card.in-use .stat-value { color: #409EFF; }
|
||||
.stat-card.idle .stat-value { color: #E6A23C; }
|
||||
.stat-card.scrapped .stat-value { color: #909399; }
|
||||
.stat-card.repairing .stat-value { color: #F56C6C; }
|
||||
.stat-card.other .stat-value { color: #A8ABB2; }
|
||||
.chart-row { margin-bottom: 16px; }
|
||||
.chart-container { height: 300px; }
|
||||
</style>
|
||||
@@ -1,14 +1,45 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>统计报表</span>
|
||||
</template>
|
||||
<el-empty description="统计报表 - 功能开发中" />
|
||||
</el-card>
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="设备统计报表" name="device">
|
||||
<DeviceStatistics />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="设备维护报表" name="maintenance">
|
||||
<MaintenanceReport />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="维修类报表" name="repair">
|
||||
<RepairReport />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现统计报表功能
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import DeviceStatistics from './DeviceStatistics.vue'
|
||||
import MaintenanceReport from './MaintenanceReport.vue'
|
||||
import RepairReport from './RepairReport.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const activeTab = ref('device')
|
||||
|
||||
function onTabChange(name) {
|
||||
router.push({ name: `${name.charAt(0).toUpperCase() + name.slice(1)}Report` })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 根据路由参数切换Tab
|
||||
const path = route.path
|
||||
if (path.includes('maintenance')) activeTab.value = 'maintenance'
|
||||
else if (path.includes('repair')) activeTab.value = 'repair'
|
||||
else activeTab.value = 'device'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 0; }
|
||||
:deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
:deep(.el-tabs__content) { padding-top: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="report-page">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" class="stat-cards">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ maintStats.Total }}</div>
|
||||
<div class="stat-label">设备总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card normal">
|
||||
<div class="stat-value">{{ maintStats.CalibrationNormal }}</div>
|
||||
<div class="stat-label">校准正常</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card overdue">
|
||||
<div class="stat-value">{{ maintStats.CalibrationOverdue }}</div>
|
||||
<div class="stat-label">校准超期</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card rate">
|
||||
<div class="stat-value">{{ maintStats.CalibrationTimelyRate }}%</div>
|
||||
<div class="stat-label">校准及时率</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="stat-cards">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ maintStats.MaintenanceNormal }}</div>
|
||||
<div class="stat-label">保养正常</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card overdue">
|
||||
<div class="stat-value">{{ maintStats.MaintenanceOverdue }}</div>
|
||||
<div class="stat-label">保养超期</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card rate">
|
||||
<div class="stat-value">{{ maintStats.MaintenanceTimelyRate }}%</div>
|
||||
<div class="stat-label">保养及时率</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ overdueList.length }}</div>
|
||||
<div class="stat-label">超期记录数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表 -->
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span>校准状态分布</span></template>
|
||||
<div ref="calChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span>保养状态分布</span></template>
|
||||
<div ref="maintChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 超期明细表 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>维护超期明细({{ overdueList.length }} 条)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="overdueList" v-loading="loading" stripe>
|
||||
<el-table-column prop="EquipmentCode" label="设备编号" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="EquipmentName" label="设备名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="Department" label="部门" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="OverdueType" label="超期类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.OverdueType === '校准超期' ? 'warning' : 'danger'">{{ row.OverdueType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期日期" width="120">
|
||||
<template #default="{ row }">{{ fmtTime(row.ExpireDate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="OverdueDays" label="超期天数" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" effect="dark">{{ row.OverdueDays }} 天</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getMaintenanceStatistics, getMaintenanceOverdueDetails } from '@/api/statisticsReport'
|
||||
|
||||
const maintStats = ref({
|
||||
Total: 0, CalibrationNormal: 0, CalibrationOverdue: 0,
|
||||
MaintenanceNormal: 0, MaintenanceOverdue: 0,
|
||||
CalibrationTimelyRate: 0, MaintenanceTimelyRate: 0
|
||||
})
|
||||
const overdueList = ref([])
|
||||
const loading = ref(false)
|
||||
const calChartRef = ref(null)
|
||||
const maintChartRef = ref(null)
|
||||
|
||||
let calChart = null
|
||||
let maintChart = null
|
||||
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 10) : '-'
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
maintStats.value = await getMaintenanceStatistics() || maintStats.value
|
||||
overdueList.value = await getMaintenanceOverdueDetails() || []
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
renderCalChart()
|
||||
renderMaintChart()
|
||||
}
|
||||
|
||||
function renderCalChart() {
|
||||
if (!calChartRef.value) return
|
||||
if (calChart) calChart.dispose()
|
||||
calChart = echarts.init(calChartRef.value)
|
||||
|
||||
calChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{c}台' },
|
||||
data: [
|
||||
{ value: maintStats.value.CalibrationNormal, name: '正常', itemStyle: { color: '#67C23A' } },
|
||||
{ value: maintStats.value.CalibrationOverdue, name: '超期', itemStyle: { color: '#F56C6C' } }
|
||||
].filter(d => d.value > 0)
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function renderMaintChart() {
|
||||
if (!maintChartRef.value) return
|
||||
if (maintChart) maintChart.dispose()
|
||||
maintChart = echarts.init(maintChartRef.value)
|
||||
|
||||
maintChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{c}台' },
|
||||
data: [
|
||||
{ value: maintStats.value.MaintenanceNormal, name: '正常', itemStyle: { color: '#67C23A' } },
|
||||
{ value: maintStats.value.MaintenanceOverdue, name: '超期', itemStyle: { color: '#F56C6C' } }
|
||||
].filter(d => d.value > 0)
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
calChart?.resize()
|
||||
maintChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
onMounted(() => window.addEventListener('resize', handleResize))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
calChart?.dispose()
|
||||
maintChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-page { padding: 0; }
|
||||
.stat-cards { margin-bottom: 16px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-card .stat-value { font-size: 28px; font-weight: bold; color: #303133; }
|
||||
.stat-card .stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||
.stat-card.normal .stat-value { color: #67C23A; }
|
||||
.stat-card.overdue .stat-value { color: #F56C6C; }
|
||||
.stat-card.rate .stat-value { color: #409EFF; }
|
||||
.chart-row { margin-bottom: 16px; }
|
||||
.chart-container { height: 280px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="report-page">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" class="stat-cards">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ repairStats.Total }}</div>
|
||||
<div class="stat-label">维修总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card completed">
|
||||
<div class="stat-value">{{ repairStats.Completed }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card pending">
|
||||
<div class="stat-value">{{ repairStats.Pending }}</div>
|
||||
<div class="stat-label">待处理</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card in-progress">
|
||||
<div class="stat-value">{{ repairStats.InProgress }}</div>
|
||||
<div class="stat-label">维修中</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card hours">
|
||||
<div class="stat-value">{{ repairStats.AvgRepairHours }}</div>
|
||||
<div class="stat-label">平均工时(h)</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stat-card cost">
|
||||
<div class="stat-value">¥{{ repairStats.TotalRepairCost?.toFixed(0) || 0 }}</div>
|
||||
<div class="stat-label">总费用</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表 -->
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span>故障类型分布</span></template>
|
||||
<div ref="faultChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><span>维修工时分析(按故障类型)</span></template>
|
||||
<div ref="hoursChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 维修记录明细表 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>维修记录明细({{ recordList.length }} 条)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recordList" v-loading="loading" stripe max-height="500">
|
||||
<el-table-column prop="EquipmentCode" label="设备编号" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="EquipmentName" label="设备名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="FaultType" label="故障类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.FaultType || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="FaultDescription" label="故障描述" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="报修日期" width="120">
|
||||
<template #default="{ row }">{{ fmtTime(row.ReportDate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="完成日期" width="120">
|
||||
<template #default="{ row }">{{ fmtTime(row.RepairEndDate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="RepairHours" label="工时(h)" width="90" align="center" />
|
||||
<el-table-column prop="RepairPerson" label="维修人员" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="RepairCost" label="费用(元)" width="100" align="right">
|
||||
<template #default="{ row }">{{ row.RepairCost ? `¥${row.RepairCost.toFixed(2)}` : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="RepairStatus" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.RepairStatus)" size="small">{{ row.RepairStatus || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getRepairStatistics, getFaultTypeDistribution, getRepairHoursAnalysis, getRepairRecordDetails } from '@/api/statisticsReport'
|
||||
|
||||
const repairStats = ref({ Total: 0, Completed: 0, InProgress: 0, Pending: 0, AvgRepairHours: 0, TotalRepairHours: 0, TotalRepairCost: 0 })
|
||||
const recordList = ref([])
|
||||
const loading = ref(false)
|
||||
const faultChartRef = ref(null)
|
||||
const hoursChartRef = ref(null)
|
||||
|
||||
let faultChart = null
|
||||
let hoursChart = null
|
||||
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 10) : '-'
|
||||
const statusTag = (s) => ({ '已完成': 'success', '维修中': 'warning', '待维修': 'info' }[s] || 'info')
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
repairStats.value = await getRepairStatistics() || repairStats.value
|
||||
recordList.value = await getRepairRecordDetails() || []
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
await loadFaultChart()
|
||||
await loadHoursChart()
|
||||
}
|
||||
|
||||
async function loadFaultChart() {
|
||||
if (!faultChartRef.value) return
|
||||
if (faultChart) faultChart.dispose()
|
||||
faultChart = echarts.init(faultChartRef.value)
|
||||
|
||||
try {
|
||||
const list = await getFaultTypeDistribution() || []
|
||||
const colors = ['#F56C6C', '#E6A23C', '#409EFF', '#67C23A', '#909399', '#A8ABB2']
|
||||
faultChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c}次 ({d}%)' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{c}次' },
|
||||
data: list.map((i, idx) => ({
|
||||
value: i.Count, name: i.FaultType || '未知',
|
||||
itemStyle: { color: colors[idx % colors.length] }
|
||||
}))
|
||||
}]
|
||||
})
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
async function loadHoursChart() {
|
||||
if (!hoursChartRef.value) return
|
||||
if (hoursChart) hoursChart.dispose()
|
||||
hoursChart = echarts.init(hoursChartRef.value)
|
||||
|
||||
try {
|
||||
const list = await getRepairHoursAnalysis() || []
|
||||
hoursChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['平均工时', '最大工时', '最小工时'], bottom: 0 },
|
||||
grid: { left: 80, right: 30, top: 20, bottom: 50 },
|
||||
xAxis: { type: 'category', data: list.map(i => i.FaultType || '未知'), axisLabel: { rotate: 30 } },
|
||||
yAxis: { type: 'value', name: '小时' },
|
||||
series: [
|
||||
{ name: '平均工时', type: 'bar', data: list.map(i => Number(i.AvgHours.toFixed(1))), itemStyle: { color: '#409EFF' } },
|
||||
{ name: '最大工时', type: 'bar', data: list.map(i => Number(i.MaxHours.toFixed(1))), itemStyle: { color: '#F56C6C' } },
|
||||
{ name: '最小工时', type: 'bar', data: list.map(i => Number(i.MinHours.toFixed(1))), itemStyle: { color: '#67C23A' } }
|
||||
]
|
||||
})
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
faultChart?.resize()
|
||||
hoursChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
onMounted(() => window.addEventListener('resize', handleResize))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
faultChart?.dispose()
|
||||
hoursChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-page { padding: 0; }
|
||||
.stat-cards { margin-bottom: 16px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-card .stat-value { font-size: 24px; font-weight: bold; color: #303133; }
|
||||
.stat-card .stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||
.stat-card.completed .stat-value { color: #67C23A; }
|
||||
.stat-card.pending .stat-value { color: #E6A23C; }
|
||||
.stat-card.in-progress .stat-value { color: #409EFF; }
|
||||
.stat-card.hours .stat-value { color: #909399; }
|
||||
.stat-card.cost .stat-value { color: #F56C6C; }
|
||||
.chart-row { margin-bottom: 16px; }
|
||||
.chart-container { height: 300px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-title">苏试试验设备管理平台</div>
|
||||
<div class="login-subtitle">Suzhou Testing Equipment Management Platform</div>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" size="large" @keyup.enter="onSubmit">
|
||||
<el-form-item prop="UserName">
|
||||
<el-input v-model="form.UserName" placeholder="用户名" :prefix-icon="User" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item prop="Password">
|
||||
<el-input v-model="form.Password" type="password" placeholder="密码" :prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" class="login-btn" :loading="loading" @click="onSubmit">登 录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="login-tip">默认管理员:admin / admin123</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 清除 401 重定向标记(request.js 里 401 时设置)
|
||||
sessionStorage.removeItem('authRedirecting')
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const form = reactive({ UserName: '', Password: '' })
|
||||
|
||||
const formRules = {
|
||||
UserName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
Password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login({ UserName: form.UserName, Password: form.Password })
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.replace(redirect)
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2d3d 0%, #2b3a4e 50%, #32475e 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 380px;
|
||||
padding: 40px 36px 28px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.login-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: #303133;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-subtitle {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.login-tip {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,142 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>操作审计日志</span>
|
||||
<div class="card-header">
|
||||
<span>操作审计日志(共 {{ total }} 条)</span>
|
||||
<el-button link type="primary" @click="fetchList">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="操作审计日志 - 功能开发中" />
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input v-model="keyword" placeholder="操作人 / 描述" clearable style="width: 200px"
|
||||
@keyup.enter="onSearch" @clear="onSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="searchType" placeholder="操作类型(全部)" clearable style="width: 140px" @change="onSearch">
|
||||
<el-option v-for="(label, val) in OPERATION_TYPES" :key="val" :label="label" :value="val" />
|
||||
</el-select>
|
||||
<el-select v-model="searchTarget" placeholder="操作对象(全部)" clearable style="width: 140px" @change="onSearch">
|
||||
<el-option v-for="t in OPERATION_TARGETS" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
<el-date-picker v-model="dateRange" type="datetimerange" start-placeholder="开始时间" end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss" style="width: 340px" @change="onSearch" />
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe border row-key="Id">
|
||||
<el-table-column label="操作时间" width="165">
|
||||
<template #default="{ row }">{{ fmtTime(row.OperateTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="UserName" label="操作人" width="110" />
|
||||
<el-table-column prop="RoleName" label="角色" width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTag(row.OperationType)" size="small">{{ typeLabel(row.OperationType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="OperationTarget" label="操作对象" width="120" />
|
||||
<el-table-column prop="Description" label="描述" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="Ip" label="IP" width="130" />
|
||||
<el-table-column label="操作前值" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.OldValue" type="primary" @click="showValue(row, 'OldValue')">查看</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作后值" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.NewValue" type="primary" @click="showValue(row, 'NewValue')">查看</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total"
|
||||
:page-sizes="[20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="fetchList" @size-change="onSizeChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 值查看对话框 -->
|
||||
<el-dialog v-model="valueVisible" :title="valueTitle" width="560px">
|
||||
<pre class="value-pre">{{ valueContent }}</pre>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现操作审计日志功能
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAuditLogList } from '@/api/audit'
|
||||
|
||||
const OPERATION_TYPES = { Create: '新增', Update: '修改', Delete: '删除', Command: '指令', Login: '登录', Logout: '登出' }
|
||||
const OPERATION_TARGETS = ['IotDevice', 'Gateway', 'User', 'Role', 'Permission']
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const keyword = ref('')
|
||||
const searchType = ref('')
|
||||
const searchTarget = ref('')
|
||||
const dateRange = ref(null)
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = (await getAuditLogList({
|
||||
pageIndex: page.value, pageSize: pageSize.value,
|
||||
keyword: keyword.value || undefined,
|
||||
operationType: searchType.value || undefined,
|
||||
operationTarget: searchTarget.value || undefined,
|
||||
startTime: dateRange.value?.[0] || undefined,
|
||||
endTime: dateRange.value?.[1] || undefined
|
||||
})) || []
|
||||
total.value = list.value.total || 0
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; fetchList() }
|
||||
function onReset() {
|
||||
keyword.value = ''; searchType.value = ''; searchTarget.value = ''; dateRange.value = null
|
||||
page.value = 1; fetchList()
|
||||
}
|
||||
function onSizeChange() { page.value = 1; fetchList() }
|
||||
|
||||
// ---------- 值查看 ----------
|
||||
const valueVisible = ref(false)
|
||||
const valueTitle = ref('')
|
||||
const valueContent = ref('')
|
||||
|
||||
function showValue(row, field) {
|
||||
valueTitle.value = field === 'OldValue' ? '操作前值' : '操作后值'
|
||||
try { valueContent.value = JSON.stringify(JSON.parse(row[field]), null, 2) }
|
||||
catch { valueContent.value = row[field] }
|
||||
valueVisible.value = true
|
||||
}
|
||||
|
||||
const typeLabel = (t) => OPERATION_TYPES[t] || t
|
||||
const typeTag = (t) => ({ Delete: 'danger', Command: 'warning', Login: 'success', Create: 'success' }[t] || 'info')
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
|
||||
|
||||
onMounted(() => fetchList())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-bar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.value-pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,235 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>组织架构管理</span>
|
||||
<div class="card-header">
|
||||
<span>组织架构管理(公司 / 实验室 / 部门 / 班组)</span>
|
||||
<el-button type="primary" icon="Plus" @click="openAddRoot">新增公司</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="组织架构管理 - 功能开发中" />
|
||||
|
||||
<el-alert type="info" :closable="false" class="org-tip"
|
||||
title="层级固定:公司 → 实验室 → 部门 → 班组(白班/夜班)。用户挂靠任一层级节点,角色的数据范围按「本组织及下级」隔离。下表展示各组织的数据权限覆盖情况。" />
|
||||
|
||||
<div class="tree-toolbar">
|
||||
<el-input v-model="filterText" placeholder="输入名称/编码过滤" clearable style="width: 260px">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button link type="primary" @click="fetchTree">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="treeRows" v-loading="loading" row-key="Id" :tree-props="{ children: 'Children' }"
|
||||
border default-expand-all :key="tableKey">
|
||||
<el-table-column prop="Name" label="组织名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-icon class="org-icon"><component :is="ORG_TYPES[row.OrgType]?.icon || 'Folder'" /></el-icon>
|
||||
<span>{{ row.Name }}</span>
|
||||
<el-tag v-if="row.OrgType === 4" size="small" :type="row.ShiftType === 2 ? 'info' : 'success'" class="shift-tag">
|
||||
{{ shiftLabel(row.ShiftType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Code" label="编码" width="120" />
|
||||
<el-table-column label="类型" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="orgTypeTag(row.OrgType)" size="small">{{ orgTypeLabel(row.OrgType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户数" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'has-users': row.TotalUserCount > 0 }">{{ row.TotalUserCount }}</span>
|
||||
<span v-if="row.UserCount !== row.TotalUserCount" class="direct-count">(直挂 {{ row.UserCount }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据权限" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.ScopeSummary && row.ScopeSummary.length">
|
||||
<el-tag v-for="s in row.ScopeSummary" :key="s" size="small"
|
||||
:type="s.endsWith('·全部') ? 'danger' : 'warning'" class="scope-tag">{{ s }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-scope">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Leader" label="负责人" width="90" />
|
||||
<el-table-column prop="Phone" label="联系电话" width="120" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.OrgType < 4" link type="primary" @click="openAddChild(row)">添加下级</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑组织节点 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
|
||||
<el-form-item label="上级组织">
|
||||
<el-input :value="parentName || '(根节点)'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="组织类型">
|
||||
<el-input :value="orgTypeLabel(form.OrgType)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.OrgType === 4" label="班次" prop="ShiftType">
|
||||
<el-radio-group v-model="form.ShiftType">
|
||||
<el-radio v-for="s in shiftOptions" :key="s.value" :value="s.value">{{ s.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="组织名称" prop="Name">
|
||||
<el-input v-model="form.Name" placeholder="如 一号实验室 / 温控部 / 白班一组" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="组织编码" prop="Code">
|
||||
<el-input v-model="form.Code" placeholder="唯一编码,如 LAB-01" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="form.Leader" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="form.Phone" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.Sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现组织架构管理功能
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getOrgTree, addOrg, updateOrg, deleteOrg,
|
||||
ORG_TYPES, orgTypeLabel, orgTypeTag, shiftLabel, shiftOptions
|
||||
} from '@/api/org'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref([])
|
||||
const filterText = ref('')
|
||||
const tableKey = ref(0)
|
||||
|
||||
// 前端过滤树(按名称/编码过滤后保留路径)
|
||||
const treeRows = computed(() => {
|
||||
const kw = filterText.value.trim().toLowerCase()
|
||||
if (!kw) return tree.value
|
||||
const filterNodes = (nodes) => {
|
||||
const result = []
|
||||
for (const n of nodes) {
|
||||
const children = filterNodes(n.Children || [])
|
||||
if (n.Name?.toLowerCase().includes(kw) || n.Code?.toLowerCase().includes(kw) || children.length > 0) {
|
||||
result.push({ ...n, Children: children })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return filterNodes(tree.value)
|
||||
})
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = (await getOrgTree()) || []
|
||||
tableKey.value++
|
||||
} catch { tree.value = [] } finally { loading.value = false }
|
||||
}
|
||||
|
||||
// ---------- 新增/编辑 ----------
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
const parentName = ref('')
|
||||
|
||||
const defaultForm = () => ({
|
||||
Id: '', ParentId: '0', Name: '', Code: '',
|
||||
OrgType: 1, ShiftType: 0, Leader: '', Phone: '', Sort: 0, Remark: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
// 层级固定:公司→实验室→部门→班组
|
||||
const CHILD_TYPE_MAP = { 1: [2], 2: [3], 3: [4] }
|
||||
|
||||
const formRules = {
|
||||
Name: [{ required: true, message: '请输入组织名称', trigger: 'blur' }],
|
||||
Code: [{ required: true, message: '请输入组织编码', trigger: 'blur' }],
|
||||
ShiftType: [{ required: true, message: '请选择班次', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() => form.Id ? '修改组织' : `新增「${parentName.value || '公司'}」`)
|
||||
|
||||
function openAddRoot() {
|
||||
Object.assign(form, defaultForm(), { OrgType: 1, ParentId: '0' })
|
||||
parentName.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openAddChild(row) {
|
||||
Object.assign(form, defaultForm())
|
||||
form.ParentId = row.Id
|
||||
// 层级固定:自动推断唯一的下级类型
|
||||
const allowed = CHILD_TYPE_MAP[row.OrgType] || []
|
||||
form.OrgType = allowed[0] || 4
|
||||
if (form.OrgType === 4) form.ShiftType = 1
|
||||
parentName.value = `${row.Name}(${orgTypeLabel(row.OrgType)})下级`
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function findNodeById(nodes, id) {
|
||||
for (const n of nodes) {
|
||||
if (n.Id === id) return n
|
||||
const found = findNodeById(n.Children || [], id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id, ParentId: row.ParentId, Name: row.Name, Code: row.Code,
|
||||
OrgType: row.OrgType, ShiftType: row.OrgType === 4 ? (row.ShiftType || 1) : 0,
|
||||
Leader: row.Leader || '', Phone: row.Phone || '',
|
||||
Sort: row.Sort, Remark: row.Remark || ''
|
||||
})
|
||||
const parent = findNodeById(tree.value, row.ParentId)
|
||||
parentName.value = parent ? `${parent.Name}(${orgTypeLabel(parent.OrgType)})` : ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form, ShiftType: form.OrgType === 4 ? form.ShiftType : 0 }
|
||||
if (form.Id) { await updateOrg(payload); ElMessage.success('修改成功') }
|
||||
else { await addOrg(payload); ElMessage.success('添加成功') }
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} catch { /* 拦截器已提示 */ } finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除组织「${row.Name}」吗?(有下级组织或挂靠用户时不可删除)`, '提示', { type: 'warning' })
|
||||
try { await deleteOrg(row.Id); ElMessage.success('删除成功'); fetchTree() } catch { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
onMounted(() => fetchTree())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.org-tip { margin-bottom: 14px; }
|
||||
.tree-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; max-width: 400px; }
|
||||
.org-icon { margin-right: 6px; vertical-align: -2px; }
|
||||
.shift-tag { margin-left: 6px; }
|
||||
.scope-tag { margin-right: 6px; margin-bottom: 2px; }
|
||||
.has-users { font-weight: 600; color: #409eff; }
|
||||
.direct-count { font-size: 12px; color: #909399; }
|
||||
.no-scope { color: #c0c4cc; }
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,231 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>角色权限管理</span>
|
||||
<div class="card-header">
|
||||
<span>角色权限管理({{ total }} 个)</span>
|
||||
<el-button v-permission="'user:manage'" type="primary" icon="Plus" @click="openAdd">添加角色</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="角色权限管理 - 功能开发中" />
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input v-model="keyword" placeholder="角色编码 / 名称" clearable style="width: 260px"
|
||||
@keyup.enter="onSearch" @clear="onSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe border row-key="Id">
|
||||
<el-table-column prop="Code" label="角色编码" width="140" />
|
||||
<el-table-column prop="Name" label="角色名称" width="140" />
|
||||
<el-table-column label="数据范围" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="dataScopeTag(row.DataScope)" size="small">{{ dataScopeLabel(row.DataScope) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="code in (row.PermissionCodes || [])" :key="code" size="small" class="perm-tag">
|
||||
{{ permName(code) }}
|
||||
</el-tag>
|
||||
<span v-if="!(row.PermissionCodes || []).length">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内置" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.IsSystem === 1" type="warning" size="small">内置</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Remark" label="备注" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="openPerm(row)">分配权限</el-button>
|
||||
<el-button v-if="row.IsSystem !== 1" link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<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, jumper"
|
||||
@current-change="fetchList" @size-change="onSizeChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑角色 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '修改角色' : '添加角色'" width="520px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
|
||||
<el-form-item label="角色编码" prop="Code">
|
||||
<el-input v-model="form.Code" placeholder="唯一编码,如 labadmin" maxlength="64"
|
||||
:disabled="!!form.Id && form.IsSystem === 1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" prop="Name">
|
||||
<el-input v-model="form.Name" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数据范围" prop="DataScope">
|
||||
<el-select v-model="form.DataScope" style="width: 100%">
|
||||
<el-option label="全部" :value="1" />
|
||||
<el-option label="本组织及下级" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.Sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分配权限 -->
|
||||
<el-dialog v-model="permVisible" :title="`分配权限 - ${permRole?.Name || ''}`" width="440px" destroy-on-close>
|
||||
<el-checkbox-group v-model="permSelectedIds">
|
||||
<div v-for="group in groupedPermissions" :key="group.name" class="perm-group">
|
||||
<div class="perm-group-title">{{ group.name }}</div>
|
||||
<el-checkbox v-for="p in group.items" :key="p.Id" :value="p.Id">{{ p.Name }}({{ p.Code }})</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="permVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="permSaving" @click="onSavePermissions">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现角色权限管理功能
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getRoleList, getRoleOptions, getPermissionList, addRole, updateRole, deleteRole, assignRolePermissions,
|
||||
dataScopeLabel, dataScopeTag
|
||||
} from '@/api/role'
|
||||
|
||||
// ---------- 列表 ----------
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const keyword = ref('')
|
||||
const allPermissions = ref([])
|
||||
|
||||
const PERM_GROUP_NAMES = {
|
||||
device: '设备', alert: '告警', inspection: '巡检', user: '系统'
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = (await getRoleList({
|
||||
pageIndex: page.value, pageSize: pageSize.value,
|
||||
keyword: keyword.value || undefined
|
||||
})) || []
|
||||
total.value = list.value.total || 0
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function fetchPermissions() {
|
||||
try { allPermissions.value = (await getPermissionList()) || [] } catch { allPermissions.value = [] }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; fetchList() }
|
||||
function onReset() { keyword.value = ''; page.value = 1; fetchList() }
|
||||
function onSizeChange() { page.value = 1; fetchList() }
|
||||
|
||||
function permName(code) {
|
||||
const p = allPermissions.value.find(x => x.Code === code)
|
||||
return p?.Name || code
|
||||
}
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups = {}
|
||||
for (const p of allPermissions.value) {
|
||||
const g = p.Group || '其他'
|
||||
if (!groups[g]) groups[g] = { name: PERM_GROUP_NAMES[g] || g, items: [] }
|
||||
groups[g].items.push(p)
|
||||
}
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
// ---------- 新增/修改 ----------
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const defaultForm = () => ({ Id: '', Code: '', Name: '', DataScope: 2, Sort: 0, IsSystem: 0, Remark: '' })
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const formRules = {
|
||||
Code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
Name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
DataScope: [{ required: true, message: '请选择数据范围', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function openAdd() { Object.assign(form, defaultForm()); dialogVisible.value = true }
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id, Code: row.Code, Name: row.Name,
|
||||
DataScope: row.DataScope, Sort: row.Sort, IsSystem: row.IsSystem, Remark: row.Remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (form.Id) { await updateRole({ ...form }); ElMessage.success('修改成功') }
|
||||
else { await addRole({ ...form }); ElMessage.success('添加成功') }
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch { /* 拦截器已提示 */ } finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ---------- 分配权限 ----------
|
||||
const permVisible = ref(false)
|
||||
const permSaving = ref(false)
|
||||
const permRole = ref(null)
|
||||
const permSelectedIds = ref([])
|
||||
|
||||
function openPerm(row) {
|
||||
permRole.value = row
|
||||
permSelectedIds.value = [...(row.PermissionIds || [])]
|
||||
permVisible.value = true
|
||||
}
|
||||
|
||||
async function onSavePermissions() {
|
||||
permSaving.value = true
|
||||
try {
|
||||
await assignRolePermissions(permRole.value.Id, permSelectedIds.value)
|
||||
ElMessage.success('权限已更新')
|
||||
permVisible.value = false
|
||||
fetchList()
|
||||
} catch { /* 拦截器已提示 */ } finally { permSaving.value = false }
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除角色「${row.Name}」吗?`, '提示', { type: 'warning' })
|
||||
try { await deleteRole(row.Id); ElMessage.success('删除成功'); fetchList() } catch { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchList(); fetchPermissions() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-bar { display: flex; gap: 10px; margin-bottom: 16px; }
|
||||
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.perm-tag { margin-right: 4px; margin-bottom: 2px; }
|
||||
.perm-group { margin-bottom: 12px; }
|
||||
.perm-group-title { font-size: 13px; color: #909399; margin-bottom: 4px; }
|
||||
.perm-group .el-checkbox { margin-right: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,270 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>用户管理</span>
|
||||
<div class="card-header">
|
||||
<span>用户管理({{ total }} 人)</span>
|
||||
<el-button v-permission="'user:manage'" type="primary" icon="Plus" @click="openAdd">添加用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="用户管理 - 功能开发中" />
|
||||
|
||||
<div class="search-bar">
|
||||
<el-tree-select v-model="searchOrgId" :data="orgTree" :props="{ label: 'Name', children: 'Children' }"
|
||||
node-key="Id" check-strictly clearable placeholder="所属组织(全部)" style="width: 200px" @change="onSearch" />
|
||||
<el-select v-model="searchRoleId" placeholder="所属角色(全部)" clearable style="width: 180px" @change="onSearch">
|
||||
<el-option v-for="r in roleOptions" :key="r.Id" :label="r.Name" :value="r.Id" />
|
||||
</el-select>
|
||||
<el-input v-model="keyword" placeholder="用户名 / 姓名" clearable style="width: 240px"
|
||||
@keyup.enter="onSearch" @clear="onSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe border row-key="Id">
|
||||
<el-table-column prop="UserName" label="用户名" width="120" />
|
||||
<el-table-column prop="RealName" label="姓名" width="100" />
|
||||
<el-table-column label="组织" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.OrgName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Position" label="岗位" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.Position || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="技能标签" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="tag in parseTags(row.SkillTags)" :key="tag" size="small" class="skill-tag">{{ tag }}</el-tag>
|
||||
<span v-if="!parseTags(row.SkillTags).length">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="name in (row.RoleNames || '').split(',').filter(Boolean)" :key="name"
|
||||
size="small" type="info" class="skill-tag">{{ name }}</el-tag>
|
||||
<span v-if="!row.RoleNames">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.IsEnabled === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.IsEnabled === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.LastLoginTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="warning" @click="onResetPassword(row)">重置密码</el-button>
|
||||
<el-button v-if="row.Id !== userStore.userInfo?.Id" link :type="row.IsEnabled === 1 ? 'info' : 'success'" @click="onToggleEnabled(row)">
|
||||
{{ row.IsEnabled === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button v-if="row.Id !== userStore.userInfo?.Id" link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<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, jumper"
|
||||
@current-change="fetchList" @size-change="onSizeChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑用户 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '修改用户' : '添加用户'" width="560px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
|
||||
<el-form-item label="用户名" prop="UserName">
|
||||
<el-input v-model="form.UserName" placeholder="登录账号" maxlength="64" :disabled="!!form.Id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="form.RealName" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!form.Id" label="初始密码" prop="InitialPassword">
|
||||
<el-input v-model="form.InitialPassword" type="password" placeholder="不少于 6 位" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属组织" prop="OrgId">
|
||||
<el-tree-select v-model="form.OrgId" :data="orgTree" :props="{ label: 'Name', children: 'Children' }"
|
||||
node-key="Id" check-strictly clearable placeholder="选择组织节点(公司/实验室/部门/班组)" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位">
|
||||
<el-input v-model="form.Position" placeholder="如 设备工程师 / 操作员" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="技能标签">
|
||||
<el-select v-model="form.TagList" multiple filterable allow-create default-first-option
|
||||
placeholder="输入或选择技能标签" style="width: 100%">
|
||||
<el-option v-for="tag in presetTags" :key="tag" :label="tag" :value="tag" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.Phone" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.Email" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="RoleIds">
|
||||
<el-select v-model="form.RoleIds" multiple placeholder="选择角色" style="width: 100%">
|
||||
<el-option v-for="r in roleOptions" :key="r.Id" :label="r.Name" :value="r.Id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现用户管理功能
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getUserList, addUser, updateUser, deleteUser, resetUserPassword, setUserEnabled } from '@/api/user'
|
||||
import { getRoleOptions } from '@/api/role'
|
||||
import { getOrgTree } from '@/api/org'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 预置技能标签建议(可自由输入新标签)
|
||||
const presetTags = ['Modbus', 'PLC', 'S7', '温度箱', '充放电柜', '电气维修', '机械维修', '计量校准', '巡检']
|
||||
|
||||
// ---------- 列表 ----------
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const keyword = ref('')
|
||||
const searchRoleId = ref('')
|
||||
const searchOrgId = ref(null)
|
||||
const roleOptions = ref([])
|
||||
const orgTree = ref([])
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = (await getUserList({
|
||||
pageIndex: page.value, pageSize: pageSize.value,
|
||||
keyword: keyword.value || undefined,
|
||||
roleId: searchRoleId.value || undefined,
|
||||
orgId: searchOrgId.value || undefined
|
||||
})) || []
|
||||
total.value = list.value.total || 0
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
try { roleOptions.value = (await getRoleOptions()) || [] } catch { roleOptions.value = [] }
|
||||
}
|
||||
|
||||
async function fetchOrgTree() {
|
||||
try { orgTree.value = (await getOrgTree()) || [] } catch { orgTree.value = [] }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; fetchList() }
|
||||
function onReset() {
|
||||
keyword.value = ''; searchRoleId.value = ''; searchOrgId.value = null
|
||||
page.value = 1; fetchList()
|
||||
}
|
||||
function onSizeChange() { page.value = 1; fetchList() }
|
||||
|
||||
function parseTags(tags) {
|
||||
return (tags || '').split(',').map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// ---------- 新增/修改 ----------
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
Id: '', UserName: '', RealName: '', InitialPassword: '',
|
||||
OrgId: null, Position: '', TagList: [],
|
||||
Phone: '', Email: '', RoleIds: [], Remark: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const formRules = {
|
||||
UserName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
InitialPassword: [
|
||||
{ required: true, message: '请输入初始密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' }
|
||||
],
|
||||
RoleIds: [{ required: true, type: 'array', min: 1, message: '请选择至少一个角色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function openAdd() { Object.assign(form, defaultForm()); dialogVisible.value = true }
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id, UserName: row.UserName, RealName: row.RealName || '',
|
||||
OrgId: row.OrgId && row.OrgId !== '0' ? row.OrgId : null,
|
||||
Position: row.Position || '',
|
||||
TagList: parseTags(row.SkillTags),
|
||||
Phone: row.Phone || '', Email: row.Email || '',
|
||||
RoleIds: row.RoleIds || [], Remark: row.Remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
OrgId: form.OrgId || '0',
|
||||
SkillTags: (form.TagList || []).join(',')
|
||||
}
|
||||
if (form.Id) {
|
||||
await updateUser({ ...payload, InitialPassword: undefined, TagList: undefined })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await addUser({ ...payload, TagList: undefined })
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch { /* 拦截器已提示 */ } finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ---------- 重置密码 ----------
|
||||
async function onResetPassword(row) {
|
||||
const { value } = await ElMessageBox.prompt(`为用户「${row.UserName}」设置新密码:`, '重置密码', {
|
||||
inputType: 'password', inputPattern: /^.{6,}$/, inputErrorMessage: '密码长度不能少于 6 位'
|
||||
})
|
||||
await resetUserPassword(row.Id, value)
|
||||
ElMessage.success('密码已重置')
|
||||
}
|
||||
|
||||
// ---------- 启用/禁用 ----------
|
||||
async function onToggleEnabled(row) {
|
||||
const target = row.IsEnabled === 1 ? 0 : 1
|
||||
await ElMessageBox.confirm(
|
||||
`确定${target === 0 ? '禁用' : '启用'}用户「${row.UserName}」吗?`, '提示', { type: 'warning' })
|
||||
await setUserEnabled(row.Id, target === 1)
|
||||
ElMessage.success(target === 0 ? '已禁用' : '已启用')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除用户「${row.UserName}」吗?`, '提示', { type: 'warning' })
|
||||
try { await deleteUser(row.Id); ElMessage.success('删除成功'); fetchList() } catch { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
|
||||
|
||||
onMounted(() => { fetchList(); fetchRoleOptions(); fetchOrgTree() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-bar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.skill-tag { margin-right: 4px; margin-bottom: 2px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user