feat: 实现 Sense 批量开通与失败重试 (#72)
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request'
|
||||
import axios from 'axios'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export function listProvisioningBatches(query) {
|
||||
return request({ url: '/api/v1/provisioning/batches', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getProvisioningBatch(id) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function createProvisioningBatch(data) {
|
||||
return request({ url: '/api/v1/provisioning/batches', method: 'post', data })
|
||||
}
|
||||
|
||||
export function executeProvisioningBatch(id, data) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${id}/execute`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function retryProvisioningFailures(id, data) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${id}/retry-failed`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function retryProvisioningItem(batchId, itemId, data) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${batchId}/items/${itemId}/retry`, method: 'post', data })
|
||||
}
|
||||
|
||||
export async function downloadProvisioningBatch(id) {
|
||||
const baseURL = String(process.env.VUE_APP_BASE_API || '').replace(/\/$/, '')
|
||||
const response = await axios.get(`${baseURL}/api/v1/provisioning/batches/${id}/export`, {
|
||||
responseType: 'blob',
|
||||
headers: { Authorization: `Bearer ${getToken()}` }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>摄像头批量开通</h3>
|
||||
<p>按模板导入、检查配额、临时填写凭据并执行开通;密码不会保存在批次记录中。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadBatches">刷新记录</el-button>
|
||||
</div>
|
||||
|
||||
<el-steps :active="activeStep" finish-status="success" simple class="workflow-steps">
|
||||
<el-step title="导入清单" />
|
||||
<el-step title="预校验" />
|
||||
<el-step title="填写凭据" />
|
||||
<el-step title="查看结果" />
|
||||
</el-steps>
|
||||
|
||||
<el-alert title="默认交付配额为 16 路,实际可开通数量以服务端预校验结果为准。模板不得包含账号或密码列。" type="info" :closable="false" show-icon />
|
||||
|
||||
<div class="toolbar">
|
||||
<el-upload v-permisaction="['sense:provisioning:import']" :auto-upload="false" :show-file-list="false" accept=".xlsx,.xls,.csv" :on-change="handleFile">
|
||||
<el-button type="primary" :icon="Upload">选择清单</el-button>
|
||||
</el-upload>
|
||||
<el-button :icon="Download" @click="downloadTemplate">下载模板</el-button>
|
||||
<span class="file-name">{{ fileName || '尚未选择文件' }}</span>
|
||||
</div>
|
||||
|
||||
<el-table v-if="importRows.length" :data="importRows" border stripe max-height="360">
|
||||
<el-table-column prop="lineNumber" label="行号" width="80" />
|
||||
<el-table-column prop="name" label="设备名称" min-width="150" />
|
||||
<el-table-column prop="location" label="安装位置" min-width="160" />
|
||||
<el-table-column prop="address" label="ONVIF 地址" min-width="250" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div v-if="importRows.length" class="import-actions">
|
||||
<el-button v-permisaction="['sense:provisioning:import']" type="primary" :loading="importing" @click="submitImport">提交预校验</el-button>
|
||||
<span>共 {{ importRows.length }} 条;提交后服务端会检查格式、重复地址和剩余配额。</span>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
<div class="section-title">
|
||||
<h4>开通批次</h4>
|
||||
<el-select v-model="statusFilter" placeholder="全部状态" clearable @change="loadBatches">
|
||||
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="batches" border stripe>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" min-width="190">
|
||||
<template #default="scope">总数 {{ scope.row.totalCount }} / 可执行 {{ scope.row.readyCount }} / 成功 {{ scope.row.successCount }} / 失败 {{ scope.row.failureCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配额" width="140">
|
||||
<template #default="scope">已用 {{ scope.row.existingCount }} / {{ scope.row.quotaLimit }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link @click="openBatch(scope.row.id)">查看</el-button>
|
||||
<el-button v-if="scope.row.readyCount" v-permisaction="['sense:provisioning:execute']" type="primary" link @click="openCredentials(scope.row.id, false)">填写凭据</el-button>
|
||||
<el-button v-if="scope.row.failureCount" v-permisaction="['sense:provisioning:retry']" type="warning" link @click="openCredentials(scope.row.id, true)">重试失败</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="loadBatches" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="批次详情" width="min(980px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<div v-if="selectedBatch" class="summary-cards" role="status" aria-live="polite">
|
||||
<el-tag>{{ statusLabel(selectedBatch.status) }}</el-tag>
|
||||
<span>配额 {{ selectedBatch.existingCount }} / {{ selectedBatch.quotaLimit }}</span>
|
||||
<span>成功 {{ selectedBatch.successCount }}</span>
|
||||
<span>失败 {{ selectedBatch.failureCount }}</span>
|
||||
</div>
|
||||
<el-table v-if="selectedBatch" :data="selectedBatch.items" border stripe max-height="470">
|
||||
<el-table-column prop="lineNumber" label="行号" width="70" />
|
||||
<el-table-column prop="name" label="设备名称" min-width="130" />
|
||||
<el-table-column prop="location" label="安装位置" min-width="130" />
|
||||
<el-table-column prop="address" label="地址" min-width="210" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="110"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="detail" label="说明" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="scope"><el-button v-if="scope.row.status === 'failed'" v-permisaction="['sense:provisioning:retry']" type="warning" link @click="openSingleRetry(scope.row)">重试</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button v-permisaction="['sense:provisioning:export']" :icon="Download" @click="exportSelected">导出结果</el-button>
|
||||
<el-button @click="detailOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="credentialOpen" :title="singleItemId ? '重试单个失败项' : retryMode ? '重试失败项' : '填写临时凭据并开通'" width="min(1180px, calc(100vw - 24px))" :close-on-click-modal="false" @closed="clearCredentials">
|
||||
<el-alert title="凭据仅用于本次请求,提交成功或关闭窗口后立即从页面内存清除。" type="warning" :closable="false" show-icon />
|
||||
<el-table :data="credentialItems" border stripe max-height="500" class="credential-table">
|
||||
<el-table-column prop="name" label="设备" min-width="130" />
|
||||
<el-table-column prop="address" label="地址" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column label="ONVIF 用户名" min-width="145"><template #default="scope"><el-input v-model="credentials[scope.row.id].onvifUsername" autocomplete="off" /></template></el-table-column>
|
||||
<el-table-column label="ONVIF 密码" min-width="160"><template #default="scope"><el-input v-model="credentials[scope.row.id].onvifPassword" type="password" show-password autocomplete="new-password" /></template></el-table-column>
|
||||
<el-table-column label="RTSP" width="120"><template #default="scope"><el-checkbox v-model="credentials[scope.row.id].rtspSameAsOnvif">同上</el-checkbox></template></el-table-column>
|
||||
<el-table-column label="RTSP 用户名" min-width="145"><template #default="scope"><el-input v-model="credentials[scope.row.id].rtspUsername" :disabled="credentials[scope.row.id].rtspSameAsOnvif" autocomplete="off" /></template></el-table-column>
|
||||
<el-table-column label="RTSP 密码" min-width="160"><template #default="scope"><el-input v-model="credentials[scope.row.id].rtspPassword" :disabled="credentials[scope.row.id].rtspSameAsOnvif" type="password" show-password autocomplete="new-password" /></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button type="primary" :loading="executing" @click="executeBatch">确认执行</el-button>
|
||||
<el-button @click="credentialOpen = false">取消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Download, Refresh, Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { createProvisioningBatch, downloadProvisioningBatch, executeProvisioningBatch, getProvisioningBatch, listProvisioningBatches, retryProvisioningFailures, retryProvisioningItem } from '@/api/sense/provisioning'
|
||||
import { clearCredentialMap, createBatchPayload, credentialPayload, normalizeImportRows } from './provisioningPayload'
|
||||
|
||||
defineOptions({ name: 'SenseProvisioning' })
|
||||
|
||||
const loading = ref(false); const importing = ref(false); const executing = ref(false)
|
||||
const batches = ref([]); const total = ref(0); const statusFilter = ref(''); const fileName = ref(''); const importRows = ref([])
|
||||
const selectedBatch = ref(null); const detailOpen = ref(false); const credentialOpen = ref(false); const retryMode = ref(false)
|
||||
const singleItemId = ref('')
|
||||
const credentials = reactive({}); const query = reactive({ pageIndex: 1, pageSize: 10 })
|
||||
const statusOptions = [{ value: 'ready', label: '待执行' }, { value: 'partial', label: '部分成功' }, { value: 'succeeded', label: '已完成' }, { value: 'failed', label: '失败' }]
|
||||
const activeStep = computed(() => credentialOpen.value ? 2 : selectedBatch.value?.status === 'succeeded' || selectedBatch.value?.status === 'partial' ? 3 : importRows.value.length ? 1 : 0)
|
||||
const credentialItems = computed(() => (selectedBatch.value?.items || []).filter(item => singleItemId.value ? item.id === singleItemId.value : retryMode.value ? item.status === 'failed' : item.status === 'ready'))
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function statusLabel(status) { return ({ ready: '待执行', running: '执行中', succeeded: '已完成', partial: '部分成功', failed: '失败', invalid: '格式错误', quota_exceeded: '超出配额' })[status] || status }
|
||||
function statusType(status) { return ({ succeeded: 'success', partial: 'warning', failed: 'danger', invalid: 'danger', quota_exceeded: 'info', ready: 'primary' })[status] || 'info' }
|
||||
function saveText(content, name, type = 'text/csv;charset=utf-8') { const url = URL.createObjectURL(new Blob([content], { type })); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url) }
|
||||
function saveBlob(content, name) { const url = URL.createObjectURL(content); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url) }
|
||||
|
||||
async function loadBatches() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listProvisioningBatches({ ...query, status: statusFilter.value }))
|
||||
batches.value = payload?.list || payload?.data || []
|
||||
total.value = payload?.count || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function handleFile(file) {
|
||||
try {
|
||||
const workbook = XLSX.read(await file.raw.arrayBuffer(), { type: 'array' })
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' })
|
||||
importRows.value = normalizeImportRows(rows)
|
||||
fileName.value = file.name
|
||||
if (!importRows.value.length) ElMessage.warning('清单中没有可导入的数据')
|
||||
} catch (error) { ElMessage.error(error.message || '无法读取清单文件') }
|
||||
}
|
||||
async function submitImport() {
|
||||
if (!importRows.value.length) return ElMessage.warning('请先选择清单')
|
||||
importing.value = true
|
||||
try {
|
||||
const key = `sense-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
selectedBatch.value = unwrap(await createProvisioningBatch(createBatchPayload(importRows.value, key)))
|
||||
detailOpen.value = true
|
||||
ElMessage.success('预校验完成')
|
||||
await loadBatches()
|
||||
} catch (error) { ElMessage.error(error.message || '清单预校验失败') } finally { importing.value = false }
|
||||
}
|
||||
async function openBatch(id) { selectedBatch.value = unwrap(await getProvisioningBatch(id)); detailOpen.value = true }
|
||||
async function openCredentials(id, retry) {
|
||||
selectedBatch.value = unwrap(await getProvisioningBatch(id)); retryMode.value = retry; singleItemId.value = ''
|
||||
credentialItems.value.forEach(item => { credentials[item.id] = { onvifUsername: '', onvifPassword: '', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' } })
|
||||
credentialOpen.value = true
|
||||
}
|
||||
function openSingleRetry(item) { retryMode.value = true; singleItemId.value = item.id; credentials[item.id] = { onvifUsername: '', onvifPassword: '', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' }; detailOpen.value = false; credentialOpen.value = true }
|
||||
function clearCredentials() { clearCredentialMap(credentials); Object.keys(credentials).forEach(key => delete credentials[key]); singleItemId.value = '' }
|
||||
async function executeBatch() {
|
||||
if (credentialItems.value.some(item => !credentials[item.id]?.onvifUsername || !credentials[item.id]?.onvifPassword)) return ElMessage.warning('请填写所有待处理设备的 ONVIF 用户名和密码')
|
||||
if (credentialItems.value.some(item => credentials[item.id]?.rtspSameAsOnvif === false && (!credentials[item.id]?.rtspUsername || !credentials[item.id]?.rtspPassword))) return ElMessage.warning('请填写所有独立 RTSP 凭据')
|
||||
executing.value = true
|
||||
try {
|
||||
const payload = credentialPayload(credentialItems.value, credentials)
|
||||
const action = singleItemId.value
|
||||
? retryProvisioningItem(selectedBatch.value.id, singleItemId.value, payload)
|
||||
: retryMode.value
|
||||
? retryProvisioningFailures(selectedBatch.value.id, payload)
|
||||
: executeProvisioningBatch(selectedBatch.value.id, payload)
|
||||
selectedBatch.value = unwrap(await action)
|
||||
clearCredentials(); credentialOpen.value = false; detailOpen.value = true
|
||||
ElMessage.success('批量开通处理完成')
|
||||
await loadBatches()
|
||||
} catch (error) { clearCredentials(); ElMessage.error(error.message || '批量开通失败') } finally { executing.value = false }
|
||||
}
|
||||
async function exportSelected() { if (!selectedBatch.value) return; try { saveBlob(await downloadProvisioningBatch(selectedBatch.value.id), `sense-provisioning-${selectedBatch.value.id}.csv`) } catch (error) { ElMessage.error(error.message || '结果导出失败') } }
|
||||
function downloadTemplate() { saveText('\uFEFFline_number,name,location,address\r\n1,东门摄像机,教学楼一楼东门,http://192.0.2.10/onvif', 'sense-provisioning-template.csv') }
|
||||
onMounted(loadBatches)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,.section-title,.toolbar,.import-actions,.summary-cards{display:flex;align-items:center;gap:12px}.page-header,.section-title{justify-content:space-between}.page-header h3,.section-title h4{margin:0 0 6px}.page-header p{margin:0;color:#909399}.workflow-steps{margin:18px 0}.toolbar{margin:18px 0}.file-name,.import-actions span{color:#606266;font-size:13px}.import-actions{justify-content:flex-end;margin-top:16px}.section-title{margin-bottom:12px}.section-title .el-select{width:160px}.summary-cards{margin-bottom:14px}.credential-table{margin-top:16px}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
const aliases = {
|
||||
lineNumber: ['lineNumber', 'line_number', '序号', '行号'],
|
||||
name: ['name', '设备名称', '摄像头名称'],
|
||||
location: ['location', '安装位置', '位置'],
|
||||
address: ['address', '设备地址', 'ONVIF地址', 'onvif_address']
|
||||
}
|
||||
|
||||
function readAlias(row, names) {
|
||||
const key = names.find(name => Object.prototype.hasOwnProperty.call(row, name))
|
||||
return key ? row[key] : ''
|
||||
}
|
||||
|
||||
export function normalizeImportRows(rows) {
|
||||
return rows.map((row, index) => ({
|
||||
lineNumber: Number(readAlias(row, aliases.lineNumber)) || index + 1,
|
||||
name: String(readAlias(row, aliases.name) || '').trim(),
|
||||
location: String(readAlias(row, aliases.location) || '').trim(),
|
||||
address: String(readAlias(row, aliases.address) || '').trim()
|
||||
}))
|
||||
}
|
||||
|
||||
export function createBatchPayload(rows, idempotencyKey) {
|
||||
return {
|
||||
idempotencyKey: String(idempotencyKey || '').trim(),
|
||||
rows: normalizeImportRows(rows).map(row => ({
|
||||
lineNumber: row.lineNumber,
|
||||
name: row.name,
|
||||
location: row.location,
|
||||
address: row.address
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialPayload(items, credentialByItem) {
|
||||
return {
|
||||
credentials: items.map(item => {
|
||||
const value = credentialByItem[item.id] || {}
|
||||
const same = value.rtspSameAsOnvif !== false
|
||||
return {
|
||||
itemId: item.id,
|
||||
onvifUsername: String(value.onvifUsername || ''),
|
||||
onvifPassword: String(value.onvifPassword || ''),
|
||||
rtspSameAsOnvif: same,
|
||||
rtspUsername: same ? '' : String(value.rtspUsername || ''),
|
||||
rtspPassword: same ? '' : String(value.rtspPassword || '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCredentialMap(credentialByItem) {
|
||||
Object.keys(credentialByItem).forEach(key => {
|
||||
credentialByItem[key].onvifUsername = ''
|
||||
credentialByItem[key].onvifPassword = ''
|
||||
credentialByItem[key].rtspUsername = ''
|
||||
credentialByItem[key].rtspPassword = ''
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user