Compare commits

...
3 Commits
Author SHA1 Message Date
dengbolingandClaude Sonnet 4.6 97ce665aec feat: 登录认证 + 用户/角色/组织架构/审计日志管理页
- 新增登录页、认证 store、路由守卫(未登录跳转 + meta.permissions 校验 + 401 统一处理)
- 新增 v-permission 按钮权限指令,Sidebar 按权限过滤菜单,Header 绑定真实用户+改密+退出
- 用户管理:增删改查、启停、重置密码、分配角色、组织树选择、岗位、技能标签
- 角色管理:预定义角色矩阵、权限分配、数据范围(全部/本组织及下级)
- 组织架构:固定层级 公司/实验室/部门/班组(白夜班) 树形管理 + 数据权限摘要展示
- 审计日志页:操作人/类型/对象/时间段筛选 + 前后值查看
- 补齐同事模块缺失文件:api/alert.js、utils/format.js(修复构建)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-10 17:30:44 +08:00
dengboling 240ede9769 feat: 网关管理页面 + 设备表单网关下拉联动
- 新增 src/api/gateway.js(网关 CRUD + 测试连接 + options API + 枚举映射)
- 新增 src/views/config/gateway/Index.vue(网关管理页面:列表/新增/编辑/测试连接/详情抽屉+下挂设备)
- 修改 src/views/config/device/Index.vue(GatewayCode 文本输入改为 GatewayId 下拉选择,列表列显示 GatewayName)
- 新增 src/api/product.js、src/api/thingPoint.js(产品/物模型点 API)
- 新增 src/components/thing/(PointSendForm、ThingPointTable、ThingAlarmPanel 共享组件)
2026-09-04 17:43:36 +08:00
dengboling a72e2e6ff5 添加产品管理、物模型配置、产品分类界面,完善设备管理的物模型配置界面 2026-09-03 17:39:48 +08:00
33 changed files with 3156 additions and 111 deletions
+3 -3
View File
@@ -5,11 +5,11 @@
<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-BaJKRrhL.js"></script>
<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">
<link rel="modulepreload" crossorigin href="/assets/request-D1PLBK09.js">
<link rel="stylesheet" crossorigin href="/assets/index-C42Vprib.css">
</head>
<body>
<div id="app"></div>
+10 -10
View File
@@ -1,49 +1,49 @@
{
"hash": "b6c330c5",
"configHash": "e57f8d4c",
"hash": "25a37344",
"configHash": "ec38f6fa",
"lockfileHash": "74076ca5",
"browserHash": "ed3d8378",
"browserHash": "8bfb7e71",
"optimized": {
"@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js",
"fileHash": "de153aba",
"fileHash": "53709748",
"needsInterop": false
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "c19fdb3e",
"fileHash": "8bc11b32",
"needsInterop": false
},
"echarts": {
"src": "../../echarts/index.js",
"file": "echarts.js",
"fileHash": "a8d7111b",
"fileHash": "bf5b1683",
"needsInterop": false
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "10cb780f",
"fileHash": "6f7bcfbf",
"needsInterop": false
},
"pinia": {
"src": "../../pinia/dist/pinia.js",
"file": "pinia.js",
"fileHash": "82d3c424",
"fileHash": "c89feaca",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.js",
"file": "vue-router.js",
"fileHash": "1b68fa4e",
"fileHash": "a9a2c9fa",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "7e296374",
"fileHash": "53d5f754",
"needsInterop": false
}
},
+116
View File
@@ -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}`)
+8 -1
View File
@@ -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)
+7
View File
@@ -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 })
+16
View File
@@ -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)
+3
View File
@@ -45,3 +45,6 @@ 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)
+54
View File
@@ -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`)
+42
View File
@@ -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}`)
+44
View File
@@ -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}`)
+36
View File
@@ -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'
+57
View File
@@ -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}`)
+31
View File
@@ -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 })
+154
View File
@@ -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>
+206
View File
@@ -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>
+229
View File
@@ -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>
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+2
View File
@@ -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')
+52 -8
View File
@@ -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,
@@ -143,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',
@@ -200,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',
@@ -287,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',
@@ -305,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',
@@ -352,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
+57
View File
@@ -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 }
})
+22 -40
View File
@@ -1,60 +1,42 @@
/**
* 通用格式化工具(后端返回的日期为 ISO 字符串,如 "2026-08-28T10:30:00"
* 通用格式化工具(asset 台账视图与告警页共用
*/
// 补零:5 → "05"
const pad = n => String(n).padStart(2, '0')
/**
* 格式化为日期:2026-08-28
* @param {string|Date} value 日期/时间值
* @returns {string} 空值返回 ''
*/
// 日期:YYYY-MM-DD(兼容 ISO 字符串 / Date
export function fmtDate(value) {
if (!value) return ''
if (!value) return '-'
const d = new Date(value)
if (isNaN(d.getTime())) return String(value).slice(0, 10)
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())}`
}
/**
* 格式化为日期时间:2026-08-28 10:30:00
* @param {string|Date} value 日期/时间值
* @returns {string} 空值返回 ''
*/
// 日期时间:YYYY-MM-DD HH:mm:ss
export function fmtDateTime(value) {
if (!value) return ''
if (!value) return '-'
const d = new Date(value)
if (isNaN(d.getTime())) return String(value).replace('T', ' ').slice(0, 19)
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())}`
}
/**
* 格式化文件大小:1024 → "1.00 KB"
* @param {number} bytes 字节数
* @returns {string} 空值返回 ''
*/
// 文件大小(字节 → 可读单位)
export function fmtFileSize(bytes) {
if (bytes === null || bytes === undefined || isNaN(bytes)) return ''
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let size = bytes
let i = -1
do {
size /= 1024
i++
} while (size >= 1024 && i < units.length - 1)
return `${size.toFixed(2)} ${units[i]}`
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`
}
/**
* 判断日期是否已超期(小于当前日期)
* @param {string|Date} value 日期值
* @returns {boolean}
*/
// 是否已逾期(日期早于今天且非空)
export function isOverdue(value) {
if (!value) return false
const d = new Date(value)
if (isNaN(d.getTime())) return false
return d < new Date()
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
View File
@@ -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)
}
)
+225 -17
View File
@@ -10,6 +10,9 @@
<!-- 查找设备输入框 -->
<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="请输入设备编号 / 名称 / 类型查找设备"
@@ -39,7 +42,17 @@
<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 prop="GatewayCode" label="所属网关" width="130" show-overflow-tooltip />
<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>
@@ -54,11 +67,21 @@
<el-table-column label="最后采集" width="160">
<template #default="{ row }">{{ fmtTime(row.LastCollectTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openLogs(row)">日志</el-button>
<el-button link type="primary" @click.stop="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click.stop="onDelete(row)">删除</el-button>
<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>
@@ -70,10 +93,16 @@
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
@click.stop
>
<div class="ctx-menu__item" @click="ctxEdit">
<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 danger" @click="ctxDelete">
<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>
@@ -106,8 +135,15 @@
<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="所属网关" prop="GatewayCode">
<el-input v-model="form.GatewayCode" placeholder="如 GW-ENV-01IP:端口或串口标识)" maxlength="64" />
<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%" />
@@ -133,6 +169,45 @@
</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">
@@ -168,12 +243,19 @@
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
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)
@@ -182,6 +264,25 @@ 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
@@ -189,7 +290,8 @@ async function fetchList() {
list.value = (await getDeviceList({
pageIndex: page.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined
keyword: keyword.value || undefined,
productId: searchProductId.value || 0
})) || []
total.value = list.value.total || 0
} catch (e) {
@@ -201,7 +303,7 @@ async function fetchList() {
}
function onSearch() { page.value = 1; fetchList() }
function onReset() { keyword.value = ''; page.value = 1; fetchList() }
function onReset() { keyword.value = ''; searchProductId.value = ''; page.value = 1; fetchList() }
function onSizeChange() { page.value = 1; fetchList() }
// ---------- 新增 / 修改 ----------
@@ -215,7 +317,7 @@ const deviceTypes = [
]
const defaultForm = () => ({
Id: '', Code: '', Name: '', DeviceType: '', GatewayCode: '',
Id: '', Code: '', Name: '', DeviceType: '', ProductId: '', GatewayId: '',
SlaveId: 1, ProtocolType: 1, Manufacturer: '', IsEnabled: true, Remark: ''
})
const form = reactive(defaultForm())
@@ -223,8 +325,7 @@ const form = reactive(defaultForm())
const formRules = {
Code: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
Name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
DeviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
GatewayCode: [{ required: true, message: '请输入所属网关', trigger: 'blur' }]
DeviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }]
}
function openAdd() {
@@ -238,7 +339,8 @@ function openEdit(row) {
Code: row.Code,
Name: row.Name,
DeviceType: row.DeviceType,
GatewayCode: row.GatewayCode,
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 || '',
@@ -293,14 +395,106 @@ function onRowContextmenu(row, column, event) {
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))
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)
@@ -419,4 +613,18 @@ const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
display: flex;
justify-content: flex-end;
}
.more-arrow {
margin-left: 2px;
}
</style>
<!-- 更多下拉挂载在 body teleportscoped 样式覆盖不到这里用全局样式标红删除项 -->
<style>
.el-dropdown-menu__item.more-danger {
color: #f56c6c;
}
.el-dropdown-menu__item.more-danger:hover {
color: #f56c6c;
background: #fef0f0;
}
</style>
+304 -3
View File
@@ -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>
+114 -2
View File
@@ -2,13 +2,125 @@
<div class="page-container">
<el-card>
<template #header>
<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>
+281 -3
View File
@@ -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>
+97
View File
@@ -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>
+132 -3
View File
@@ -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>
+225 -3
View File
@@ -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>
+221 -3
View File
@@ -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>
+260 -3
View File
@@ -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>