Files
one-pipe-system/src/views/asset-management/device-list/index.vue
luo 8faa2ea2ef
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 8m8s
feat: 预计最终到期时间展示
2026-07-22 16:31:01 +08:00

3226 lines
104 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>
<ArtTableFullScreen>
<div class="device-list-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
type="primary"
@click="handleBatchAllocate"
:disabled="!selectedDevices.length"
v-permission="'device:batch_allocate'"
>
批量分配
</ElButton>
<ElButton
@click="handleBatchRecall"
:disabled="!selectedDevices.length"
v-permission="'device:batch_recall'"
>
批量回收
</ElButton>
<ElButton
type="info"
:disabled="!selectedDevices.length"
@click="handleBatchSetSeries"
v-permission="'device:batch_set_series'"
>
批量设置套餐系列
</ElButton>
<ElButton
type="primary"
:disabled="!selectedDevices.length"
@click="showBatchRealnamePolicyDialog"
v-permission="'device:realname_policy'"
>
批量修改实名顺序
</ElButton>
<ElButton
type="primary"
:disabled="!selectedDevices.length"
@click="showEnterpriseDeviceAuthorizeDialog"
v-permission="'devices:authorize_enterprise'"
>
授权设备给企业
</ElButton>
<ElButton
type="warning"
:disabled="!selectedDevices.length"
@click="showEnterpriseDeviceRecallDialog"
v-permission="'devices:recall_enterprise_authorization'"
>
撤销设备授权
</ElButton>
<ElButton type="primary" @click="showExportDialog" v-permission="'devices:export'">
导出
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="deviceList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 批量分配对话框 -->
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="600px">
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
>
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="店铺" prop="target_shop_id">
<ElCascader
v-model="allocateForm.target_shop_id"
:options="shopCascadeOptions"
:props="shopCascadeProps"
placeholder="请选择目标店铺"
clearable
filterable
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="备注">
<ElInput
v-model="allocateForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息(选填)"
/>
</ElFormItem>
</ElForm>
<!-- 分配结果 -->
<div v-if="allocateResult" style="margin-top: 20px">
<ElAlert
:type="allocateResult.fail_count === 0 ? 'success' : 'warning'"
:closable="false"
style="margin-bottom: 10px"
>
<template #title>
成功分配 {{ allocateResult.success_count }} 失败
{{ allocateResult.fail_count }}
</template>
</ElAlert>
<div v-if="allocateResult.failed_items && allocateResult.failed_items.length > 0">
<div style="margin-bottom: 10px; font-weight: bold">失败详情</div>
<div
v-for="item in allocateResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.virtual_no }} - {{ item.reason }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleCloseAllocateDialog">
{{ allocateResult ? '关闭' : '取消' }}
</ElButton>
<ElButton
v-if="!allocateResult"
type="primary"
@click="handleConfirmAllocate"
:loading="allocateLoading"
>
确认分配
</ElButton>
</div>
</template>
</ElDialog>
<!-- 批量回收对话框 -->
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px">
<ElForm ref="recallFormRef" :model="recallForm" label-width="120px">
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #e6a23c">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="备注">
<ElInput
v-model="recallForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息(选填)"
/>
</ElFormItem>
</ElForm>
<!-- 回收结果 -->
<div v-if="recallResult" style="margin-top: 20px">
<ElAlert
:type="recallResult.fail_count === 0 ? 'success' : 'warning'"
:closable="false"
style="margin-bottom: 10px"
>
<template #title>
成功回收 {{ recallResult.success_count }} 失败 {{ recallResult.fail_count }}
</template>
</ElAlert>
<div v-if="recallResult.failed_items && recallResult.failed_items.length > 0">
<div style="margin-bottom: 10px; font-weight: bold">失败详情</div>
<div
v-for="item in recallResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.virtual_no }} - {{ item.reason }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleCloseRecallDialog">
{{ recallResult ? '关闭' : '取消' }}
</ElButton>
<ElButton
v-if="!recallResult"
type="primary"
@click="handleConfirmRecall"
:loading="recallLoading"
>
确认回收
</ElButton>
</div>
</template>
</ElDialog>
<!-- 批量设置套餐系列绑定对话框 -->
<ElDialog
v-model="seriesBindingDialogVisible"
title="批量设置设备套餐系列绑定"
width="35%"
@close="handleCloseSeriesBindingDialog"
>
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
>
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="seriesBindingForm.selection_type">
<ElRadio label="list">设备ID列表</ElRadio>
<ElRadio label="range">设备号范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.LIST"
label="设备列表"
>
<div>已选择 {{ selectedDevices.length }} 台设备</div>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.RANGE"
label="起始设备号"
prop="virtual_no_start"
>
<ElInput
v-model="seriesBindingForm.virtual_no_start"
placeholder="请输入起始设备号"
/>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.RANGE"
label="结束设备号"
prop="virtual_no_end"
>
<ElInput v-model="seriesBindingForm.virtual_no_end" placeholder="请输入结束设备号" />
</ElFormItem>
<div
v-if="
seriesBindingForm.selection_type === DeviceSelectionType.RANGE &&
seriesBindingRangeCountText
"
class="range-allocation-hint range-allocation-hint--device"
>
共分配{{ seriesBindingRangeCountText }}台设备
</div>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="设备号"
>
<ElInput
v-model="seriesBindingForm.virtual_no"
placeholder="请输入设备号"
clearable
/>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="设备名称"
>
<ElInput
v-model="seriesBindingForm.device_name"
placeholder="请输入设备名称"
clearable
/>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="设备类型"
>
<ElInput
v-model="seriesBindingForm.device_type"
placeholder="请输入设备类型"
clearable
/>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="制造商"
>
<ElInput
v-model="seriesBindingForm.manufacturer"
placeholder="请输入制造商"
clearable
/>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="状态"
>
<ElSelect
v-model="seriesBindingForm.status"
placeholder="请选择状态"
clearable
style="width: 100%"
>
<ElOption label="在库" :value="1" />
<ElOption label="已分销" :value="2" />
<ElOption label="已激活" :value="3" />
<ElOption label="已停用" :value="4" />
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="批次号"
>
<ElSelect
v-model="seriesBindingForm.batch_no"
placeholder="请选择或搜索批次号"
clearable
filterable
remote
:remote-method="handleSearchBatchNo"
style="width: 100%"
>
<ElOption
v-for="item in searchBatchNoOptions"
:key="item.id"
:label="item.batch_no"
:value="item.batch_no"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="店铺名称"
>
<ElCascader
v-model="seriesBindingForm.shop_id_path"
:options="seriesBindingShopCascadeOptions"
:props="seriesBindingShopCascadeProps"
placeholder="请选择店铺名称"
clearable
filterable
style="width: 100%"
/>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="当前套餐系列"
>
<ElSelect
v-model="seriesBindingForm.filter_series_id"
placeholder="请选择或搜索当前套餐系列"
clearable
filterable
remote
:remote-method="searchSeries"
style="width: 100%"
>
<ElOption
v-for="series in searchSeriesOptions"
:key="series.id"
:label="series.series_name"
:value="series.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="seriesBindingForm.selection_type === DeviceSelectionType.FILTER"
label="创建设备时间"
>
<ElDatePicker
v-model="seriesBindingForm.created_at_range"
type="daterange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="目标套餐系列" prop="series_id">
<ElSelect
v-model="seriesBindingForm.series_id"
placeholder="请选择或搜索套餐系列"
style="width: 100%"
filterable
remote
reserve-keyword
:remote-method="searchPackageSeries"
:loading="seriesLoading"
clearable
>
<ElOption
v-for="series in packageSeriesList"
:key="series.id"
:label="series.series_name"
:value="series.id"
:disabled="series.status !== 1"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<!-- 设置结果 -->
<div v-if="seriesBindingResult" style="margin-top: 20px">
<ElAlert
:type="seriesBindingResult.fail_count === 0 ? 'success' : 'warning'"
:closable="false"
style="margin-bottom: 10px"
>
<template #title>
成功设置 {{ seriesBindingResult.success_count }} 台,失败
{{ seriesBindingResult.fail_count }} 台
</template>
</ElAlert>
<div
v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0"
>
<div style="margin-bottom: 10px; font-weight: bold">失败详情:</div>
<div
v-for="item in seriesBindingResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.virtual_no }} - {{ item.reason }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleCloseSeriesBindingDialog">
{{ seriesBindingResult ? '关闭' : '取消' }}
</ElButton>
<ElButton
v-if="!seriesBindingResult"
type="primary"
@click="handleConfirmSeriesBinding"
:loading="seriesBindingLoading"
>
确认设置
</ElButton>
</div>
</template>
</ElDialog>
<!-- 设备详情弹窗 -->
<ElDialog v-model="deviceDetailDialogVisible" title="设备详情" width="1000px">
<div v-if="deviceDetailLoading" style="padding: 40px 0; text-align: center">
<ElIcon class="is-loading" :size="40">
<Loading />
</ElIcon>
</div>
<div v-else-if="currentDeviceDetail">
<!-- 设备基本信息 -->
<ElDescriptions :column="3" border style="margin-bottom: 20px">
<ElDescriptionsItem label="设备ID">{{ currentDeviceDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{
currentDeviceDetail.virtual_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{
currentDeviceDetail.device_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{
currentDeviceDetail.device_model || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{
currentDeviceDetail.device_type || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{
currentDeviceDetail.manufacturer || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{
currentDeviceDetail.max_sim_slots
}}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{
currentDeviceDetail.bound_card_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getDeviceStatusTagType(currentDeviceDetail.status)">
{{ currentDeviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号" :span="2">{{
currentDeviceDetail.batch_no || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间" :span="3">{{
formatDateTime(currentDeviceDetail.created_at) || '-'
}}</ElDescriptionsItem>
</ElDescriptions>
</div>
</ElDialog>
<!-- 绑定卡片列表弹窗 -->
<ElDialog v-model="deviceCardsDialogVisible" title="设备绑定的卡" width="60%">
<!-- 绑定新卡表单 -->
<ElCard shadow="never" class="bind-card-section" style="margin-bottom: 20px">
<template #header>
<div style="font-weight: bold">绑定新卡</div>
</template>
<ElForm
ref="bindCardFormRef"
:model="bindCardForm"
:rules="bindCardRules"
label-width="80"
>
<ElRow :gutter="20">
<ElCol :span="6">
<ElFormItem label="搜索类型" prop="search_type">
<ElSelect
v-model="bindCardForm.search_type"
placeholder="请选择搜索类型"
style="width: 100%"
>
<ElOption label="ICCID" value="iccid" />
<ElOption label="运营商名称" value="carrier_name" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="9">
<ElFormItem label="ICCID" prop="iot_card_id">
<ElSelect
v-model="bindCardForm.iot_card_id"
:placeholder="
bindCardForm.search_type === 'carrier_name'
? '请选择或搜索运营商名称'
: '请选择或搜索ICCID'
"
filterable
remote
reserve-keyword
:remote-method="searchIotCards"
:loading="iotCardSearchLoading"
clearable
style="width: 100%"
>
<ElOption
v-for="card in iotCardList"
:key="card.id"
:label="`${card.iccid} ${card.carrier_name ? '(' + card.carrier_name + ')' : ''}`"
:value="card.id"
/>
<ElOption
v-if="iotCardList.length === 0 && !iotCardSearchLoading"
label="未找到相关数据"
value=""
disabled
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="6">
<ElFormItem label="插槽位置" prop="slot_position">
<ElSelect
v-model="bindCardForm.slot_position"
placeholder="请选择插槽位置"
style="width: 100%"
>
<ElOption
v-for="i in currentDeviceDetail?.max_sim_slots || 4"
:key="i"
:label="`插槽 ${i}`"
:value="i"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="3">
<ElButton
v-permission="'device:bind_card'"
type="primary"
@click="handleConfirmBindCard"
:loading="bindCardLoading"
style="width: 100%"
>
确认绑定
</ElButton>
</ElCol>
</ElRow>
</ElForm>
</ElCard>
<!-- 已绑定卡片列表 -->
<div v-if="deviceCardsLoading" style="padding: 40px 0; text-align: center">
<ElIcon class="is-loading" :size="40">
<Loading />
</ElIcon>
</div>
<div v-else>
<ElTable :data="deviceCards" border style="width: 100%">
<ElTableColumn prop="slot_position" label="插槽位置" width="120">
<template #default="{ row }">
<div style="display: flex; gap: 10px; align-items: center">
<span>SIM-{{ row.slot_position }}</span>
<ElTag v-if="row.is_current" type="success" size="small">当前</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="iccid" label="ICCID" min-width="120" />
<ElTableColumn prop="msisdn" label="接入号" width="160" />
<ElTableColumn prop="network_status" label="网络状态" width="100">
<template #default="{ row }">
<ElTag :type="getNetworkStatusTagType(row.network_status)">
{{ getNetworkStatusText(row.network_status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="bind_time" label="绑定时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.bind_time) || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<ElButton
v-permission="'device:unbind_card'"
type="danger"
size="small"
link
@click="handleUnbindCard(row)"
>
解绑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div
v-if="deviceCards.length === 0"
style="padding: 20px; color: #909399; text-align: center"
>
暂无设备绑定的卡
</div>
</div>
</ElDialog>
<!-- 切换SIM卡对话框 -->
<SwitchCardDialog
v-model="switchCardDialogVisible"
:device-info="currentOperatingImei"
:cards="deviceBindingCards"
:loading="loadingDeviceCards"
:confirm-loading="switchCardLoading"
@confirm="handleConfirmSwitchCard"
/>
<!-- 设置切卡模式对话框 -->
<SwitchModeDialog
v-model="switchModeDialogVisible"
:device-identifier="currentOperatingDeviceIdentifier"
:current-mode="currentDeviceSwitchMode"
:cards="switchModeCards"
:has-imei="currentOperatingDeviceHasImei"
:confirm-loading="switchModeLoading"
@confirm="handleConfirmSwitchMode"
/>
<!-- 实名认证策略对话框 -->
<RealnamePolicyDialog
v-model="realnamePolicyDialogVisible"
:asset-identifier="currentRealnamePolicyAsset"
:current-policy="currentRealnamePolicy"
@confirm="handleConfirmRealnamePolicy"
/>
<BatchRealnamePolicyDialog
v-model="batchRealnamePolicyDialogVisible"
:selected-count="selectedDevices.length"
asset-unit="台设备"
:loading="batchRealnamePolicyLoading"
:error-message="batchRealnamePolicyError"
device-policy-hint
@confirm="handleConfirmBatchRealnamePolicy"
/>
<!-- 操作审计日志弹窗 -->
<OperationLogsDialog
v-model="operationLogsDialogVisible"
:identifier="operationLogsIdentifier"
download-permission="device:download_log_file"
/>
<ElDialog
v-model="enterpriseDeviceAuthorizeDialogVisible"
title="授权设备给企业"
width="620px"
@close="handleEnterpriseDeviceAuthorizeDialogClose"
>
<ElForm
ref="enterpriseDeviceAuthorizeFormRef"
:model="enterpriseDeviceAuthorizeForm"
:rules="enterpriseDeviceAuthorizeRules"
label-width="120px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
v-model="enterpriseDeviceAuthorizeForm.enterprise_id"
placeholder="请选择或搜索企业"
clearable
filterable
remote
reserve-keyword
:remote-method="handleSearchEnterprise"
:loading="enterpriseLoading"
style="width: 100%"
>
<ElOption
v-for="item in enterpriseOptions"
:key="item.id"
:label="item.enterprise_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceAuthorizeForm.selection_type">
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="enterpriseDeviceAuthorizeForm.selection_type === DeviceSelectionType.LIST"
label="设备号列表"
>
<div>已选择 {{ selectedDevices.length }} 台设备</div>
</ElFormItem>
<template
v-if="enterpriseDeviceAuthorizeForm.selection_type === DeviceSelectionType.FILTER"
>
<ElFormItem label="设备号">
<ElInput
v-model="enterpriseDeviceAuthorizeForm.virtual_no"
placeholder="请输入设备号"
clearable
/>
</ElFormItem>
<ElFormItem label="批次号">
<ElSelect
v-model="enterpriseDeviceAuthorizeForm.batch_no"
placeholder="请选择或搜索批次号"
clearable
filterable
remote
:remote-method="handleSearchBatchNo"
style="width: 100%"
>
<ElOption
v-for="item in searchBatchNoOptions"
:key="item.id"
:label="item.batch_no"
:value="item.batch_no"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="店铺名称">
<ElCascader
v-model="enterpriseDeviceAuthorizeForm.shop_id_path"
:options="shopCascadeOptions"
:props="shopCascadeProps"
placeholder="请选择店铺名称"
clearable
filterable
style="width: 100%"
/>
</ElFormItem>
</template>
<ElFormItem label="备注">
<ElInput
v-model="enterpriseDeviceAuthorizeForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="enterpriseDeviceAuthorizeDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleEnterpriseDeviceAuthorize"
:loading="enterpriseDeviceAuthorizeLoading"
>
确认授权
</ElButton>
</div>
</template>
</ElDialog>
<ElDialog
v-model="enterpriseDeviceRecallDialogVisible"
title="撤销设备授权"
width="620px"
@close="handleEnterpriseDeviceRecallDialogClose"
>
<ElForm
ref="enterpriseDeviceRecallFormRef"
:model="enterpriseDeviceRecallForm"
:rules="enterpriseDeviceRecallRules"
label-width="120px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
v-model="enterpriseDeviceRecallForm.enterprise_id"
placeholder="请选择或搜索企业"
clearable
filterable
remote
reserve-keyword
:remote-method="handleSearchEnterprise"
:loading="enterpriseLoading"
style="width: 100%"
>
<ElOption
v-for="item in enterpriseOptions"
:key="item.id"
:label="item.enterprise_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceRecallForm.selection_type">
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="enterpriseDeviceRecallForm.selection_type === DeviceSelectionType.LIST"
label="设备号列表"
>
<div>已选择 {{ selectedDevices.length }} 台设备</div>
</ElFormItem>
<template
v-if="enterpriseDeviceRecallForm.selection_type === DeviceSelectionType.FILTER"
>
<ElFormItem label="设备号">
<ElInput
v-model="enterpriseDeviceRecallForm.virtual_no"
placeholder="请输入设备号"
clearable
/>
</ElFormItem>
<ElFormItem label="批次号">
<ElSelect
v-model="enterpriseDeviceRecallForm.batch_no"
placeholder="请选择或搜索批次号"
clearable
filterable
remote
:remote-method="handleSearchBatchNo"
style="width: 100%"
>
<ElOption
v-for="item in searchBatchNoOptions"
:key="item.id"
:label="item.batch_no"
:value="item.batch_no"
/>
</ElSelect>
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="enterpriseDeviceRecallDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleEnterpriseDeviceRecall"
:loading="enterpriseDeviceRecallLoading"
>
确认撤销
</ElButton>
</div>
</template>
</ElDialog>
<ElDialog
v-model="enterpriseDeviceResultDialogVisible"
:title="enterpriseDeviceResultTitle"
width="700px"
>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ enterpriseDeviceOperationResult.success_count }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<ElTag type="danger">{{ enterpriseDeviceOperationResult.fail_count }}</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<div
v-if="
enterpriseDeviceOperationResult.failed_items &&
enterpriseDeviceOperationResult.failed_items.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="enterpriseDeviceOperationResult.failed_items" border max-height="300">
<ElTableColumn prop="virtual_no" label="设备号" width="180" />
<ElTableColumn prop="reason" label="失败原因" />
</ElTable>
</div>
<div
v-if="
enterpriseDeviceOperationResult.authorized_devices &&
enterpriseDeviceOperationResult.authorized_devices.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">已授权设备</ElDivider>
<ElTable
:data="enterpriseDeviceOperationResult.authorized_devices"
border
max-height="220"
>
<ElTableColumn prop="virtual_no" label="设备号" width="180" />
<ElTableColumn prop="card_count" label="绑定卡数" />
</ElTable>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="enterpriseDeviceResultDialogVisible = false"
>确定</ElButton
>
</div>
</template>
</ElDialog>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="device"
:query="exportQuery"
confirm-permission="devices:export"
title="导出设备"
/>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import {
DeviceService,
ShopService,
CardService,
PackageSeriesService,
AssetService,
EnterpriseService
} from '@/api/modules'
import { DeviceSelectionType } from '@/types/api'
import { ElMessage, ElMessageBox, ElTag, ElIcon, ElRadioGroup, ElRadio } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
import SwitchModeDialog from '@/views/asset-management/asset-information/components/dialogs/SwitchModeDialog.vue'
import type {
Device,
DeviceStatus,
AllocateDevicesResponse,
RecallDevicesResponse,
BatchSetDeviceSeriesBindingRequest,
BatchSetDeviceSeriesBindingResponse
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
import type { PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type {
EnterpriseAllocateDevicesResponse,
EnterpriseRecallDevicesResponse
} from '@/types/api/enterpriseDevice'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import BatchRealnamePolicyDialog from '@/components/business/BatchRealnamePolicyDialog.vue'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import type { AssetRealnamePolicy } from '@/types/api'
defineOptions({ name: 'DeviceList' })
interface SwitchModeCardOption {
iot_card_id: number
iccid: string
slot_position?: number
carrier_name?: string
msisdn?: string
network_status?: number
real_name_status?: number
is_current?: boolean
}
const { hasAuth } = useAuth()
const router = useRouter()
const loading = ref(false)
const allocateLoading = ref(false)
const recallLoading = ref(false)
const tableRef = ref()
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>()
const allocateDialogVisible = ref(false)
const recallDialogVisible = ref(false)
const selectedDevices = ref<Device[]>([])
const batchRealnamePolicyDialogVisible = ref(false)
const batchRealnamePolicyLoading = ref(false)
const batchRealnamePolicyError = ref('')
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
const shopCascadeOptions = ref<any[]>([])
const shopCascadeProps = {
lazy: true,
checkStrictly: true,
lazyLoad: async (node: any, resolve: any) => {
const { level, value } = node
const parentId = level === 0 ? undefined : value
try {
const res = await ShopService.getShopsCascade({ parent_id: parentId })
if (res.code === 0) {
const nodes = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
resolve(nodes)
} else {
resolve([])
}
} catch (error) {
console.error('加载店铺级联数据失败:', error)
resolve([])
}
},
value: 'value',
label: 'label',
children: 'children'
}
const loadTopLevelShopCascadeOptions = async () => {
try {
const res = await ShopService.getShopsCascade({ parent_id: undefined })
if (res.code === 0) {
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
}
} catch (error) {
console.error('加载顶级店铺失败:', error)
}
}
const seriesBindingShopCascadeOptions = ref<any[]>([])
const seriesBindingShopCascadeProps = {
lazy: true,
checkStrictly: true,
lazyLoad: async (node: any, resolve: any) => {
const { level, value } = node
const parentId = level === 0 ? undefined : value
try {
const res = await ShopService.getShopsCascade({
parent_id: parentId,
exclude_self: true
})
if (res.code === 0) {
const nodes = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
resolve(nodes)
} else {
resolve([])
}
} catch (error) {
console.error('加载批量设置套餐系列店铺级联数据失败:', error)
resolve([])
}
},
value: 'value',
label: 'label',
children: 'children'
}
const allocateResult = ref<AllocateDevicesResponse | null>(null)
const recallResult = ref<RecallDevicesResponse | null>(null)
const enterpriseDeviceAuthorizeDialogVisible = ref(false)
const enterpriseDeviceRecallDialogVisible = ref(false)
const enterpriseDeviceResultDialogVisible = ref(false)
const enterpriseDeviceAuthorizeLoading = ref(false)
const enterpriseDeviceRecallLoading = ref(false)
const enterpriseDeviceResultTitle = ref('')
const enterpriseDeviceAuthorizeFormRef = ref<FormInstance>()
const enterpriseDeviceRecallFormRef = ref<FormInstance>()
const enterpriseOptions = ref<EnterpriseItem[]>([])
const enterpriseLoading = ref(false)
const enterpriseDeviceOperationResult = ref<
Partial<EnterpriseAllocateDevicesResponse & EnterpriseRecallDevicesResponse> & {
success_count: number
fail_count: number
}
>({
success_count: 0,
fail_count: 0,
failed_items: null,
authorized_devices: null
})
// 套餐系列绑定相关
const seriesBindingDialogVisible = ref(false)
const seriesBindingLoading = ref(false)
const seriesBindingFormRef = ref<FormInstance>()
const seriesLoading = ref(false)
const packageSeriesList = ref<PackageSeriesResponse[]>([])
const createInitialSeriesBindingState = () => ({
selection_type: DeviceSelectionType.LIST,
series_id: undefined as number | undefined,
virtual_no_start: '',
virtual_no_end: '',
virtual_no: '',
device_name: '',
device_type: '',
manufacturer: '',
batch_no: '',
status: undefined as DeviceStatus | undefined,
shop_id_path: undefined as number[] | undefined,
filter_series_id: undefined as number | undefined,
created_at_range: [] as string[],
created_at_start: '',
created_at_end: ''
})
const seriesBindingForm = reactive(createInitialSeriesBindingState())
const createInitialEnterpriseDeviceAuthorizeState = () => ({
enterprise_id: undefined as number | undefined,
selection_type: DeviceSelectionType.LIST,
virtual_no: '',
batch_no: '',
shop_id_path: undefined as number[] | undefined,
remark: ''
})
const createInitialEnterpriseDeviceRecallState = () => ({
enterprise_id: undefined as number | undefined,
selection_type: DeviceSelectionType.LIST,
virtual_no: '',
batch_no: ''
})
const enterpriseDeviceAuthorizeForm = reactive(createInitialEnterpriseDeviceAuthorizeState())
const enterpriseDeviceRecallForm = reactive(createInitialEnterpriseDeviceRecallState())
const parseNumericStringToBigInt = (value: string): bigint | null => {
const normalizedValue = value.trim()
if (!normalizedValue || !/^\d+$/.test(normalizedValue)) {
return null
}
try {
return BigInt(normalizedValue)
} catch {
return null
}
}
const getPositiveRangeCountText = (start: bigint | null, end: bigint | null): string => {
if (start === null || end === null || end < start) {
return ''
}
return (end - start + 1n).toString()
}
const formatDigitGrouping = (value: string): string => value.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const parseDeviceRangeEndpoint = (value: string): { prefix: string; number: bigint } | null => {
const normalizedValue = value.trim()
if (!normalizedValue) {
return null
}
const numericValue = parseNumericStringToBigInt(normalizedValue)
if (numericValue !== null) {
return {
prefix: '',
number: numericValue
}
}
const match = normalizedValue.match(/^(.*?)(\d+)$/)
if (!match) {
return null
}
try {
return {
prefix: match[1],
number: BigInt(match[2])
}
} catch {
return null
}
}
const getDeviceRangeCountText = (startValue: string, endValue: string): string => {
const start = parseDeviceRangeEndpoint(startValue)
const end = parseDeviceRangeEndpoint(endValue)
if (!start || !end || start.prefix !== end.prefix) {
return ''
}
const countText = getPositiveRangeCountText(start.number, end.number)
return countText ? formatDigitGrouping(countText) : ''
}
const seriesBindingRangeCountText = computed(() =>
getDeviceRangeCountText(seriesBindingForm.virtual_no_start, seriesBindingForm.virtual_no_end)
)
const seriesBindingRules = reactive<FormRules>({
selection_type: [{ required: true, message: '请选择选设备方式', trigger: 'change' }],
series_id: [{ required: true, message: '请选择套餐系列', trigger: 'change' }],
virtual_no_start: [
{
required: true,
validator: (_rule, value, callback) => {
if (seriesBindingForm.selection_type === DeviceSelectionType.RANGE && !value) {
callback(new Error('请输入起始设备号'))
} else {
callback()
}
},
trigger: 'blur'
}
],
virtual_no_end: [
{
required: true,
validator: (_rule, value, callback) => {
if (seriesBindingForm.selection_type === DeviceSelectionType.RANGE && !value) {
callback(new Error('请输入结束设备号'))
} else {
callback()
}
},
trigger: 'blur'
}
]
})
const createEnterpriseDeviceRules = () =>
reactive<FormRules>({
enterprise_id: [{ required: true, message: '请选择企业', trigger: 'change' }],
selection_type: [{ required: true, message: '请选择选设备方式', trigger: 'change' }]
})
const enterpriseDeviceAuthorizeRules = createEnterpriseDeviceRules()
const enterpriseDeviceRecallRules = createEnterpriseDeviceRules()
const seriesBindingResult = ref<BatchSetDeviceSeriesBindingResponse | null>(null)
// 搜索表单店铺/系列选项
const shopOptions = ref<any[]>([])
const searchSeriesOptions = ref<any[]>([])
const searchBatchNoOptions = ref<any[]>([])
// 设备详情弹窗相关
const deviceDetailDialogVisible = ref(false)
const deviceDetailLoading = ref(false)
const currentDeviceDetail = ref<any>(null)
const deviceCards = ref<any[]>([])
const deviceCardsLoading = ref(false)
const deviceCardsDialogVisible = ref(false)
// 绑定卡相关
const bindCardLoading = ref(false)
const bindCardFormRef = ref<FormInstance>()
const iotCardList = ref<any[]>([])
const iotCardSearchLoading = ref(false)
const iotCardHasSearched = ref(false) // 标记是否已执行过搜索
const bindCardForm = reactive({
search_type: 'iccid' as 'iccid' | 'carrier_name',
iot_card_id: undefined as number | undefined,
slot_position: 1
})
const bindCardRules = reactive<FormRules>({
search_type: [{ required: true, message: '请选择搜索类型', trigger: 'change' }],
iot_card_id: [{ required: true, message: '请选择ICCID', trigger: 'change' }],
slot_position: [{ required: true, message: '请选择插槽位置', trigger: 'change' }]
})
// 设备操作相关对话框
const currentOperatingImei = ref<string>('') // 用于存储当前操作设备的IMEI
const currentOperatingDevice = ref<Device | null>(null)
const switchCardDialogVisible = ref(false)
const switchCardLoading = ref(false)
const deviceBindingCards = ref<any[]>([]) // 设备绑定的卡列表
const loadingDeviceCards = ref(false) // 加载设备绑定卡列表的状态
// 切换模式相关
const switchModeDialogVisible = ref(false)
const switchModeLoading = ref(false)
const switchModeCards = ref<SwitchModeCardOption[]>([])
const currentOperatingDeviceIdentifier = ref<string>('')
const currentOperatingDeviceHasImei = ref(false)
const currentDeviceSwitchMode = ref<number>(0)
// 实名认证策略相关
const realnamePolicyDialogVisible = ref(false)
const currentRealnamePolicyAsset = ref<string>('')
const currentRealnamePolicy = ref<string>('none')
// 搜索表单初始值
const initialSearchState = {
virtual_no: '',
imei: '',
device_name: '',
status: undefined as DeviceStatus | undefined,
activation_status: undefined as number | undefined,
real_name_status: undefined as 0 | 1 | undefined,
batch_no: '',
device_type: '',
manufacturer: '',
shop_id: undefined as number | undefined,
series_id: undefined as number | undefined,
has_active_package: undefined as boolean | undefined,
authorized_enterprise_id: undefined as number | undefined,
is_authorized_to_enterprise: undefined as boolean | undefined,
dateRange: [] as string[],
created_at_start: '',
created_at_end: ''
}
// 搜索表单
const searchForm = reactive<Record<string, any>>({ ...initialSearchState })
// 搜索批次号(用于搜索表单)
const loadSearchBatchNoOptions = async (batchNo?: string) => {
try {
const params: any = {
page: 1,
page_size: 20
}
if (batchNo) {
params.batch_no = batchNo
}
const res = await CardService.getIotCardImportTasks(params)
if (res.code === 0) {
searchBatchNoOptions.value = res.data.items || []
}
} catch (error) {
console.error('加载批次号选项失败:', error)
}
}
// 搜索批次号
const handleSearchBatchNo = (query: string) => {
if (query) {
loadSearchBatchNoOptions(query)
} else {
loadSearchBatchNoOptions()
}
}
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: '设备号',
prop: 'virtual_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备号'
}
},
{
label: '设备名称',
prop: 'device_name',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备名称'
}
},
{
label: 'IMEI',
prop: 'imei',
type: 'input',
config: {
clearable: true,
placeholder: '请输入IMEI'
}
},
{
label: '虚拟号',
prop: 'virtual_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入虚拟号'
}
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '请选择状态'
},
options: () => [
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
},
{
label: '激活状态',
prop: 'activation_status',
type: 'select',
config: {
clearable: true,
placeholder: '请选择激活状态'
},
options: () => [
{ label: '未激活', value: 0 },
{ label: '已激活', value: 1 }
]
},
{
label: '实名状态',
prop: 'real_name_status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已实名', value: 1 },
{ label: '未实名', value: 0 }
]
},
{
label: '批次号',
prop: 'batch_no',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: handleSearchBatchNo,
loading: false,
placeholder: '请选择或搜索批次号'
},
options: () =>
searchBatchNoOptions.value.map((b) => ({
label: b.batch_no,
value: b.batch_no
}))
},
{
label: '设备类型',
prop: 'device_type',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备类型'
}
},
{
label: '制造商',
prop: 'manufacturer',
type: 'input',
config: {
clearable: true,
placeholder: '请输入制造商'
}
},
{
label: '店铺名称',
prop: 'shop_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: (query: string) => searchShops(query),
loading: false,
placeholder: '请选择或搜索店铺名称'
},
options: () =>
shopOptions.value.map((shop) => ({
label: shop.shop_name,
value: shop.id
}))
},
{
label: '套餐系列',
prop: 'series_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: (query: string) => searchSeries(query),
loading: seriesLoading.value,
placeholder: '请选择或搜索套餐系列'
},
options: () =>
searchSeriesOptions.value.map((s) => ({
label: s.series_name,
value: s.id
}))
},
{
label: '起止时间',
prop: 'dateRange',
type: 'date',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
}
}
]
// 分页
searchFormItems.splice(searchFormItems.length - 1, 0, {
label: '有效套餐',
prop: 'has_active_package',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '是', value: true },
{ label: '否', value: false }
]
})
searchFormItems.splice(searchFormItems.length - 1, 0, {
label: '授权企业',
prop: 'authorized_enterprise_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: (query: string) => handleSearchEnterprise(query),
loading: enterpriseLoading.value,
placeholder: '请选择或搜索企业'
},
options: () =>
enterpriseOptions.value.map((enterprise) => ({
label: enterprise.enterprise_name,
value: enterprise.id
}))
})
searchFormItems.splice(searchFormItems.length - 1, 0, {
label: '授权状态',
prop: 'is_authorized_to_enterprise',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已授权', value: true },
{ label: '未授权', value: false }
]
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 列配置
const columnOptions = [
{ label: '设备号', prop: 'virtual_no' },
{ label: 'IMEI', prop: 'imei' },
{ label: '设备名称', prop: 'device_name' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '套餐系列', prop: 'series_name' },
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
{ label: '设备型号', prop: 'device_model' },
{ label: '设备类型', prop: 'device_type' },
{ label: '状态', prop: 'status' },
{ label: '状态名称', prop: 'status_name' },
{ label: '资产世代', prop: 'generation' },
{ label: '业务状态', prop: 'asset_status' },
{ label: '激活状态', prop: 'activation_status_name' },
{ label: '实名状态', prop: 'real_name_status_name' },
{ label: '在线状态', prop: 'online_status' },
{ label: '实名策略', prop: 'realname_policy' },
{ label: '切卡模式', prop: 'switch_mode' },
{ label: '激活时间', prop: 'activated_at' },
{ label: '制造商', prop: 'manufacturer' },
{ label: '软件版本', prop: 'software_version' },
{ label: '最大插槽数', prop: 'max_sim_slots' },
{ label: '已绑定卡数', prop: 'bound_card_count' },
{ label: '序列号', prop: 'sn' },
{ label: '批次号', prop: 'batch_no' },
{ label: '最后在线时间', prop: 'last_online_time' },
{ label: '最后同步时间', prop: 'last_gateway_sync_at' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
const deviceList = ref<Device[]>([])
// 分配表单
const allocateForm = reactive({
target_shop_id: undefined as number[] | undefined,
remark: ''
})
// 分配表单验证规则
const allocateRules = reactive<FormRules>({
target_shop_id: [{ required: true, message: '请选择目标店铺', trigger: 'change' }]
})
// 回收表单
const recallForm = reactive({
remark: ''
})
// 跳转到资产信息页面(原设备详情)
const goToDeviceSearchDetail = (deviceNo: string) => {
if (hasAuth('device:view_detail')) {
router.push({
path: RoutesAlias.AssetInformation,
query: {
virtual_no: deviceNo
}
})
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 查看设备设备绑定的卡
const handleViewCards = async (device: Device) => {
currentDeviceDetail.value = device
deviceCards.value = []
deviceCardsDialogVisible.value = true
// 重置绑定卡表单
bindCardForm.search_type = 'iccid'
bindCardForm.iot_card_id = undefined
bindCardForm.slot_position = 1
bindCardFormRef.value?.resetFields()
// 加载未绑定设备的卡列表
await Promise.all([loadDeviceCards(device.virtual_no), loadDefaultIotCards()])
}
// 加载设备绑定的卡列表
const loadDeviceCards = async (virtualNo: string) => {
deviceCardsLoading.value = true
try {
const res = await DeviceService.getDeviceCards(virtualNo)
if (res.code === 0 && res.data) {
deviceCards.value = (res.data.bindings || []).sort(
(a, b) => a.slot_position - b.slot_position
)
}
} catch (error) {
console.error('获取设备绑定的卡列表失败:', error)
} finally {
deviceCardsLoading.value = false
}
}
// 加载默认的IoT卡列表
const loadDefaultIotCards = async () => {
iotCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
page: 1,
page_size: 20,
is_standalone: true
})
if (res.code === 0 && res.data?.items) {
iotCardList.value = res.data.items
}
} catch (error) {
console.error('获取IoT卡列表失败:', error)
} finally {
iotCardSearchLoading.value = false
}
}
// 搜索IoT卡根据ICCID或运营商名称
const searchIotCards = async (query: string) => {
if (!query) {
await loadDefaultIotCards()
return
}
iotCardSearchLoading.value = true
iotCardHasSearched.value = true
try {
const params: any = {
page: 1,
page_size: 20,
is_standalone: true
}
if (bindCardForm.search_type === 'iccid') {
params.iccid = query
} else if (bindCardForm.search_type === 'carrier_name') {
params.carrier_name = query
}
const res = await CardService.getStandaloneIotCards(params)
if (res.code === 0 && res.data?.items) {
iotCardList.value = res.data.items
}
} catch (error) {
console.error('搜索IoT卡失败:', error)
} finally {
iotCardSearchLoading.value = false
}
}
// 确认绑定卡
const handleConfirmBindCard = async () => {
if (!bindCardFormRef.value || !currentDeviceDetail.value) return
await bindCardFormRef.value.validate(async (valid) => {
if (valid) {
bindCardLoading.value = true
try {
const res = await DeviceService.bindCard(currentDeviceDetail.value.virtual_no, {
iot_card_id: bindCardForm.iot_card_id!,
slot_position: bindCardForm.slot_position
})
if (res.code === 0) {
ElMessage.success('绑定成功')
// 重置表单
bindCardForm.iot_card_id = undefined
bindCardForm.slot_position = 1
bindCardFormRef.value?.resetFields()
// 重新加载卡列表
await loadDeviceCards(currentDeviceDetail.value.virtual_no)
// 刷新设备详情以更新绑定卡数量
const detailRes = await AssetService.resolveAsset(currentDeviceDetail.value.virtual_no)
if (detailRes.code === 0 && detailRes.data) {
currentDeviceDetail.value = detailRes.data
}
}
} catch (error: any) {
console.error('绑定卡失败:', error)
} finally {
bindCardLoading.value = false
}
}
})
}
// 解绑卡
const handleUnbindCard = (row: any) => {
ElMessageBox.confirm(`确定要解绑 ICCID: ${row.iccid} 吗?`, '解绑确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await DeviceService.unbindCard(
currentDeviceDetail.value.virtual_no,
row.iccid
)
if (res.code === 0) {
ElMessage.success('解绑成功')
// 重新加载卡列表
await loadDeviceCards(currentDeviceDetail.value.virtual_no)
// 刷新设备详情以更新绑定卡数量
const detailRes = await AssetService.resolveAsset(currentDeviceDetail.value.virtual_no)
if (detailRes.code === 0 && detailRes.data) {
currentDeviceDetail.value = detailRes.data
}
} else {
ElMessage.error(res.msg || '解绑失败')
}
} catch (error: any) {
console.error('解绑卡失败:', error)
}
})
.catch(() => {
// 用户取消
})
}
// 获取网络状态标签类型
const getNetworkStatusTagType = (networkStatus: number) => {
// 0: 停机 (danger 红色)
// 1: 开机 - 正常/异常 (success 绿色 / warning 黄色)
if (networkStatus === 0) {
return 'danger' // 停机 - 红色
} else if (networkStatus === 1) {
return 'success' // 开机正常 - 绿色
}
return 'info' // 未知状态
}
// 获取网络状态文本
const getNetworkStatusText = (networkStatus: number) => {
// 0: 停机
// 1: 开机 - 显示为"正常"
const textMap: Record<number, string> = {
0: '停机',
1: '正常' // 开机显示为"正常"
}
return textMap[networkStatus] || '未知'
}
// 获取设备状态标签类型
const getDeviceStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info', // 在库
2: 'warning', // 已分销
3: 'success', // 已激活
4: 'danger' // 已停用
}
return typeMap[status] || 'info'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'virtual_no',
label: '设备号',
minWidth: 170,
showOverflowTooltip: true,
formatter: (row: Device) => {
return h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
goToDeviceSearchDetail(row.virtual_no)
}
},
row.virtual_no
)
}
},
{
prop: 'imei',
label: 'IMEI',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: Device) => row.imei || '-'
},
{
prop: 'device_name',
label: '设备名称',
showOverflowTooltip: true,
minWidth: 150
},
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 120
},
{
prop: 'series_name',
label: '套餐系列',
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'estimated_final_expires_at',
label: '预计套餐到期时间',
width: 180,
formatter: (row: Device) =>
h(
'span',
{
class: { 'expiry-estimate--expiring': row.is_expiring },
title: getExpiryEstimateTooltip(row)
},
getExpiryEstimateText(row)
)
},
{
prop: 'device_model',
label: '设备型号',
minWidth: 120
},
{
prop: 'device_type',
label: '设备类型',
width: 120
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: Device) => {
const statusMap: Record<number, { text: string; type: any }> = {
1: { text: '在库', type: 'info' },
2: { text: '已分销', type: 'primary' },
3: { text: '已激活', type: 'success' },
4: { text: '已停用', type: 'danger' }
}
const status = statusMap[row.status] || { text: '未知', type: 'info' }
return h(ElTag, { type: status.type }, () => status.text)
}
},
{
prop: 'status_name',
label: '状态名称',
width: 100,
formatter: (row: Device) => row.status_name || '-'
},
{
prop: 'generation',
label: '资产世代',
width: 100,
formatter: (row: Device) => row.generation ?? '-'
},
{
prop: 'asset_status',
label: '业务状态',
width: 100,
formatter: (row: Device) => {
const assetStatusMap: Record<number, { text: string; type: any }> = {
1: { text: '在库', type: 'info' },
2: { text: '已销售', type: 'success' },
3: { text: '已换货', type: 'warning' },
4: { text: '已停用', type: 'danger' }
}
if (row.asset_status == null) return '-'
const status = assetStatusMap[row.asset_status] || { text: '未知', type: 'info' }
return h(ElTag, { type: status.type }, () => status.text)
}
},
{
prop: 'activation_status_name',
label: '激活状态',
width: 120,
formatter: (row: Device) => row.activation_status_name || '-'
},
{
prop: 'real_name_status_name',
label: '实名状态',
width: 100,
formatter: (row: Device) => row.real_name_status_name || '-'
},
{
prop: 'online_status',
label: '在线状态',
width: 100,
formatter: (row: Device) => {
const onlineStatusMap: Record<number, { text: string; type: any }> = {
0: { text: '未知', type: 'info' },
1: { text: '在线', type: 'success' },
2: { text: '离线', type: 'danger' }
}
const status = onlineStatusMap[row.online_status ?? 0] || { text: '未知', type: 'info' }
return h(ElTag, { type: status.type }, () => status.text)
}
},
{
prop: 'realname_policy',
label: '实名策略',
width: 150,
formatter: (row: Device) => {
const policy = row.realname_policy || ''
const policyMap: Record<string, string> = {
none: '无需实名',
before_order: '先实名后充值/购买',
after_order: '先充值/购买后实名'
}
return policyMap[policy] || (policy ? policy : '未知')
}
},
{
prop: 'switch_mode',
label: '切卡模式',
width: 100,
formatter: (row: Device) => {
const modeMap: Record<string, string> = {
'0': '自动',
'1': '手动'
}
return modeMap[row.switch_mode || ''] || '-'
}
},
{
prop: 'activated_at',
label: '激活时间',
width: 180,
formatter: (row: Device) => (row.activated_at ? formatDateTime(row.activated_at) : '-')
},
{
prop: 'manufacturer',
label: '制造商',
minWidth: 100
},
{
prop: 'software_version',
label: '软件版本',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: Device) => row.software_version || '-'
},
{
prop: 'max_sim_slots',
label: '最大插槽数',
width: 100
},
{
prop: 'bound_card_count',
label: '已绑定卡数',
width: 110,
formatter: (row: Device) => {
const color = row.bound_card_count > 0 ? '#67c23a' : '#909399'
return h('span', { style: { color, fontWeight: 'bold' } }, row.bound_card_count)
}
},
{
prop: 'sn',
label: '序列号',
minWidth: 120,
showOverflowTooltip: true,
formatter: (row: Device) => row.sn || '-'
},
{
prop: 'batch_no',
label: '批次号',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: Device) => row.batch_no || '-'
},
{
prop: 'last_online_time',
label: '最后在线时间',
width: 180,
formatter: (row: Device) =>
row.last_online_time ? formatDateTime(row.last_online_time) : '-'
},
{
prop: 'last_gateway_sync_at',
label: '最后同步时间',
width: 180,
formatter: (row: Device) =>
row.last_gateway_sync_at ? formatDateTime(row.last_gateway_sync_at) : '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: Device) => formatDateTime(row.created_at)
}
])
// 搜索店铺(用于搜索表单)
const searchShops = async (query: string) => {
try {
const params: any = {
page: 1,
page_size: 20
}
if (query) {
params.shop_name = query
}
const res = await ShopService.getShops(params)
if (res.code === 0) {
shopOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索店铺失败:', error)
}
}
// 搜索套餐系列(用于搜索表单)
const searchSeries = async (query: string) => {
try {
const params: any = {
page: 1,
page_size: 20,
status: 1
}
if (query) {
params.series_name = query
}
const res = await PackageSeriesService.getPackageSeries(params)
if (res.code === 0) {
searchSeriesOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索套餐系列失败:', error)
}
}
const handleSearchEnterprise = async (query?: string) => {
try {
enterpriseLoading.value = true
const params = {
page: 1,
page_size: 20,
enterprise_name: query || undefined,
status: 1
}
const res = await EnterpriseService.getEnterprises(params)
if (res.code === 0) {
enterpriseOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索企业失败:', error)
} finally {
enterpriseLoading.value = false
}
}
let isFirstActivation = true
onMounted(() => {
getTableData()
})
// 当页面被 keep-alive 激活时自动刷新数据
onActivated(() => {
if (!isFirstActivation) {
getTableData()
}
isFirstActivation = false
})
// 获取设备列表
const getTableData = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
virtual_no: searchForm.virtual_no || undefined,
imei: searchForm.imei || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
activation_status: searchForm.activation_status ?? undefined,
real_name_status: searchForm.real_name_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
shop_id: searchForm.shop_id || undefined,
series_id: searchForm.series_id || undefined,
has_active_package: searchForm.has_active_package ?? undefined,
authorized_enterprise_id: searchForm.authorized_enterprise_id || undefined,
is_authorized_to_enterprise: searchForm.is_authorized_to_enterprise ?? undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
}
const res = await DeviceService.getDevices(params)
if (res.code === 0 && res.data) {
deviceList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
const exportQuery = computed(() => {
const query: Record<string, unknown> = {
virtual_no: searchForm.virtual_no || undefined,
imei: searchForm.imei || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
activation_status: searchForm.activation_status ?? undefined,
real_name_status: searchForm.real_name_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
shop_id: searchForm.shop_id || undefined,
series_id: searchForm.series_id || undefined,
has_active_package: searchForm.has_active_package ?? undefined,
authorized_enterprise_id: searchForm.authorized_enterprise_id || undefined,
is_authorized_to_enterprise: searchForm.is_authorized_to_enterprise ?? undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
}
Object.keys(query).forEach((key) => {
if (query[key] === undefined || query[key] === null || query[key] === '') {
delete query[key]
}
})
return query
})
const showExportDialog = () => {
exportDialogVisible.value = true
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
// 搜索
const handleSearch = () => {
// 处理日期范围
if (searchForm.dateRange && Array.isArray(searchForm.dateRange)) {
searchForm.created_at_start = searchForm.dateRange[0] || ''
searchForm.created_at_end = searchForm.dateRange[1] || ''
} else {
searchForm.created_at_start = ''
searchForm.created_at_end = ''
}
pagination.page = 1
getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
}
// 处理选择变化
const handleSelectionChange = (selection: Device[]) => {
selectedDevices.value = selection
}
const showBatchRealnamePolicyDialog = () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要修改的设备')
return
}
batchRealnamePolicyError.value = ''
batchRealnamePolicyDialogVisible.value = true
}
const handleConfirmBatchRealnamePolicy = async (realnamePolicy: AssetRealnamePolicy) => {
const assetIds = selectedDevices.value.map((device) => device.id)
if (assetIds.length === 0) {
batchRealnamePolicyError.value = '请先选择要修改的设备'
return
}
if (assetIds.length > 500) {
batchRealnamePolicyError.value = '单次最多可修改500台设备'
return
}
batchRealnamePolicyError.value = ''
batchRealnamePolicyLoading.value = true
try {
const res = await DeviceService.batchUpdateRealnamePolicy(
{ asset_ids: assetIds, realname_policy: realnamePolicy },
{ requestOptions: { errorMessageMode: 'none' } }
)
if (res.code !== 0) {
batchRealnamePolicyError.value = res.msg || '批量修改实名顺序失败'
return
}
ElMessage.success('批量修改实名顺序成功')
batchRealnamePolicyDialogVisible.value = false
selectedDevices.value = []
await getTableData()
} catch (error: any) {
batchRealnamePolicyError.value = error?.response?.data?.msg || '批量修改实名顺序失败'
} finally {
batchRealnamePolicyLoading.value = false
}
}
// 删除设备
const deleteDevice = (row: Device) => {
ElMessageBox.confirm(
`确定删除设备 ${row.virtual_no} 吗?删除后将自动解绑所有卡。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
)
.then(async () => {
try {
await DeviceService.deleteDevice(row.virtual_no)
ElMessage.success('删除成功')
await getTableData()
} catch (error) {
console.error(error)
}
})
.catch(() => {
// 用户取消
})
}
// 清除设备套餐系列绑定
const handleClearDeviceSeries = async (deviceNo: string) => {
const device = deviceList.value.find((d) => d.virtual_no === deviceNo)
if (!device) {
ElMessage.error('未找到该设备')
return
}
ElMessageBox.confirm(`确定要清除设备 ${deviceNo} 的套餐系列关联吗?`, '确认清除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await DeviceService.batchSetDeviceSeriesBinding({
selection_type: DeviceSelectionType.LIST,
device_ids: [device.id],
series_id: 0
})
if (res.code === 0) {
if (res.data.fail_count === 0) {
ElMessage.success('清除关联成功')
} else {
ElMessage.warning(`部分失败:${res.data.failed_items?.[0]?.reason || '未知原因'}`)
}
await getTableData()
}
} catch (error) {
console.error('清除关联失败:', error)
}
})
.catch(() => {
// 用户取消
})
}
// 批量分配
const handleBatchAllocate = async () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要分配的设备')
return
}
allocateForm.target_shop_id = undefined
allocateForm.remark = ''
allocateResult.value = null
shopCascadeOptions.value = []
allocateDialogVisible.value = true
}
// 确认批量分配
const handleConfirmAllocate = async () => {
if (!allocateFormRef.value) return
await allocateFormRef.value.validate(async (valid) => {
if (valid) {
allocateLoading.value = true
try {
const targetShopPath = allocateForm.target_shop_id
if (!targetShopPath?.length) {
ElMessage.warning('请选择目标店铺')
return
}
const data = {
device_ids: selectedDevices.value.map((d) => d.id),
target_shop_id: targetShopPath[targetShopPath.length - 1],
remark: allocateForm.remark
}
const res = await DeviceService.allocateDevices(data)
if (res.code === 0) {
allocateResult.value = res.data
if (res.data.fail_count === 0) {
ElMessage.success('全部分配成功')
setTimeout(() => {
handleCloseAllocateDialog()
getTableData()
}, 1500)
} else {
ElMessage.warning(`部分分配失败,请查看失败详情`)
}
}
} catch (error) {
console.error(error)
} finally {
allocateLoading.value = false
}
}
})
}
// 关闭分配对话框
const handleCloseAllocateDialog = () => {
allocateDialogVisible.value = false
allocateResult.value = null
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
}
// 批量回收
const handleBatchRecall = () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要回收的设备')
return
}
recallForm.remark = ''
recallResult.value = null
recallDialogVisible.value = true
}
// 确认批量回收
const handleConfirmRecall = async () => {
recallLoading.value = true
try {
const data = {
device_ids: selectedDevices.value.map((d) => d.id),
remark: recallForm.remark
}
const res = await DeviceService.recallDevices(data)
if (res.code === 0) {
recallResult.value = res.data
if (res.data.fail_count === 0) {
ElMessage.success('全部回收成功')
setTimeout(() => {
handleCloseRecallDialog()
getTableData()
}, 1500)
} else {
ElMessage.warning(`部分回收失败,请查看失败详情`)
}
}
} catch (error) {
console.error(error)
} finally {
recallLoading.value = false
}
}
// 关闭回收对话框
const handleCloseRecallDialog = () => {
recallDialogVisible.value = false
recallResult.value = null
recallForm.remark = ''
}
const getInferredDeviceEnterpriseId = () => {
const filterEnterpriseId = searchForm.authorized_enterprise_id
if (filterEnterpriseId) return filterEnterpriseId
const selectedEnterpriseIds = Array.from(
new Set(
selectedDevices.value
.map((device) => device.authorized_enterprise_id)
.filter((id): id is number => typeof id === 'number')
)
)
return selectedEnterpriseIds.length === 1 ? selectedEnterpriseIds[0] : undefined
}
const resetEnterpriseDeviceAuthorizeForm = () => {
Object.assign(enterpriseDeviceAuthorizeForm, createInitialEnterpriseDeviceAuthorizeState(), {
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER,
virtual_no: searchForm.virtual_no || '',
batch_no: searchForm.batch_no || '',
shop_id_path: undefined
})
}
const resetEnterpriseDeviceRecallForm = () => {
Object.assign(enterpriseDeviceRecallForm, createInitialEnterpriseDeviceRecallState(), {
enterprise_id: getInferredDeviceEnterpriseId(),
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER,
virtual_no: searchForm.virtual_no || '',
batch_no: searchForm.batch_no || ''
})
}
const showEnterpriseDeviceAuthorizeDialog = async () => {
resetEnterpriseDeviceAuthorizeForm()
await Promise.all([
handleSearchEnterprise(),
loadSearchBatchNoOptions(),
loadTopLevelShopCascadeOptions()
])
enterpriseDeviceAuthorizeDialogVisible.value = true
enterpriseDeviceAuthorizeFormRef.value?.clearValidate()
}
const showEnterpriseDeviceRecallDialog = async () => {
resetEnterpriseDeviceRecallForm()
await Promise.all([handleSearchEnterprise(), loadSearchBatchNoOptions()])
enterpriseDeviceRecallDialogVisible.value = true
enterpriseDeviceRecallFormRef.value?.clearValidate()
}
const handleEnterpriseDeviceAuthorizeDialogClose = () => {
Object.assign(enterpriseDeviceAuthorizeForm, createInitialEnterpriseDeviceAuthorizeState())
enterpriseDeviceAuthorizeFormRef.value?.resetFields()
}
const handleEnterpriseDeviceRecallDialogClose = () => {
Object.assign(enterpriseDeviceRecallForm, createInitialEnterpriseDeviceRecallState())
enterpriseDeviceRecallFormRef.value?.resetFields()
}
const buildEnterpriseDeviceParams = (form: any) => {
const params: Record<string, unknown> = {
selection_type: form.selection_type,
remark: form.remark || undefined
}
if (form.selection_type === DeviceSelectionType.LIST) {
const deviceNos = selectedDevices.value.map((device) => device.virtual_no).filter(Boolean)
if (!deviceNos.length) {
ElMessage.warning('列表模式下请先选择设备')
return null
}
params.device_nos = deviceNos
} else {
if (form.batch_no) params.batch_no = form.batch_no
if (Array.isArray(form.shop_id_path) && form.shop_id_path.length > 0) {
params.shop_id = form.shop_id_path[form.shop_id_path.length - 1]
}
if (form.virtual_no) params.virtual_no = form.virtual_no
}
Object.keys(params).forEach((key) => {
if (params[key] === undefined || params[key] === '') delete params[key]
})
return params
}
const applyEnterpriseDeviceOperationSuccess = async (
title: string,
result: EnterpriseAllocateDevicesResponse | EnterpriseRecallDevicesResponse
) => {
enterpriseDeviceOperationResult.value = result
enterpriseDeviceResultTitle.value = title
enterpriseDeviceResultDialogVisible.value = true
selectedDevices.value = []
await getTableData()
}
const handleEnterpriseDeviceAuthorize = async () => {
if (!enterpriseDeviceAuthorizeFormRef.value) return
await enterpriseDeviceAuthorizeFormRef.value.validate(async (valid) => {
if (!valid) return
const params = buildEnterpriseDeviceParams(enterpriseDeviceAuthorizeForm)
if (!params) return
enterpriseDeviceAuthorizeLoading.value = true
try {
const res = await EnterpriseService.allocateDevices(
enterpriseDeviceAuthorizeForm.enterprise_id!,
params as any
)
if (res.code === 0) {
enterpriseDeviceAuthorizeDialogVisible.value = false
await applyEnterpriseDeviceOperationSuccess('授权设备给企业结果', res.data)
}
} catch (error) {
console.error('授权设备给企业失败:', error)
} finally {
enterpriseDeviceAuthorizeLoading.value = false
}
})
}
const handleEnterpriseDeviceRecall = async () => {
if (!enterpriseDeviceRecallFormRef.value) return
await enterpriseDeviceRecallFormRef.value.validate(async (valid) => {
if (!valid) return
const params = buildEnterpriseDeviceParams(enterpriseDeviceRecallForm)
if (!params) return
enterpriseDeviceRecallLoading.value = true
try {
const res = await EnterpriseService.recallDevices(
enterpriseDeviceRecallForm.enterprise_id!,
params as any
)
if (res.code === 0) {
enterpriseDeviceRecallDialogVisible.value = false
await applyEnterpriseDeviceOperationSuccess('撤销设备授权结果', res.data)
}
} catch (error) {
console.error('撤销设备授权失败:', error)
} finally {
enterpriseDeviceRecallLoading.value = false
}
})
}
// 批量设置套餐系列
const handleBatchSetSeries = async () => {
seriesBindingResult.value = null
Object.assign(seriesBindingForm, createInitialSeriesBindingState(), {
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
})
await Promise.all([
loadPackageSeriesList(),
loadSearchBatchNoOptions(),
loadSeriesBindingShopCascadeOptions()
])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}
// 加载套餐系列列表支持名称搜索默认20条
const loadPackageSeriesList = async (seriesName?: string) => {
seriesLoading.value = true
try {
const params: any = {
page: 1,
page_size: 20,
status: 1 // 只获取启用的
}
if (seriesName) {
params.series_name = seriesName
}
const res = await PackageSeriesService.getPackageSeries(params)
if (res.code === 0 && res.data.items) {
packageSeriesList.value = res.data.items
}
} catch (error) {
console.error('获取套餐系列列表失败:', error)
} finally {
seriesLoading.value = false
}
}
// 搜索套餐系列
const searchPackageSeries = async (query: string) => {
await loadPackageSeriesList(query || undefined)
}
const loadSeriesBindingShopCascadeOptions = async () => {
try {
const res = await ShopService.getShopsCascade({
parent_id: undefined,
exclude_self: true
})
if (res.code === 0) {
seriesBindingShopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
}
} catch (error) {
console.error('加载批量设置套餐系列根级店铺失败:', error)
}
}
const buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => {
const request: BatchSetDeviceSeriesBindingRequest = {
selection_type: seriesBindingForm.selection_type,
series_id: seriesBindingForm.series_id!
}
if (seriesBindingForm.selection_type === DeviceSelectionType.LIST) {
const deviceIds = selectedDevices.value.map((device) => device.id)
if (deviceIds.length === 0) {
ElMessage.warning('列表模式下请先选择要设置的设备')
return null
}
request.device_ids = deviceIds
return request
}
if (seriesBindingForm.selection_type === DeviceSelectionType.RANGE) {
request.virtual_no_start = seriesBindingForm.virtual_no_start
request.virtual_no_end = seriesBindingForm.virtual_no_end
return request
}
if (seriesBindingForm.virtual_no) request.virtual_no = seriesBindingForm.virtual_no
if (seriesBindingForm.device_name) request.device_name = seriesBindingForm.device_name
if (seriesBindingForm.device_type) request.device_type = seriesBindingForm.device_type
if (seriesBindingForm.manufacturer) request.manufacturer = seriesBindingForm.manufacturer
if (seriesBindingForm.batch_no) request.batch_no = seriesBindingForm.batch_no
if (seriesBindingForm.status !== undefined) request.status = seriesBindingForm.status
if (seriesBindingForm.shop_id_path?.length) {
request.shop_id = seriesBindingForm.shop_id_path[seriesBindingForm.shop_id_path.length - 1]
}
if (seriesBindingForm.filter_series_id !== undefined) {
request.filter_series_id = seriesBindingForm.filter_series_id
}
if (seriesBindingForm.created_at_range.length === 2) {
request.created_at_start = seriesBindingForm.created_at_range[0]
request.created_at_end = seriesBindingForm.created_at_range[1]
}
return request
}
// 确认设置套餐系列绑定
const handleConfirmSeriesBinding = async () => {
if (!seriesBindingFormRef.value) return
await seriesBindingFormRef.value.validate(async (valid) => {
if (valid) {
seriesBindingLoading.value = true
try {
const data = buildSeriesBindingRequest()
if (!data) {
return
}
const res = await DeviceService.batchSetDeviceSeriesBinding(data)
if (res.code === 0) {
seriesBindingResult.value = res.data
if (res.data.fail_count === 0) {
ElMessage.success('全部设置成功')
setTimeout(() => {
handleCloseSeriesBindingDialog()
getTableData()
}, 1500)
} else {
ElMessage.warning(`部分设置失败,请查看失败详情`)
}
}
} catch (error) {
console.error(error)
} finally {
seriesBindingLoading.value = false
}
}
})
}
// 关闭套餐系列绑定对话框
const handleCloseSeriesBindingDialog = () => {
seriesBindingDialogVisible.value = false
seriesBindingResult.value = null
Object.assign(seriesBindingForm, createInitialSeriesBindingState())
if (seriesBindingFormRef.value) {
seriesBindingFormRef.value.resetFields()
}
}
// ========== 设备操作相关 ==========
// 获取设备IMEI(通过virtual_no)
const getDeviceImei = async (virtualNo: string): Promise<string | null> => {
try {
const res = await AssetService.resolveAsset(virtualNo)
if (res.code === 0 && res.data && res.data.asset_type === 'device') {
return res.data.imei || null
}
return null
} catch (error) {
console.error('获取设备IMEI失败:', error)
return null
}
}
// 设备操作路由
const handleDeviceOperation = async (command: string, deviceNo: string, device?: Device) => {
// 设置当前操作的设备
currentOperatingDevice.value = device || null
switch (command) {
case 'view-cards':
handleViewCardsByDeviceNo(deviceNo)
break
case 'reboot': {
const imei = device?.imei || (await getDeviceImei(deviceNo))
if (!imei) {
ElMessage.error('无法获取设备IMEI,无法执行重启操作')
return
}
handleRebootDevice(imei)
break
}
case 'reset': {
const imei = device?.imei || (await getDeviceImei(deviceNo))
if (!imei) {
ElMessage.error('无法获取设备IMEI,无法执行重置操作')
return
}
handleResetDevice(imei)
break
}
case 'switch-card': {
const imei = device?.imei || (await getDeviceImei(deviceNo))
if (!imei) {
ElMessage.error('无法获取设备IMEI,无法执行切卡操作')
return
}
await showSwitchCardDialog(imei)
break
}
case 'manual-deactivate':
handleManualDeactivateDevice()
break
case 'delete':
await handleDeleteDeviceByNo(deviceNo)
break
case 'clear-series':
await handleClearDeviceSeries(deviceNo)
break
case 'switch-mode':
await showSwitchModeDialog(deviceNo)
break
case 'realname-policy':
showRealnamePolicyDialog(deviceNo, device?.realname_policy)
break
}
}
// 显示实名认证策略对话框
const showRealnamePolicyDialog = (deviceNo: string, currentPolicy?: string) => {
currentRealnamePolicyAsset.value = deviceNo
currentRealnamePolicy.value = currentPolicy ?? 'none'
realnamePolicyDialogVisible.value = true
}
// 确认设置实名认证策略
const handleConfirmRealnamePolicy = async (data: { realname_policy: string }) => {
try {
const res = await AssetService.updateRealnamePolicy(
currentRealnamePolicyAsset.value,
data.realname_policy
)
if (res.code === 0) {
ElMessage.success('设置实名认证策略成功')
realnamePolicyDialogVisible.value = false
await getTableData()
} else {
ElMessage.error(res.msg || '设置失败')
}
} catch (error) {
console.error('设置实名认证策略失败:', error)
}
}
// 通过设备号设备绑定的卡
const handleViewCardsByDeviceNo = (deviceNo: string) => {
const device = deviceList.value.find((d) => d.virtual_no === deviceNo)
if (device) {
handleViewCards(device)
} else {
ElMessage.error('未找到该设备')
}
}
// 手动停用设备(资产层面的停用)
const handleManualDeactivateDevice = () => {
const device = currentOperatingDevice.value
if (!device) {
ElMessage.error('未找到设备信息')
return
}
ElMessageBox.confirm(
`确定要手动停用设备 ${device.virtual_no} 吗?此操作将在资产管理层面停用该设备。`,
'确认手动停用',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
try {
const res = await AssetService.deactivateAsset(device.virtual_no)
if (res.code === 0) {
ElMessage.success('手动停用成功')
await getTableData()
}
} catch (error: any) {
console.error('手动停用失败:', error)
}
})
.catch(() => {
// 用户取消
})
}
// 通过设备号删除设备
const handleDeleteDeviceByNo = async (deviceNo: string) => {
// 先根据设备号找到设备对象
const device = deviceList.value.find((d) => d.virtual_no === deviceNo)
if (device) {
deleteDevice(device)
} else {
ElMessage.error('未找到该设备')
}
}
// 重启设备
const handleRebootDevice = (imei: string) => {
ElMessageBox.confirm(`确定要重启设备 ${imei} 吗?`, '重启确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await DeviceService.rebootDevice(imei)
if (res.code === 0) {
ElMessage.success('重启指令已发送')
} else {
ElMessage.error(res.msg || '重启失败')
}
} catch (error: any) {
console.error('重启设备失败:', error)
}
})
.catch(() => {
// 用户取消
})
}
// 恢复出厂设置
const handleResetDevice = (imei: string) => {
ElMessageBox.confirm(
`确定要恢复设备 ${imei} 的出厂设置吗?此操作将清除所有配置和数据!`,
'恢复出厂设置确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
)
.then(async () => {
try {
const res = await DeviceService.resetDevice(imei)
if (res.code === 0) {
ElMessage.success('恢复出厂设置指令已发送')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (error: any) {
console.error('恢复出厂设置失败:', error)
}
})
.catch(() => {
// 用户取消
})
}
// 显示切换SIM卡对话框
const showSwitchCardDialog = async (imei: string) => {
currentOperatingImei.value = imei
deviceBindingCards.value = []
switchCardDialogVisible.value = true
// 使用当前操作的设备
const device = currentOperatingDevice.value
if (!device) {
ElMessage.error('未找到设备信息')
return
}
// 加载设备绑定的卡列表
loadingDeviceCards.value = true
try {
const res = await DeviceService.getDeviceCards(device.virtual_no)
if (res.code === 0 && res.data) {
deviceBindingCards.value = res.data.bindings || []
if (deviceBindingCards.value.length === 0) {
ElMessage.warning('该设备暂无绑定的SIM卡')
}
}
} catch (error) {
console.error('加载设备绑定卡列表失败:', error)
} finally {
loadingDeviceCards.value = false
}
}
// 确认切换SIM卡
const handleConfirmSwitchCard = async (data: { target_iccid: string }) => {
try {
const res = await DeviceService.switchCard(currentOperatingImei.value, {
target_iccid: data.target_iccid
})
if (res.code === 0) {
ElMessage.success('切换SIM卡指令已发送')
switchCardDialogVisible.value = false
} else {
ElMessage.error(res.msg || '切换失败')
}
} catch (error: any) {
console.error('切换SIM卡失败:', error)
}
}
// 显示切换模式对话框
const normalizeSwitchModeCards = (cards: Array<Record<string, any>>): SwitchModeCardOption[] => {
return cards
.map((card) => ({
iot_card_id: Number(card.iot_card_id),
iccid: card.iccid,
slot_position: card.slot_position,
carrier_name: card.carrier_name,
msisdn: card.msisdn,
network_status: card.network_status === undefined ? undefined : Number(card.network_status),
real_name_status:
card.real_name_status === undefined ? undefined : Number(card.real_name_status),
is_current: card.is_current
}))
.filter((card) => Number.isFinite(card.iot_card_id) && Boolean(card.iccid))
}
// 显示切换模式对话框
const showSwitchModeDialog = async (deviceIdentifier: string) => {
const device = currentOperatingDevice.value
if (!device) {
ElMessage.error('未找到设备信息')
return
}
currentOperatingDeviceIdentifier.value = deviceIdentifier
currentDeviceSwitchMode.value = Number(device.switch_mode ?? 0)
currentOperatingDeviceHasImei.value = Boolean(device.imei)
switchModeCards.value = []
if (currentOperatingDeviceHasImei.value) {
try {
const res = await DeviceService.getDeviceCards(device.virtual_no)
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '加载设备绑定卡失败')
return
}
switchModeCards.value = normalizeSwitchModeCards(
(res.data.bindings || []) as Array<Record<string, any>>
)
} catch (error) {
console.error('加载切卡模式绑定卡失败:', error)
ElMessage.error('加载设备绑定卡失败')
return
}
}
switchModeDialogVisible.value = true
}
// 确认切换模式
const handleConfirmSwitchMode = async (data: { iot_card_id: number; switch_mode: 0 | 1 }) => {
switchModeLoading.value = true
try {
const res = await DeviceService.setSwitchMode(currentOperatingDeviceIdentifier.value, data)
if (res.code === 0) {
ElMessage.success('设置切卡模式成功')
switchModeDialogVisible.value = false
await getTableData()
} else {
ElMessage.error(res.msg || '设置切卡模式失败')
}
} catch (error: any) {
console.error('设置切卡模式失败:', error)
} finally {
switchModeLoading.value = false
}
}
// 设备操作菜单项配置
// 表格操作列配置
const getActions = (row: Device) => {
const actions: any[] = []
// 第一个按钮切换SIM卡
if (hasAuth('device:switch_sim')) {
actions.push({
label: '切换SIM卡',
handler: () => handleDeviceOperation('switch-card', row.virtual_no, row),
type: 'primary'
})
}
// 其他操作放到"更多"下拉菜单中
const moreActions: any[] = []
if (hasAuth('device:view_cards')) {
moreActions.push({
label: '设备绑定的卡',
handler: () => handleDeviceOperation('view-cards', row.virtual_no, row),
type: 'primary'
})
}
if (hasAuth('device:switch_mode')) {
moreActions.push({
label: '设置切卡模式',
handler: () => handleDeviceOperation('switch-mode', row.virtual_no, row),
type: 'primary'
})
}
if (hasAuth('device:reboot')) {
moreActions.push({
label: '重启设备',
handler: () => handleDeviceOperation('reboot', row.virtual_no, row),
type: 'primary'
})
}
if (hasAuth('device:factory_reset')) {
moreActions.push({
label: '恢复出厂',
handler: () => handleDeviceOperation('reset', row.virtual_no, row),
type: 'primary'
})
}
if (hasAuth('device:clear_series')) {
moreActions.push({
label: '清除关联',
handler: () => handleDeviceOperation('clear-series', row.virtual_no, row),
type: 'primary'
})
}
if (hasAuth('device:realname_policy')) {
moreActions.push({
label: '实名认证策略',
handler: () => handleDeviceOperation('realname-policy', row.virtual_no, row),
type: 'primary'
})
}
// if (hasAuth('device:manual_deactivate')) {
// moreActions.push({
// label: '手动停用',
// handler: () => handleDeviceOperation('manual-deactivate', row.virtual_no, row),
// type: 'primary'
// })
// }
if (hasAuth('device:delete')) {
moreActions.push({
label: '删除设备',
handler: () => handleDeviceOperation('delete', row.virtual_no, row),
type: 'primary'
})
}
if (hasAuth('device:operation_logs')) {
moreActions.push({
label: '操作审计日志',
handler: () => {
operationLogsIdentifier.value = row.virtual_no
operationLogsDialogVisible.value = true
},
type: 'primary'
})
}
// 如果有更多操作,添加到 actions 中
if (moreActions.length > 0) {
actions.push(...moreActions)
}
return actions
}
</script>
<style scoped lang="scss">
.device-list-page {
height: 100%;
}
.range-allocation-hint {
font-size: 13px;
line-height: 20px;
color: var(--el-text-color-secondary);
}
.range-allocation-hint--device {
margin: -6px 0 18px 120px;
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.expiry-estimate--expiring {
font-weight: 500;
color: var(--el-color-danger);
}
</style>