feat: 重建设备台账与凭据边界 (#65)

This commit is contained in:
QiuSW
2026-08-14 14:59:34 +08:00
parent 1ef4a1fe0b
commit be091f093d
18 changed files with 1468 additions and 2 deletions
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function listDevices(query) {
return request({ url: '/api/v1/devices', method: 'get', params: query })
}
export function getDevice(id) {
return request({ url: `/api/v1/devices/${id}`, method: 'get' })
}
export function addDevice(data) {
return request({ url: '/api/v1/devices', method: 'post', data })
}
export function updateDevice(id, data) {
return request({ url: `/api/v1/devices/${id}`, method: 'put', data })
}
export function disableDevice(id, data) {
return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
}
export function updateDeviceCredentials(id, data) {
return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
}
@@ -0,0 +1,32 @@
export function createDevicePayload(form) {
return {
name: form.name,
location: form.location,
modality: form.modality,
capabilities: [...form.capabilities]
}
}
export function updateDevicePayload(form) {
return {
name: form.name,
location: form.location,
capabilities: [...form.capabilities],
version: form.version
}
}
export function credentialPayload(form) {
return {
onvifUsername: form.onvifUsername,
onvifPassword: form.onvifPassword,
rtspSameAsOnvif: form.rtspSameAsOnvif,
rtspUsername: form.rtspSameAsOnvif ? '' : form.rtspUsername,
rtspPassword: form.rtspSameAsOnvif ? '' : form.rtspPassword,
version: form.version
}
}
export function disableDevicePayload(version) {
return { version }
}
+305
View File
@@ -0,0 +1,305 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<el-form ref="queryForm" :model="queryParams" :inline="true" class="search-form">
<el-form-item label="设备信息" prop="keyword">
<el-input v-model="queryParams.keyword" placeholder="名称或安装位置" clearable size="small" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="设备类型" prop="modality">
<el-select v-model="queryParams.modality" placeholder="全部类型" clearable size="small">
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable size="small">
<el-option label="待接入" value="pending" />
<el-option label="已停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" :icon="Search" @click="handleQuery">搜索</el-button>
<el-button size="small" :icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar mb8">
<el-button v-permisaction="['sense:device:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增设备</el-button>
</div>
<el-table v-loading="loading" :data="deviceList" border stripe>
<el-table-column label="设备名称" min-width="150" prop="name" />
<el-table-column label="安装位置" min-width="180" prop="location" show-overflow-tooltip />
<el-table-column label="设备类型" width="110" align="center">
<template #default="scope">{{ modalityLabel(scope.row.modality) }}</template>
</el-table-column>
<el-table-column label="能力" min-width="130">
<template #default="scope">
<el-tag v-for="capability in scope.row.capabilities" :key="capability" size="small" class="capability-tag">{{ modalityLabel(capability) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="接入能力" width="130" align="center">
<template #default="scope">
<el-tag :type="scope.row.adapterStatus === 'ready' ? 'success' : 'warning'">
{{ scope.row.adapterStatus === 'ready' ? '可接入' : '适配器未就绪' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="凭据" width="120" align="center">
<template #default="scope">
<el-tag :type="scope.row.onvifCredentialConfigured ? 'success' : 'info'">
{{ scope.row.onvifCredentialConfigured ? '已配置' : '未配置' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'disabled' ? 'info' : 'success'">{{ scope.row.status === 'disabled' ? '已停用' : '待接入' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="80" align="center" prop="version" />
<el-table-column label="更新时间" width="180" align="center">
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" align="center" fixed="right" class-name="small-padding fixed-width">
<template #default="scope">
<el-button v-permisaction="['sense:device:edit']" type="primary" link size="small" :icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
<el-divider direction="vertical" />
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
<el-divider direction="vertical" />
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="queryParams.pageIndex" v-model:page-size="queryParams.pageSize" :total="total" @pagination="getList" />
<el-dialog v-model="deviceOpen" :title="deviceTitle" width="620px" :close-on-click-modal="false">
<el-form ref="deviceForm" :model="deviceForm" :rules="deviceRules" label-width="100px">
<el-form-item label="设备名称" prop="name">
<el-input v-model="deviceForm.name" maxlength="128" show-word-limit placeholder="例如:东门摄像机" />
</el-form-item>
<el-form-item label="安装位置" prop="location">
<el-input v-model="deviceForm.location" maxlength="255" show-word-limit placeholder="例如:教学楼一楼东门" />
</el-form-item>
<el-form-item label="设备类型" prop="modality">
<el-select v-model="deviceForm.modality" :disabled="Boolean(deviceForm.id)" @change="handleModalityChange">
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span v-if="deviceForm.modality !== 'video'" class="form-tip">该类型尚未提供接入适配器,将显示“适配器未就绪”。</span>
</el-form-item>
<el-form-item label="设备能力" prop="capabilities">
<el-checkbox-group v-model="deviceForm.capabilities">
<el-checkbox v-for="item in modalityOptions" :key="item.value" :label="item.value">{{ item.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitDevice">确定</el-button>
<el-button @click="deviceOpen = false">取消</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="credentialOpen" title="更新摄像头凭据" width="620px" :close-on-click-modal="false" @closed="resetCredential">
<el-alert title="凭据只可写入,保存后不会回显。更新成功会请求后续接入流程重新验证。" type="warning" :closable="false" show-icon class="credential-alert" />
<el-form ref="credentialForm" :model="credentialForm" :rules="credentialRules" label-width="120px" autocomplete="off">
<el-form-item label="ONVIF 用户名" prop="onvifUsername">
<el-input v-model="credentialForm.onvifUsername" maxlength="255" autocomplete="off" />
</el-form-item>
<el-form-item label="ONVIF 密码" prop="onvifPassword">
<el-input v-model="credentialForm.onvifPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
</el-form-item>
<el-form-item label="RTSP 凭据">
<el-checkbox v-model="credentialForm.rtspSameAsOnvif">与 ONVIF 相同</el-checkbox>
</el-form-item>
<template v-if="!credentialForm.rtspSameAsOnvif">
<el-form-item label="RTSP 用户名" prop="rtspUsername">
<el-input v-model="credentialForm.rtspUsername" maxlength="255" autocomplete="off" />
</el-form-item>
<el-form-item label="RTSP 密码" prop="rtspPassword">
<el-input v-model="credentialForm.rtspPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
</el-form-item>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitCredential">安全保存</el-button>
<el-button @click="credentialOpen = false">取消</el-button>
</div>
</template>
</el-dialog>
</el-card>
</template>
</BasicLayout>
</template>
<script>
import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
addDevice,
disableDevice,
getDevice,
listDevices,
updateDevice,
updateDeviceCredentials
} from '@/api/sense/device'
import {
createDevicePayload,
credentialPayload,
disableDevicePayload,
updateDevicePayload
} from './devicePayload'
export default {
name: 'SenseDeviceManage',
setup() {
return { Edit, Key, Plus, Refresh, Search }
},
data() {
return {
loading: false,
total: 0,
deviceList: [],
deviceOpen: false,
credentialOpen: false,
deviceTitle: '',
queryParams: { pageIndex: 1, pageSize: 10, keyword: '', modality: '', status: '' },
modalityOptions: [
{ value: 'video', label: '视频设备' },
{ value: 'radar', label: '雷达' },
{ value: 'contact', label: '门磁' },
{ value: 'button', label: '按钮' },
{ value: 'wearable', label: '穿戴设备' },
{ value: 'other', label: '其他' }
],
deviceForm: {},
credentialForm: {},
deviceRules: {
name: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
modality: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
capabilities: [{ type: 'array', required: true, min: 1, message: '至少选择一项设备能力', trigger: 'change' }]
},
credentialRules: {
onvifUsername: [{ required: true, message: 'ONVIF 用户名不能为空', trigger: 'blur' }],
onvifPassword: [{ required: true, message: 'ONVIF 密码不能为空', trigger: 'blur' }],
rtspUsername: [{ required: true, message: 'RTSP 用户名不能为空', trigger: 'blur' }],
rtspPassword: [{ required: true, message: 'RTSP 密码不能为空', trigger: 'blur' }]
}
}
},
created() {
this.resetDevice()
this.resetCredential()
this.getList()
},
methods: {
getList() {
this.loading = true
listDevices(this.queryParams).then(response => {
this.deviceList = response.data.list
this.total = response.data.count
}).finally(() => {
this.loading = false
})
},
modalityLabel(value) {
return this.modalityOptions.find(item => item.value === value)?.label || value
},
resetDevice() {
this.deviceForm = { id: undefined, name: '', location: '', modality: 'video', capabilities: ['video'], version: undefined }
this.$nextTick(() => this.resetForm('deviceForm'))
},
resetCredential() {
this.credentialForm = {
id: undefined,
onvifUsername: '',
onvifPassword: '',
rtspSameAsOnvif: true,
rtspUsername: '',
rtspPassword: '',
version: undefined
}
this.$nextTick(() => this.resetForm('credentialForm'))
},
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleAdd() {
this.resetDevice()
this.deviceTitle = '新增设备'
this.deviceOpen = true
},
handleUpdate(row) {
this.resetDevice()
getDevice(row.id).then(response => {
this.deviceForm = { ...response.data, capabilities: [...response.data.capabilities] }
this.deviceTitle = '编辑设备'
this.deviceOpen = true
})
},
handleModalityChange(value) {
this.deviceForm.capabilities = [value]
},
submitDevice() {
this.$refs.deviceForm.validate(valid => {
if (!valid) return
const request = this.deviceForm.id
? updateDevice(this.deviceForm.id, updateDevicePayload(this.deviceForm))
: addDevice(createDevicePayload(this.deviceForm))
request.then(response => {
this.msgSuccess(response.msg)
this.deviceOpen = false
this.getList()
})
})
},
handleCredential(row) {
this.resetCredential()
this.credentialForm.id = row.id
this.credentialForm.version = row.version
this.credentialOpen = true
},
submitCredential() {
this.$refs.credentialForm.validate(valid => {
if (!valid) return
updateDeviceCredentials(this.credentialForm.id, credentialPayload(this.credentialForm)).then(response => {
this.msgSuccess(response.msg)
this.credentialOpen = false
this.getList()
})
})
},
handleDisable(row) {
this.$confirm(`停用“${row.name}”后,后续接入流程不会继续使用该设备。是否继续?`, '停用设备', {
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning'
}).then(() => disableDevice(row.id, disableDevicePayload(row.version))).then(response => {
this.msgSuccess(response.msg)
this.getList()
}).catch(() => {})
}
}
}
</script>
<style scoped>
.capability-tag {
margin-right: 4px;
}
.form-tip {
display: block;
width: 100%;
color: #909399;
font-size: 12px;
line-height: 20px;
}
.credential-alert {
margin-bottom: 18px;
}
</style>