Files
one-pipe-system/src/views/asset-management/device-list/index.vue
2026-04-08 19:31:22 +08:00

1820 lines
57 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"
@click="handleBatchSetSeries"
:disabled="!selectedDevices.length"
v-permission="'device:batch_set_series'"
>
批量设置套餐系列
</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">
<ElTreeSelect
v-model="allocateForm.target_shop_id"
:data="shopTreeData"
:props="{ label: 'shop_name', value: 'id', children: 'children' }"
placeholder="请选择目标店铺"
clearable
filterable
check-strictly
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="600px"
>
<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="series_id">
<ElSelect
v-model="seriesBindingForm.series_id"
placeholder="请选择或搜索套餐系列"
style="width: 100%"
filterable
remote
reserve-keyword
:remote-method="searchPackageSeries"
:loading="seriesLoading"
clearable
>
<ElOption label="清除关联" :value="0" />
<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="100px"
>
<ElRow :gutter="20">
<ElCol :span="10">
<ElFormItem label="IoT卡" prop="iot_card_id">
<ElSelect
v-model="bindCardForm.iot_card_id"
placeholder="请选择或搜索IoT卡支持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.msisdn ? '(' + card.msisdn + ')' : ''}`"
:value="card.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="10">
<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="4">
<ElButton
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" align="center">
<template #default="{ row }">
<div
style="display: flex; gap: 10px; align-items: center; justify-content: 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" align="center">
<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 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卡对话框 -->
<ElDialog v-model="switchCardDialogVisible" title="切换SIM卡" width="600px">
<div v-if="loadingDeviceCards" style="padding: 40px; text-align: center">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
<div style="margin-top: 16px">加载设备绑定的卡列表中...</div>
</div>
<template v-else>
<ElAlert
v-if="deviceBindingCards.length === 0"
title="该设备暂无绑定的SIM卡"
type="warning"
:closable="false"
style="margin-bottom: 20px"
>
<template #default>
<div>当前设备没有绑定任何SIM卡,无法进行切换操作</div>
<div>请先为设备绑定SIM卡后再进行切换</div>
</template>
</ElAlert>
<ElForm
v-if="deviceBindingCards.length > 0"
ref="switchCardFormRef"
:model="switchCardForm"
:rules="switchCardRules"
label-width="120px"
>
<ElFormItem label="设备号">
<span style="font-weight: bold; color: #409eff">{{ currentOperatingImei }}</span>
</ElFormItem>
<ElFormItem label="目标ICCID" prop="target_iccid">
<ElSelect
v-model="switchCardForm.target_iccid"
placeholder="请选择要切换到的目标ICCID"
style="width: 100%"
clearable
>
<ElOption
v-for="card in deviceBindingCards"
:key="card.iccid"
:label="`${card.iccid} - 插槽${card.slot_position} - ${card.carrier_name}${card.is_current ? ' (当前)' : ''}`"
:value="card.iccid"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ card.iccid }}</span>
<ElTag size="small" style="margin-left: 10px"
>插槽{{ card.slot_position }}</ElTag
>
</div>
</ElOption>
</ElSelect>
<div style="margin-top: 8px; font-size: 12px; color: #909399">
当前设备共绑定 {{ deviceBindingCards.length }} 张SIM卡
</div>
</ElFormItem>
</ElForm>
</template>
<template #footer>
<ElButton @click="switchCardDialogVisible = false">取消</ElButton>
<ElButton
v-if="deviceBindingCards.length > 0"
type="primary"
@click="handleConfirmSwitchCard"
:loading="switchCardLoading"
>
确认切换
</ElButton>
</template>
</ElDialog>
<!-- 设置WiFi对话框 -->
<ElDialog v-model="setWiFiDialogVisible" title="设置WiFi" width="500px">
<ElForm
ref="setWiFiFormRef"
:model="setWiFiForm"
:rules="setWiFiRules"
label-width="120px"
>
<ElFormItem label="设备号">
<span style="font-weight: bold; color: #409eff">{{ currentOperatingImei }}</span>
</ElFormItem>
<ElFormItem label="WiFi状态" prop="enabled">
<ElRadioGroup v-model="setWiFiForm.enabled">
<ElRadio :value="1">启用</ElRadio>
<ElRadio :value="0">禁用</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="WiFi名称" prop="ssid">
<ElInput
v-model="setWiFiForm.ssid"
placeholder="请输入WiFi名称1-32个字符"
maxlength="32"
show-word-limit
clearable
/>
</ElFormItem>
<ElFormItem label="WiFi密码" prop="password">
<ElInput
v-model="setWiFiForm.password"
type="password"
placeholder="请输入WiFi密码8-63个字符"
maxlength="63"
show-word-limit
show-password
clearable
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="setWiFiDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleConfirmSetWiFi" :loading="setWiFiLoading">
确认设置
</ElButton>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import {
DeviceService,
ShopService,
CardService,
PackageSeriesService,
AssetService
} from '@/api/modules'
import {
ElMessage,
ElMessageBox,
ElTag,
ElIcon,
ElTreeSelect,
ElRadioGroup,
ElRadio
} from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import type {
Device,
DeviceStatus,
AllocateDevicesResponse,
RecallDevicesResponse,
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 type { PackageSeriesResponse } from '@/types/api'
defineOptions({ name: 'DeviceList' })
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 shopTreeData = ref<any[]>([])
const allocateResult = ref<AllocateDevicesResponse | null>(null)
const recallResult = ref<RecallDevicesResponse | null>(null)
// 套餐系列绑定相关
const seriesBindingDialogVisible = ref(false)
const seriesBindingLoading = ref(false)
const seriesBindingFormRef = ref<FormInstance>()
const seriesLoading = ref(false)
const packageSeriesList = ref<PackageSeriesResponse[]>([])
const seriesBindingForm = reactive({
series_id: undefined as number | undefined
})
const seriesBindingRules = reactive<FormRules>({
series_id: [{ required: true, message: '请选择套餐系列', trigger: 'change' }]
})
const seriesBindingResult = ref<BatchSetDeviceSeriesBindingResponse | null>(null)
// 设备详情弹窗相关
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 bindCardForm = reactive({
iot_card_id: undefined as number | undefined,
slot_position: 1
})
const bindCardRules = reactive<FormRules>({
iot_card_id: [{ required: true, message: '请选择IoT卡', 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 switchCardFormRef = ref<FormInstance>()
const switchCardForm = reactive({
target_iccid: ''
})
const switchCardRules = reactive<FormRules>({
target_iccid: [{ required: true, message: '请选择目标ICCID', trigger: 'change' }]
})
const deviceBindingCards = ref<any[]>([]) // 设备绑定的卡列表
const loadingDeviceCards = ref(false) // 加载设备绑定卡列表的状态
const setWiFiDialogVisible = ref(false)
const setWiFiLoading = ref(false)
const setWiFiFormRef = ref<FormInstance>()
const setWiFiForm = reactive({
enabled: 1,
ssid: '',
password: ''
})
const setWiFiRules = reactive<FormRules>({
ssid: [
{ required: true, message: '请输入WiFi名称', trigger: 'blur' },
{ min: 1, max: 32, message: 'WiFi名称长度为1-32个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入WiFi密码', trigger: 'blur' },
{ min: 8, max: 63, message: 'WiFi密码长度为8-63个字符', trigger: 'blur' }
]
})
// 搜索表单初始值
const initialSearchState = {
virtual_no: '',
device_name: '',
status: undefined as DeviceStatus | undefined,
batch_no: '',
device_type: '',
manufacturer: ''
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: '设备号',
prop: 'virtual_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备号'
}
},
{
label: '设备名称',
prop: 'device_name',
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: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
},
{
label: '设备类型',
prop: 'device_type',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备类型'
}
},
{
label: '制造商',
prop: 'manufacturer',
type: 'input',
config: {
clearable: true,
placeholder: '请输入制造商'
}
}
]
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 列配置
const columnOptions = [
{ label: '设备号', prop: 'virtual_no' },
{ label: '设备名称', prop: 'device_name' },
{ label: '设备型号', prop: 'device_model' },
{ label: '设备类型', prop: 'device_type' },
{ label: '制造商', prop: 'manufacturer' },
{ label: '最大插槽数', prop: 'max_sim_slots' },
{ label: '已绑定卡数', prop: 'bound_card_count' },
{ label: '状态', prop: 'status' },
{ label: '在线状态', prop: 'online_status' },
{ label: '固件版本', prop: 'software_version' },
{ label: '切卡模式', prop: 'switch_mode' },
{ label: '最后在线时间', prop: 'last_online_time' },
{ label: '最后同步时间', prop: 'last_gateway_sync_at' },
{ label: '批次号', prop: 'batch_no' },
{ 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.iot_card_id = undefined
bindCardForm.slot_position = 1
// 加载设备卡列表和默认IoT卡列表
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 || []
console.log('设备绑定的卡列表:', deviceCards.value)
// 🔧 临时调试强制第一张卡显示为当前卡方便测试UI效果
// 测试完成后请删除此代码
if (deviceCards.value.length > 0) {
deviceCards.value[0].is_current = true
console.log('🧪 测试模式:已将第一张卡标记为当前卡')
}
}
} 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
})
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
try {
const res = await CardService.getStandaloneIotCards({
page: 1,
page_size: 20,
iccid: query
})
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: 150,
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: 'device_name',
label: '设备名称',
minWidth: 120
},
{
prop: 'device_model',
label: '设备型号',
minWidth: 120
},
{
prop: 'device_type',
label: '设备类型',
width: 120
},
{
prop: 'manufacturer',
label: '制造商',
minWidth: 120
},
{
prop: 'max_sim_slots',
label: '最大插槽数',
width: 100,
align: 'center'
},
{
prop: 'bound_card_count',
label: '已绑定卡数',
width: 110,
align: 'center',
formatter: (row: Device) => {
const color = row.bound_card_count > 0 ? '#67c23a' : '#909399'
return h('span', { style: { color, fontWeight: 'bold' } }, row.bound_card_count)
}
},
{
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: '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: 'software_version',
label: '固件版本',
width: 120,
formatter: (row: Device) => row.software_version || '-'
},
{
prop: 'switch_mode',
label: '切卡模式',
width: 100,
formatter: (row: Device) => {
const modeMap: Record<string, string> = {
'0': '自动',
'1': '手动'
}
return modeMap[row.switch_mode || ''] || '-'
}
},
{
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: 'batch_no',
label: '批次号',
minWidth: 180,
formatter: (row: Device) => row.batch_no || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: Device) => formatDateTime(row.created_at)
}
])
onMounted(() => {
getTableData()
loadShopTree()
})
// 当页面被 keep-alive 激活时自动刷新数据
onActivated(() => {
getTableData()
})
// 加载店铺树形数据
const loadShopTree = async () => {
try {
const res = await ShopService.getShops({
page: 1,
page_size: 9999, // 获取所有数据用于构建树形结构
status: 1 // 只获取启用的店铺
})
if (res.code === 0) {
shopTreeData.value = buildShopTree(res.data.items || [])
}
} catch (error) {
console.error('获取店铺列表失败:', error)
}
}
// 构建店铺树形结构
const buildShopTree = (shops: any[]) => {
const map = new Map<number, any>()
const tree: any[] = []
// 先将所有项放入 map
shops.forEach((shop) => {
map.set(shop.id, { ...shop, children: [] })
})
// 构建树形结构
shops.forEach((shop) => {
const node = map.get(shop.id)!
if (shop.parent_id && map.has(shop.parent_id)) {
// 有父节点,添加到父节点的 children 中
const parent = map.get(shop.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
// 没有父节点或父节点不存在,作为根节点
tree.push(node)
}
})
return tree
}
// 获取设备列表
const getTableData = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
virtual_no: searchForm.virtual_no || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || 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 handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
// 搜索
const handleSearch = () => {
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 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 handleBatchAllocate = async () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要分配的设备')
return
}
allocateForm.target_shop_id = undefined
allocateForm.remark = ''
allocateResult.value = null
allocateDialogVisible.value = true
}
// 确认批量分配
const handleConfirmAllocate = async () => {
if (!allocateFormRef.value) return
await allocateFormRef.value.validate(async (valid) => {
if (valid) {
allocateLoading.value = true
try {
const data = {
device_ids: selectedDevices.value.map((d) => d.id),
target_shop_id: allocateForm.target_shop_id!,
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 handleBatchSetSeries = async () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要设置的设备')
return
}
seriesBindingForm.series_id = undefined
seriesBindingResult.value = null
await loadPackageSeriesList()
seriesBindingDialogVisible.value = true
}
// 加载套餐系列列表支持名称搜索默认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 handleConfirmSeriesBinding = async () => {
if (!seriesBindingFormRef.value) return
await seriesBindingFormRef.value.validate(async (valid) => {
if (valid) {
seriesBindingLoading.value = true
try {
const data = {
device_ids: selectedDevices.value.map((d) => d.id),
series_id: seriesBindingForm.series_id!
}
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
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 'set-wifi': {
const imei = device?.imei || (await getDeviceImei(deviceNo))
if (!imei) {
ElMessage.error('无法获取设备IMEI,无法执行WiFi设置操作')
return
}
showSetWiFiDialog(imei)
break
}
case 'manual-deactivate':
handleManualDeactivateDevice()
break
case 'delete':
await handleDeleteDeviceByNo(deviceNo)
break
}
}
// 通过设备号设备绑定的卡
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
switchCardForm.target_iccid = ''
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 || []
// 🔧 临时调试强制第一张卡显示为当前卡方便测试UI效果
// 测试完成后请删除此代码
if (deviceBindingCards.value.length > 0) {
deviceBindingCards.value[0].is_current = true
console.log('🧪 切换SIM卡测试模式已将第一张卡标记为当前卡')
}
if (deviceBindingCards.value.length === 0) {
ElMessage.warning('该设备暂无绑定的SIM卡')
}
}
} catch (error) {
console.error('加载设备绑定卡列表失败:', error)
} finally {
loadingDeviceCards.value = false
}
}
// 确认切换SIM卡
const handleConfirmSwitchCard = async () => {
if (!switchCardFormRef.value) return
await switchCardFormRef.value.validate(async (valid) => {
if (valid) {
switchCardLoading.value = true
try {
const res = await DeviceService.switchCard(currentOperatingImei.value, {
target_iccid: switchCardForm.target_iccid
})
if (res.code === 0) {
ElMessage.success('切换SIM卡指令已发送')
switchCardDialogVisible.value = false
} else {
ElMessage.error(res.msg || '切换失败')
}
} catch (error: any) {
console.error('切换SIM卡失败:', error)
} finally {
switchCardLoading.value = false
}
}
})
}
// 显示设置WiFi对话框
const showSetWiFiDialog = (imei: string) => {
currentOperatingImei.value = imei
setWiFiForm.enabled = 1
setWiFiForm.ssid = ''
setWiFiForm.password = ''
setWiFiDialogVisible.value = true
}
// 确认设置WiFi
const handleConfirmSetWiFi = async () => {
if (!setWiFiFormRef.value) return
await setWiFiFormRef.value.validate(async (valid) => {
if (valid) {
setWiFiLoading.value = true
try {
const res = await DeviceService.setWiFi(currentOperatingImei.value, {
enabled: setWiFiForm.enabled,
ssid: setWiFiForm.ssid,
password: setWiFiForm.password
})
if (res.code === 0) {
ElMessage.success('WiFi设置成功')
setWiFiDialogVisible.value = false
} else {
ElMessage.error(res.msg || '设置失败')
}
} catch (error: any) {
console.error('设置WiFi失败:', error)
} finally {
setWiFiLoading.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: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:set_wifi')) {
moreActions.push({
label: '设置WiFi',
handler: () => handleDeviceOperation('set-wifi', 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'
})
}
// 如果有更多操作,添加到 actions 中
if (moreActions.length > 0) {
actions.push(...moreActions)
}
return actions
}
</script>
<style scoped lang="scss">
.device-list-page {
height: 100%;
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
</style>