feat: add Sense device inventory and credential boundary (#23)

This commit is contained in:
QiuSW
2026-08-12 17:10:13 +08:00
parent b9416ebc08
commit 5b8f4a4c8c
14 changed files with 704 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
import request from '../../../bootstrap/request'
export const listDevices = (params) => request.get('/devices', { params })
export const createDevice = (data) => request.post('/devices', data)
export const updateDevice = (id, data) => request.patch(`/devices/${id}`, data)
export const updateCredential = (id, data) => request.post(`/devices/${id}/credentials`, data)
export const disableDevice = (id, version) => request.post(`/devices/${id}/disable`, { version })
@@ -0,0 +1,9 @@
import Devices from '../../views/sense/device/Devices.vue'
export default {
path: 'devices',
name: 'sense-devices',
component: Devices,
meta: { title: '设备管理', icon: 'VideoCamera', order: 10, permission: 'device.read' }
}
@@ -0,0 +1,75 @@
<template>
<section class="page-container">
<div class="page-heading"><div><h1>设备管理</h1><p>登记设备和安装位置;摄像机密码只可更新,不能查看。</p></div></div>
<div class="search-toolbar">
<el-form :inline="true" @submit.prevent="load">
<el-form-item label="名称/位置"><el-input v-model="query.keyword" clearable placeholder="输入设备名称或位置" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="load">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
</div>
<div class="table-container">
<div class="table-actions"><strong>设备列表</strong><el-button v-if="canWrite" type="primary" :icon="Plus" @click="openCreate">添加设备</el-button></div>
<el-table v-loading="loading" :data="page.items" border>
<el-table-column prop="name" label="设备名称" min-width="160" />
<el-table-column prop="location" label="安装位置" min-width="150" />
<el-table-column label="类型" width="110"><template #default="scope">{{ scope.row.modality === 'video' ? '视频设备' : scope.row.modality }}</template></el-table-column>
<el-table-column label="接入能力" width="140"><template #default="scope"><el-tag :type="scope.row.adapter_status === 'ready' ? 'success' : 'warning'">{{ scope.row.adapter_status === 'ready' ? '可接入' : '适配器未就绪' }}</el-tag></template></el-table-column>
<el-table-column label="凭据" width="110"><template #default="scope"><el-tag :type="scope.row.credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured ? '已配置' : '未配置' }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.status === 'disabled' ? 'info' : 'primary'">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column v-if="canWrite" label="操作" width="210" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button link type="primary" @click="openCredential(scope.row)">更新凭据</el-button><el-button v-if="scope.row.status !== 'disabled'" link type="danger" @click="disable(scope.row)">停用</el-button></template></el-table-column>
</el-table>
<div class="pagination"><el-pagination v-model:current-page="query.page" v-model:page-size="query.page_size" background layout="total, sizes, prev, pager, next" :total="page.total" :page-sizes="[20, 50, 100]" @change="load" /></div>
</div>
<el-dialog v-model="deviceDialog" :title="editing ? '编辑设备' : '添加设备'" width="560px" destroy-on-close>
<el-form ref="deviceFormRef" :model="deviceForm" :rules="deviceRules" label-width="92px">
<el-form-item label="设备名称" prop="name"><el-input v-model="deviceForm.name" /></el-form-item>
<el-form-item label="安装位置" prop="location"><el-input v-model="deviceForm.location" /></el-form-item>
<el-form-item label="设备类型" prop="modality"><el-select v-model="deviceForm.modality" :disabled="Boolean(editing)" style="width: 100%"><el-option label="视频设备" value="video" /><el-option label="雷达(适配器未就绪)" value="radar" /><el-option label="按钮(适配器未就绪)" value="button" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="deviceDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveDevice">保存</el-button></template>
</el-dialog>
<el-dialog v-model="credentialDialog" title="更新设备凭据" width="520px" destroy-on-close>
<el-alert title="凭据保存后不能查看,只能再次更新。请勿把密码写入设备地址。" type="warning" show-icon :closable="false" />
<el-form ref="credentialFormRef" :model="credentialForm" :rules="credentialRules" label-width="82px" class="credential-form">
<el-form-item label="用户名" prop="username"><el-input v-model="credentialForm.username" autocomplete="off" /></el-form-item>
<el-form-item label="密码" prop="password"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" /></el-form-item>
</el-form>
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">安全保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useStore } from 'vuex'
import { createDevice, disableDevice, listDevices, updateCredential, updateDevice } from '../../../api/sense/device'
const store = useStore()
const loading = ref(false), saving = ref(false), deviceDialog = ref(false), credentialDialog = ref(false)
const editing = ref(null), credentialTarget = ref(null), deviceFormRef = ref(), credentialFormRef = ref()
const query = reactive({ keyword: '', page: 1, page_size: 20 })
const page = reactive({ items: [], total: 0 })
const deviceForm = reactive({ name: '', location: '', modality: 'video', capabilities: ['video'] })
const credentialForm = reactive({ username: '', password: '' })
const deviceRules = { name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], modality: [{ required: true, message: '请选择类型', trigger: 'change' }] }
const credentialRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }] }
const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('device.write'))
function statusLabel(value) { return ({ pending: '待接入', active: '正常', offline: '离线', disabled: '已停用' })[value] || value }
async function load() { loading.value = true; try { Object.assign(page, await listDevices(query)) } finally { loading.value = false } }
function reset() { Object.assign(query, { keyword: '', page: 1, page_size: 20 }); load() }
function openCreate() { editing.value = null; Object.assign(deviceForm, { name: '', location: '', modality: 'video', capabilities: ['video'] }); deviceDialog.value = true }
function openEdit(row) { editing.value = row; Object.assign(deviceForm, { name: row.name, location: row.location, modality: row.modality, capabilities: row.capabilities }); deviceDialog.value = true }
function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { username: '', password: '' }); credentialDialog.value = true }
async function saveDevice() { const valid = await deviceFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { if (editing.value) await updateDevice(editing.value.id, { ...deviceForm, version: editing.value.version }); else await createDevice(deviceForm); ElMessage.success('设备已保存'); deviceDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false } }
async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { username: '', password: '' }); ElMessage.success('凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } }
async function disable(row) { await ElMessageBox.confirm(`停用“${row.name}”后将停止后续接入,设备记录和审计仍保留。`, '确认停用', { type: 'warning' }); await disableDevice(row.id, row.version); ElMessage.success('设备已停用'); await load() }
onMounted(load)
</script>
<style scoped>
.credential-form { margin-top: 20px; }
</style>