201 lines
15 KiB
Vue
201 lines
15 KiB
Vue
<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>
|