Files
yovision/Sense/ui/src/views/sense/provisioning/index.vue
T

201 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>