- 新增设备最后心跳查询接口及类型定义 - 设备在线状态字段统一更名为 online,移除重复字段 - 设备详情首次加载时从心跳接口获取最后上线时间 - WebSocket 推送在线状态时同步更新屏幕状态和最后心跳时间 - 编辑设备弹窗精简为仅可修改昵称,其余字段改为只读展示
1914 lines
62 KiB
Vue
1914 lines
62 KiB
Vue
<template>
|
||
<div class="device-detail">
|
||
<!-- 设备头部信息 -->
|
||
<div class="device-header">
|
||
<div class="device-info">
|
||
<img :src="deviceImage" alt="设备图片" class="device-image" />
|
||
<div class="info-content">
|
||
<div class="info-row">
|
||
<span class="device-name">{{ deviceData.snName }}</span>
|
||
<span
|
||
class="status-dot"
|
||
:style="{ backgroundColor: deviceData.onlineStatus == 1 ? '#67c23a' : '#f56c6c' }"
|
||
/>
|
||
<el-tag :type="deviceData.onlineStatus == 1 ? 'success' : 'danger'" size="small">
|
||
{{ deviceData.onlineStatus == 1 ? "在线" : "离线" }}
|
||
</el-tag>
|
||
<el-tag
|
||
v-if="deviceData.onlineStatus == 1"
|
||
:type="deviceData.screenState == 1 ? 'warning' : 'info'"
|
||
size="small"
|
||
>
|
||
{{
|
||
deviceData.screenState == 1
|
||
? "亮屏"
|
||
: deviceData.screenState === 0
|
||
? "熄屏"
|
||
: "未知"
|
||
}}
|
||
</el-tag>
|
||
</div>
|
||
<div class="info-grid">
|
||
<div class="info-item">
|
||
<span class="label">序列号:</span>
|
||
<span class="value">{{ formatText(props.serialno) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">IMEI:</span>
|
||
<span class="value">{{ formatText(deviceData.imei || networkInfo.mobileImei) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">设备所有者:</span>
|
||
<span class="value">{{ formatText(deviceData.owner) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">所属部门:</span>
|
||
<span class="value">{{ formatText(deviceData.department) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">分组:</span>
|
||
<span class="value">{{ formatText(deviceData.group) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">设备类型:</span>
|
||
<span class="value">{{ formatText(deviceData.deviceType) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">设备来源:</span>
|
||
<span class="value">{{ formatText(deviceData.source) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">激活时间:</span>
|
||
<span class="value">{{ formatText(deviceData.activationTime) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">最后上线:</span>
|
||
<span class="value">{{ formatDate(deviceData.lastOnline) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">设备编号:</span>
|
||
<span class="value">{{ formatText() }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设备截图轮播 -->
|
||
<div v-loading="screenshotLoading" class="device-screenshots">
|
||
<div class="screenshot-title">
|
||
<span>设备截图</span>
|
||
<el-tag v-if="screenshots.length" size="small" type="info">
|
||
{{ screenshots.length }} 张
|
||
</el-tag>
|
||
<el-button
|
||
class="refresh-btn"
|
||
size="small"
|
||
:loading="screenshotLoading"
|
||
@click="refreshScreenshots"
|
||
>
|
||
刷新
|
||
</el-button>
|
||
<el-button
|
||
class="capture-btn"
|
||
size="small"
|
||
type="primary"
|
||
:loading="captureLoading"
|
||
@click="captureScreenshot"
|
||
>
|
||
截图
|
||
</el-button>
|
||
<el-button
|
||
class="clear-btn"
|
||
size="small"
|
||
type="danger"
|
||
plain
|
||
:disabled="screenshots.length === 0"
|
||
:loading="clearLoading"
|
||
@click="clearScreenshotsFn"
|
||
>
|
||
清空截图
|
||
</el-button>
|
||
</div>
|
||
<el-empty
|
||
v-if="!screenshotLoading && screenshots.length === 0"
|
||
:image-size="80"
|
||
description="暂无截图"
|
||
/>
|
||
<el-carousel
|
||
v-else
|
||
class="screenshot-carousel"
|
||
height="360px"
|
||
indicator-position="outside"
|
||
arrow="always"
|
||
:autoplay="false"
|
||
>
|
||
<el-carousel-item v-for="(item, index) in screenshots" :key="item.id">
|
||
<div class="screenshot-item">
|
||
<el-image
|
||
:src="resolveIconUrl(item.url)"
|
||
fit="contain"
|
||
class="screenshot-image"
|
||
@click="openPreview(index)"
|
||
>
|
||
<template #error>
|
||
<div class="screenshot-error">图片加载失败</div>
|
||
</template>
|
||
</el-image>
|
||
<div v-if="item.uploadTime" class="screenshot-meta">
|
||
上传时间:{{ item.uploadTime }}
|
||
</div>
|
||
<span class="delete-btn" title="删除该截图" @click.stop="deleteScreenshotItem(item)">
|
||
<el-icon v-if="deletingId !== item.id" class="delete-icon"><Delete /></el-icon>
|
||
<el-icon v-else class="delete-icon is-loading"><Loading /></el-icon>
|
||
</span>
|
||
</div>
|
||
</el-carousel-item>
|
||
</el-carousel>
|
||
|
||
<!-- 全屏预览单张截图 -->
|
||
<el-image-viewer
|
||
v-if="previewVisible"
|
||
:url-list="screenshots.map((s) => resolveIconUrl(s.url) as string)"
|
||
:initial-index="previewIndex"
|
||
teleported
|
||
@close="previewVisible = false"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="action-buttons">
|
||
<el-button @click="handleReturn">
|
||
<el-icon><Back /></el-icon>
|
||
返回
|
||
</el-button>
|
||
<el-button @click="handleRefresh">
|
||
<el-icon><Refresh /></el-icon>
|
||
刷新
|
||
</el-button>
|
||
<el-button type="primary" @click="handleEdit">
|
||
<el-icon><Edit /></el-icon>
|
||
编辑
|
||
</el-button>
|
||
|
||
<div class="secondary-actions">
|
||
<el-button text @click="handleRemoteLock">
|
||
<el-icon><Lock /></el-icon>
|
||
远程锁定
|
||
</el-button>
|
||
<el-button text @click="handleLostMode">
|
||
<el-icon><Warning /></el-icon>
|
||
丢失模式
|
||
</el-button>
|
||
<el-button text @click="handleWipeDevice">
|
||
<el-icon><Delete /></el-icon>
|
||
清除设备
|
||
</el-button>
|
||
<el-button text @click="handleRestartDevice">
|
||
<el-icon><RefreshRight /></el-icon>
|
||
重启设备
|
||
</el-button>
|
||
<el-button text @click="handleSyncDevice">
|
||
<el-icon><Refresh /></el-icon>
|
||
同步设备
|
||
</el-button>
|
||
<el-dropdown trigger="click" @command="handleMoreActions">
|
||
<el-button text>
|
||
更多操作
|
||
<el-icon><ArrowDown /></el-icon>
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="assign">分配设备</el-dropdown-item>
|
||
<el-dropdown-item command="transfer">转移设备</el-dropdown-item>
|
||
<el-dropdown-item command="batch">批量操作</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设备信息标签页:第一个 tab,内含基本信息等分面板独立加载 -->
|
||
<el-tabs v-model="activeTab" class="tabs-container device-tabs">
|
||
<el-tab-pane label="设备信息" name="deviceInfo">
|
||
<el-collapse v-model="activeNames" class="device-sn-detail">
|
||
<!-- 基本信息 -->
|
||
<el-collapse-item title="基本信息" name="basic">
|
||
<template #title>
|
||
<div class="panel-title">
|
||
基本信息
|
||
<el-tag v-if="loading.basic" size="small" type="info" effect="plain">
|
||
加载中…
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-descriptions v-loading="loading.basic" :column="2" border label-width="130px">
|
||
<el-descriptions-item label="设备名称">
|
||
{{ formatText(basicInfo.deviceName) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="设备品牌">
|
||
{{ formatText(basicInfo.deviceBrand) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="设备型号">
|
||
{{ formatText(basicInfo.deviceModel) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="主板名称">
|
||
{{ formatText(basicInfo.deviceBoard) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="制造商">
|
||
{{ formatText(basicInfo.deviceManufacturer) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="硬件信息">
|
||
{{ formatText(basicInfo.hardware) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="主机名">
|
||
{{ formatText(basicInfo.host) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Android 版本">
|
||
{{ formatText(basicInfo.androidVersion) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Android API">
|
||
{{ formatText(basicInfo.androidApi) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Android ID">
|
||
{{ formatText(basicInfo.androidId) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Build ID">
|
||
{{ formatText(basicInfo.buildId) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Build Display ID">
|
||
{{ formatText(basicInfo.buildDisplayId) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Build 类型">
|
||
{{ formatText(basicInfo.buildType) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="系统语言">
|
||
{{ formatText(basicInfo.language) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="时区">
|
||
{{ formatText(basicInfo.timezone) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="屏幕分辨率">
|
||
{{ formatText(basicInfo.screenResolution) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="屏幕密度">
|
||
{{ formatText(basicInfo.screenDensity) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="内核版本">
|
||
{{ formatText(basicInfo.kernelVersion) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="ROM 版本">
|
||
{{ formatText(basicInfo.romVersion) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-collapse-item>
|
||
|
||
<!-- 硬件信息 -->
|
||
<el-collapse-item title="硬件信息" name="hardware">
|
||
<template #title>
|
||
<div class="panel-title">
|
||
硬件信息
|
||
<el-tag v-if="loading.hardware" size="small" type="info" effect="plain">
|
||
加载中…
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-descriptions v-loading="loading.hardware" :column="2" border label-width="130px">
|
||
<el-descriptions-item label="CPU ABI">
|
||
{{ formatText(hardwareInfo.cpuAbi) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="CPU 型号">
|
||
{{ formatText(hardwareInfo.cpuModel) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="CPU 核心数">
|
||
{{ formatText(hardwareInfo.cpuCores) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="CPU 最大频率">
|
||
{{ formatText(hardwareInfo.cpuMaxFreq) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="CPU 最小频率">
|
||
{{ formatText(hardwareInfo.cpuMinFreq) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="总内存">
|
||
{{ formatText(hardwareInfo.totalRam) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="可用内存">
|
||
{{ formatText(hardwareInfo.availableRam) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="内部存储">
|
||
{{ formatText(hardwareInfo.internalStorage) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="可用存储">
|
||
{{ formatText(hardwareInfo.availableStorage) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="外部存储">
|
||
{{ formatText(hardwareInfo.externalStorage) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="电池容量">
|
||
{{ formatText(hardwareInfo.batteryCapacity) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="电池电量">
|
||
{{ formatText(hardwareInfo.batteryLevel) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="电池状态">
|
||
{{ formatText(hardwareInfo.batteryStatus) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="电池健康度">
|
||
{{ formatText(hardwareInfo.batteryHealth) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="电池温度">
|
||
{{ formatText(hardwareInfo.batteryTemp) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="GPU 渲染器">
|
||
{{ formatText(hardwareInfo.gpuRenderer) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="GPU 厂商">
|
||
{{ formatText(hardwareInfo.gpuVendor) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="GPU 版本">
|
||
{{ formatText(hardwareInfo.gpuVersion) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="传感器">
|
||
{{ formatText(hardwareInfo.sensors) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-collapse-item>
|
||
|
||
<!-- 网络信息 -->
|
||
<el-collapse-item title="网络信息" name="network">
|
||
<template #title>
|
||
<div class="panel-title">
|
||
网络信息
|
||
<el-tag v-if="loading.network" size="small" type="info" effect="plain">
|
||
加载中…
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-descriptions v-loading="loading.network" :column="2" border label-width="130px">
|
||
<el-descriptions-item label="网络类型">
|
||
{{ formatText(networkInfo.networkType) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="是否联网">
|
||
{{ formatBool(networkInfo.isConnected) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="WiFi SSID">
|
||
{{ formatText(networkInfo.wifiSsid) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="WiFi BSSID">
|
||
{{ formatText(networkInfo.wifiBssid) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="WiFi IP">
|
||
{{ formatText(networkInfo.wifiIp) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="WiFi MAC">
|
||
{{ formatText(networkInfo.wifiMac) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="WiFi 网关">
|
||
{{ formatText(networkInfo.wifiGateway) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="WiFi 信号">
|
||
{{ formatText(networkInfo.wifiRssi) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="运营商">
|
||
{{ formatText(networkInfo.mobileOperator) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="IMEI">
|
||
{{ formatText(networkInfo.mobileImei) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="IMSI">
|
||
{{ formatText(networkInfo.mobileImsi) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="蓝牙 MAC">
|
||
{{ formatText(networkInfo.bluetoothMac) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="蓝牙名称">
|
||
{{ formatText(networkInfo.bluetoothName) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="VPN">
|
||
{{ formatBool(networkInfo.isVpn) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="代理">
|
||
{{ formatBool(networkInfo.isProxy) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="代理主机">
|
||
{{ formatText(networkInfo.proxyHost) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="代理端口">
|
||
{{ formatText(networkInfo.proxyPort) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-collapse-item>
|
||
|
||
<!-- 安全信息 -->
|
||
<el-collapse-item title="安全信息" name="security">
|
||
<template #title>
|
||
<div class="panel-title">
|
||
安全信息
|
||
<el-tag v-if="loading.security" size="small" type="info" effect="plain">
|
||
加载中…
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-descriptions v-loading="loading.security" :column="2" border label-width="130px">
|
||
<el-descriptions-item label="Root 状态">
|
||
{{ formatBool(securityInfo.rootStatus) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="已 Root">
|
||
{{ formatBool(securityInfo.isRoot) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="越狱状态">
|
||
{{ formatBool(securityInfo.isJailbreak) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="su 二进制">
|
||
{{ formatBool(securityInfo.hasSuBinary) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="BusyBox">
|
||
{{ formatBool(securityInfo.hasBusybox) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="模拟器">
|
||
{{ formatBool(securityInfo.isEmulator) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="可调试">
|
||
{{ formatBool(securityInfo.isDebuggable) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="USB 调试">
|
||
{{ formatBool(securityInfo.isUsbDebug) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="未知来源">
|
||
{{ formatBool(securityInfo.isUnknownSources) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="加密">
|
||
{{ formatBool(securityInfo.isEncrypted) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="密码保护">
|
||
{{ formatBool(securityInfo.hasPassword) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="锁屏类型">
|
||
{{ formatText(securityInfo.lockScreenType) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="安全补丁">
|
||
{{ formatText(securityInfo.securityPatch) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="ADB 状态">
|
||
{{ formatBool(securityInfo.adbStatus) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="VPN 激活">
|
||
{{ formatBool(securityInfo.isVpnActive) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="设备所有者">
|
||
{{ formatBool(securityInfo.isDeviceOwner) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-collapse-item>
|
||
|
||
<!-- 其他信息 -->
|
||
<el-collapse-item title="其他信息" name="other">
|
||
<template #title>
|
||
<div class="panel-title">
|
||
其他信息
|
||
<el-tag v-if="loading.other" size="small" type="info" effect="plain">
|
||
加载中…
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-descriptions v-loading="loading.other" :column="2" border label-width="130px">
|
||
<el-descriptions-item label="App 版本">
|
||
{{ formatText(otherInfo.appVersion) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="App 版本号">
|
||
{{ formatText(otherInfo.appVersionCode) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="包名">
|
||
{{ formatText(otherInfo.packageName) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="渠道">
|
||
{{ formatText(otherInfo.channel) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="极光推送ID">
|
||
{{ formatText(otherInfo.pushId) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="GAID">
|
||
{{ formatText(otherInfo.gaid) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="OAID">
|
||
{{ formatText(otherInfo.oaid) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="手机号">
|
||
{{ formatText(otherInfo.phoneNumber) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="ICCID">
|
||
{{ formatText(otherInfo.iccid) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="充电中">
|
||
{{ formatBool(otherInfo.isCharging) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="充电类型">
|
||
{{ formatText(otherInfo.chargingType) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="设备温度">
|
||
{{ formatText(otherInfo.temperature) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="剩余存储">
|
||
{{ formatText(otherInfo.freeStorage) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="剩余内存">
|
||
{{ formatText(otherInfo.freeRam) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="传感器数量">
|
||
{{ formatText(otherInfo.sensorCount) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="应用列表 MD5" :span="2">
|
||
{{ formatText(otherInfo.appListMd5) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</el-tab-pane>
|
||
|
||
<!-- 扩展标签页(应用信息 / 策略信息 / 合规状态 / 操作日志 / 定位信息) -->
|
||
<el-tab-pane label="应用信息" name="appInfo">
|
||
<div class="tab-content">
|
||
<div class="apk-toolbar">
|
||
<el-button
|
||
type="primary"
|
||
plain
|
||
size="small"
|
||
:loading="loading.apk"
|
||
@click="loadApkInfo(props.serialno)"
|
||
>
|
||
<el-icon><Refresh /></el-icon>
|
||
刷新应用列表
|
||
</el-button>
|
||
</div>
|
||
<el-table v-loading="loading.apk" :data="apkList" border stripe empty-text="暂无应用信息">
|
||
<el-table-column label="图标" width="70" align="center">
|
||
<template #default="{ row }">
|
||
<el-image
|
||
:src="resolveIconUrl(row.iconUrl)"
|
||
fit="cover"
|
||
class="apk-icon"
|
||
:preview-src-list="row.iconUrl ? [resolveIconUrl(row.iconUrl)] : []"
|
||
preview-teleported
|
||
>
|
||
<template #error>
|
||
<div class="apk-icon-placeholder">无</div>
|
||
</template>
|
||
</el-image>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="appName"
|
||
label="应用名称"
|
||
min-width="160"
|
||
sortable
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="packageName"
|
||
label="包名"
|
||
min-width="220"
|
||
sortable
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column prop="versionName" label="版本" width="120" show-overflow-tooltip />
|
||
<el-table-column label="大小" width="110" sortable sort-by="apkSize">
|
||
<template #default="{ row }">{{ formatSize(row.apkSize) }}</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="系统应用"
|
||
width="120"
|
||
align="center"
|
||
sortable
|
||
sort-by="systemApp"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.systemApp" type="info" size="small">系统</el-tag>
|
||
<el-tag v-else type="success" size="small">普通</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="安装时间"
|
||
width="170"
|
||
sortable
|
||
:sort-method="
|
||
(a: ApkInstallInfo, b: ApkInstallInfo) =>
|
||
dateValue(a.installTime) - dateValue(b.installTime)
|
||
"
|
||
>
|
||
<template #default="{ row }">{{ formatDate(row.installTime) }}</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="更新时间"
|
||
width="170"
|
||
sortable
|
||
:sort-method="
|
||
(a: ApkInstallInfo, b: ApkInstallInfo) =>
|
||
dateValue(a.lastUpdateTime) - dateValue(b.lastUpdateTime)
|
||
"
|
||
>
|
||
<template #default="{ row }">{{ formatDate(row.lastUpdateTime) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="160" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="apk-actions">
|
||
<el-tooltip content="打开" placement="top">
|
||
<el-button
|
||
link
|
||
type="primary"
|
||
:icon="VideoPlay"
|
||
@click="handleLaunchApp(row)"
|
||
/>
|
||
</el-tooltip>
|
||
<el-tooltip content="停止" placement="top">
|
||
<el-button
|
||
link
|
||
type="warning"
|
||
:icon="SwitchButton"
|
||
@click="handleStopApp(row)"
|
||
/>
|
||
</el-tooltip>
|
||
<el-tooltip content="清除数据" placement="top">
|
||
<el-button link type="warning" :icon="Brush" @click="handleClearAppData(row)" />
|
||
</el-tooltip>
|
||
<el-tooltip content="卸载" placement="top">
|
||
<el-button link type="danger" :icon="Delete" @click="handleUninstallApp(row)" />
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="定位信息" name="locationInfo">
|
||
<div class="tab-content location-tab">
|
||
<!-- 定位信息概览 -->
|
||
<el-card class="location-overview" shadow="never">
|
||
<template #header>
|
||
<div class="location-overview__header">
|
||
<span>定位概览</span>
|
||
<el-button
|
||
size="small"
|
||
:loading="locationLoading"
|
||
:icon="Refresh"
|
||
@click="handleRefreshLocation"
|
||
>
|
||
刷新定位
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<el-descriptions v-loading="locationLoading" :column="2" border size="small">
|
||
<el-descriptions-item label="定位时间">
|
||
{{ formatText(locationInfo.lastSuccessfulTime) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="定位状态">
|
||
<el-tag
|
||
v-if="locationInfo.mapError && locationInfo.mapError !== '-'"
|
||
type="danger"
|
||
size="small"
|
||
>
|
||
失败
|
||
</el-tag>
|
||
<el-tag v-else type="success" size="small">成功</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="省/市" :span="2">
|
||
{{ formatText(locationInfo.province) }} / {{ formatText(locationInfo.city) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="区/县" :span="2">
|
||
{{ formatText(locationInfo.district) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="详细地址" :span="2">
|
||
{{ formatText(locationInfo.address) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="位置描述" :span="2">
|
||
{{ formatText(locationInfo.locationDescribe) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="经度">
|
||
{{ formatText(locationInfo.longitude) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="纬度">
|
||
{{ formatText(locationInfo.latitude) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item
|
||
v-if="locationInfo.mapError && locationInfo.mapError !== '-'"
|
||
label="失败原因"
|
||
:span="2"
|
||
>
|
||
<span class="location-error">{{ locationInfo.mapError }}</span>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-card>
|
||
|
||
<!-- 百度地图 -->
|
||
<el-card class="location-map" shadow="never">
|
||
<template #header>
|
||
<span>地图位置</span>
|
||
</template>
|
||
<div ref="mapContainer" class="baidu-map"></div>
|
||
<el-empty
|
||
v-if="!locationInfo.latitude || !locationInfo.longitude"
|
||
description="暂无定位坐标,无法显示地图"
|
||
class="baidu-map__empty"
|
||
/>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="策略信息" name="policyInfo">
|
||
<div class="tab-content">
|
||
<el-empty description="策略信息加载中..." />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="合规状态" name="complianceInfo">
|
||
<div class="tab-content">
|
||
<el-empty description="合规状态加载中..." />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="操作日志" name="operationLog">
|
||
<div class="tab-content">
|
||
<el-empty description="操作日志加载中..." />
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import DeviceAPI from "@/api/system/device";
|
||
import type {
|
||
ApkInstallInfo,
|
||
DeviceBasicInfo,
|
||
DeviceHardwareInfo,
|
||
DeviceNetworkInfo,
|
||
DeviceOnlineVO,
|
||
DeviceOtherInfo,
|
||
DeviceSecurityInfo,
|
||
DeviceLocationInfo,
|
||
DeviceScreenshot,
|
||
} from "@/api/system/device/types";
|
||
import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||
import { useSse } from "@/composables";
|
||
import {
|
||
Back,
|
||
Edit,
|
||
Refresh,
|
||
Lock,
|
||
Warning,
|
||
Delete,
|
||
Loading,
|
||
RefreshRight,
|
||
ArrowDown,
|
||
VideoPlay,
|
||
Brush,
|
||
SwitchButton,
|
||
} from "@element-plus/icons-vue";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
|
||
// 接收传入的 props
|
||
interface Props {
|
||
modelValue?: boolean; // 控制对话框显示隐藏
|
||
serialno?: string; // 传递的设备序列号
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
modelValue: false,
|
||
serialno: "",
|
||
});
|
||
|
||
// 定义自定义事件
|
||
const emit = defineEmits<{
|
||
"update:modelValue": [boolean];
|
||
returnClick: [];
|
||
}>();
|
||
|
||
// 监听 serialno 变化,加载对应的数据
|
||
watch(
|
||
() => props.serialno,
|
||
(newSerialno) => {
|
||
if (newSerialno) {
|
||
// 切换设备时重置应用信息懒加载状态
|
||
apkLoaded.value = false;
|
||
apkList.value = [];
|
||
loadAll(newSerialno);
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
const activeNames = ref(["basic", "hardware", "network", "security", "other"]);
|
||
|
||
/** 标签页当前选中项(设备信息为第一个 tab,内含基本/硬件/网络/安全/其他分面板) */
|
||
const activeTab = ref("deviceInfo");
|
||
|
||
/** 设备头部数据 */
|
||
const deviceData = reactive({
|
||
snName: "",
|
||
onlineStatus: 0,
|
||
screenState: null as number | null,
|
||
owner: "",
|
||
department: "",
|
||
group: "",
|
||
deviceType: "",
|
||
source: "",
|
||
activationTime: "",
|
||
lastOnline: "",
|
||
deviceSn: "",
|
||
imei: "",
|
||
serialNumber: "",
|
||
});
|
||
|
||
/** 设备图片 */
|
||
const deviceImage = ref("./src/assets/images/iphone-17-pro.webp");
|
||
|
||
/** 设备截图列表 */
|
||
const screenshots = ref<DeviceScreenshot[]>([]);
|
||
const screenshotLoading = ref(false);
|
||
/** 当前设备SN,用于刷新截图 */
|
||
const currentSn = ref<string>("");
|
||
/** 全屏预览可见性 */
|
||
const previewVisible = ref(false);
|
||
/** 全屏预览初始索引 */
|
||
const previewIndex = ref(0);
|
||
/** 截图指令请求中 */
|
||
const captureLoading = ref(false);
|
||
/** 清空截图请求中 */
|
||
const clearLoading = ref(false);
|
||
/** 正在删除的截图ID(用于单张删除按钮 loading) */
|
||
const deletingId = ref<number | null>(null);
|
||
|
||
/** 分面板数据 */
|
||
const basicInfo = reactive<Partial<DeviceBasicInfo>>({});
|
||
const hardwareInfo = reactive<Partial<DeviceHardwareInfo>>({});
|
||
const networkInfo = reactive<Partial<DeviceNetworkInfo>>({});
|
||
const securityInfo = reactive<Partial<DeviceSecurityInfo>>({});
|
||
const otherInfo = reactive<Partial<DeviceOtherInfo>>({});
|
||
|
||
/** 设备定位信息 */
|
||
const locationInfo = reactive<Partial<DeviceLocationInfo>>({});
|
||
const locationLoading = ref(false);
|
||
/** 百度地图容器 DOM 引用 */
|
||
const mapContainer = ref<HTMLElement | null>(null);
|
||
/** 百度地图实例 */
|
||
let baiduMap: any = null;
|
||
/** 百度地图 JS 是否已加载 */
|
||
let baiduScriptLoaded = false;
|
||
|
||
const loading = reactive({
|
||
basic: false,
|
||
hardware: false,
|
||
network: false,
|
||
security: false,
|
||
other: false,
|
||
apk: false,
|
||
location: false,
|
||
});
|
||
|
||
/** 设备已安装应用列表 */
|
||
const apkList = ref<ApkInstallInfo[]>([]);
|
||
/** 应用信息是否已加载(懒加载,仅首次进入 tab 时拉取) */
|
||
const apkLoaded = ref(false);
|
||
|
||
/**
|
||
* 拼接应用图标完整访问地址
|
||
*
|
||
* 后端返回的是相对路径(如 /static/app_icon/{md5}.png)。
|
||
* img/el-image 的 src 是浏览器原生请求,不经过 axios,拿不到 baseURL,
|
||
* 因此必须手动补上代理前缀,否则请求会打到前端 dev server 上导致 404。
|
||
*/
|
||
function resolveIconUrl(url?: string | null): string {
|
||
if (!url) return "";
|
||
// 已是绝对地址(http/https)或 base64,直接返回
|
||
if (/^(https?:)?\/\//.test(url) || url.startsWith("data:")) return url;
|
||
const baseApi = import.meta.env.VITE_APP_BASE_API ?? "";
|
||
// 避免 baseApi 结尾与 url 开头的斜杠重复
|
||
return `${baseApi.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
|
||
}
|
||
|
||
/** 空值统一显示占位符 */
|
||
function formatText(value?: string | number | null): string {
|
||
return value === undefined || value === null || value === "" ? "-" : String(value);
|
||
}
|
||
|
||
/** 0/1 枚举转文案 */
|
||
function formatBool(value?: number | null): string {
|
||
if (value === undefined || value === null) return "-";
|
||
return value === 1 ? "是" : "否";
|
||
}
|
||
|
||
/** 字节数转可读大小 */
|
||
function formatSize(bytes?: number): string {
|
||
if (bytes === undefined || bytes === null || bytes === 0) return "-";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let size = bytes;
|
||
let unitIndex = 0;
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex++;
|
||
}
|
||
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
/** 时间格式化 */
|
||
function formatDate(value?: Date | string | null): string {
|
||
if (value === undefined || value === null || value === "") return "-";
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "-";
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||
}
|
||
|
||
/** 将时间转为可用于排序的时间戳,无效值视为 0(排在最前) */
|
||
function dateValue(value?: Date | string | null): number {
|
||
if (value === undefined || value === null || value === "") return 0;
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
const time = date.getTime();
|
||
return Number.isNaN(time) ? 0 : time;
|
||
}
|
||
|
||
function assignTo<T extends object>(reactiveObj: T, data?: object) {
|
||
Object.keys(reactiveObj as object).forEach(
|
||
(key) => delete (reactiveObj as Record<string, unknown>)[key]
|
||
);
|
||
if (data) {
|
||
Object.assign(reactiveObj, data);
|
||
}
|
||
}
|
||
|
||
/** 首次进入时是否已加载过最后心跳时间 */
|
||
let lastHeartbeatLoaded = false;
|
||
|
||
/** 加载设备头部信息 */
|
||
async function loadDeviceDetail(serialno: string) {
|
||
try {
|
||
const response = await DeviceAPI.getFormData(serialno);
|
||
if (response) {
|
||
Object.assign(deviceData, {
|
||
snName: response.snName,
|
||
onlineStatus: response.online,
|
||
deviceSn: response.serialno || serialno,
|
||
imei: response.snImei,
|
||
department: response.department,
|
||
group: response.group,
|
||
deviceType: response.deviceType,
|
||
source: response.source,
|
||
activationTime: response.activationTime,
|
||
lastOnline: response.lastOnline,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error("加载设备详情失败");
|
||
console.error("Error loading device detail:", error);
|
||
}
|
||
// 加载完设备基础信息后,单独查询设备在线状态
|
||
loadOnlineStatus(serialno);
|
||
// 首次进入时从接口获取最后心跳时间作为最后上线时间
|
||
if (!lastHeartbeatLoaded) {
|
||
loadLastHeartbeat(serialno);
|
||
lastHeartbeatLoaded = true;
|
||
}
|
||
}
|
||
|
||
/** 查询单台设备在线状态(仅更新在线状态和屏幕状态,不更新最后上线时间) */
|
||
async function loadOnlineStatus(serialno: string) {
|
||
try {
|
||
const onlineInfo = await DeviceAPI.getOnlineStatus(serialno);
|
||
if (onlineInfo) {
|
||
deviceData.onlineStatus = onlineInfo.online ?? 0;
|
||
deviceData.screenState = onlineInfo.screenState ?? null;
|
||
}
|
||
} catch (error) {
|
||
console.error("查询设备在线状态失败:", error);
|
||
}
|
||
}
|
||
|
||
/** 查询设备最后心跳时间(仅首次进入时调用,作为最后上线时间) */
|
||
async function loadLastHeartbeat(serialno: string) {
|
||
try {
|
||
const heartbeat = await DeviceAPI.getLastHeartbeat(serialno);
|
||
if (heartbeat?.lastTime) {
|
||
deviceData.lastOnline = heartbeat.lastTime;
|
||
}
|
||
} catch (error) {
|
||
console.error("查询设备最后心跳时间失败:", error);
|
||
}
|
||
}
|
||
|
||
/** 基本信息 */
|
||
async function loadBasicInfo(serialno: string) {
|
||
loading.basic = true;
|
||
try {
|
||
const data = await DeviceAPI.getBasicInfo(serialno);
|
||
assignTo(basicInfo, data);
|
||
} catch (e) {
|
||
console.error("加载基本信息失败", e);
|
||
} finally {
|
||
loading.basic = false;
|
||
}
|
||
}
|
||
|
||
/** 硬件信息 */
|
||
async function loadHardwareInfo(serialno: string) {
|
||
loading.hardware = true;
|
||
try {
|
||
const data = await DeviceAPI.getHardwareInfo(serialno);
|
||
assignTo(hardwareInfo, data);
|
||
} catch (e) {
|
||
console.error("加载硬件信息失败", e);
|
||
} finally {
|
||
loading.hardware = false;
|
||
}
|
||
}
|
||
|
||
/** 网络信息 */
|
||
async function loadNetworkInfo(serialno: string) {
|
||
loading.network = true;
|
||
try {
|
||
const data = await DeviceAPI.getNetworkInfo(serialno);
|
||
assignTo(networkInfo, data);
|
||
// 同步 IMEI 到头部信息
|
||
if (data?.mobileImei) {
|
||
deviceData.imei = data.mobileImei;
|
||
}
|
||
} catch (e) {
|
||
console.error("加载网络信息失败", e);
|
||
} finally {
|
||
loading.network = false;
|
||
}
|
||
}
|
||
|
||
/** 安全信息 */
|
||
async function loadSecurityInfo(serialno: string) {
|
||
loading.security = true;
|
||
try {
|
||
const data = await DeviceAPI.getSecurityInfo(serialno);
|
||
assignTo(securityInfo, data);
|
||
} catch (e) {
|
||
console.error("加载安全信息失败", e);
|
||
} finally {
|
||
loading.security = false;
|
||
}
|
||
}
|
||
|
||
/** 其他信息 */
|
||
async function loadOtherInfo(serialno: string) {
|
||
loading.other = true;
|
||
try {
|
||
const data = await DeviceAPI.getOtherInfo(serialno);
|
||
assignTo(otherInfo, data);
|
||
} catch (e) {
|
||
console.error("加载其他信息失败", e);
|
||
} finally {
|
||
loading.other = false;
|
||
}
|
||
}
|
||
|
||
/** 定位信息 */
|
||
async function loadLocationInfo(serialno: string) {
|
||
locationLoading.value = true;
|
||
try {
|
||
const data = (await DeviceAPI.getLocationInfo(serialno)) || {};
|
||
assignTo(locationInfo, data);
|
||
// 坐标齐全则初始化/刷新地图(地图需等容器渲染完成后执行)
|
||
if (locationInfo.latitude && locationInfo.longitude) {
|
||
await nextTick();
|
||
initBaiduMap(Number(locationInfo.longitude), Number(locationInfo.latitude));
|
||
}
|
||
} catch (e) {
|
||
console.error("加载定位信息失败", e);
|
||
} finally {
|
||
locationLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 刷新定位:先下发定位指令,再拉取最新定位信息 */
|
||
async function handleRefreshLocation() {
|
||
if (!props.serialno) return;
|
||
try {
|
||
await DeviceAPI.deviceLocate(props.serialno);
|
||
ElMessage.success("已下发定位指令,正在获取最新位置");
|
||
// 定位上报有网络延迟,稍作等待后拉取
|
||
setTimeout(() => loadLocationInfo(props.serialno), 1500);
|
||
} catch (e) {
|
||
ElMessage.error("下发定位指令失败");
|
||
console.error("触发定位失败", e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化百度地图并在指定坐标打点
|
||
* 百度地图 JS 通过动态 <script> 引入,AK 取自环境变量 VITE_BAIDU_MAP_AK。
|
||
*
|
||
* @param lng 经度
|
||
* @param lat 纬度
|
||
*/
|
||
async function initBaiduMap(lng: number, lat: number) {
|
||
if (!mapContainer.value) return;
|
||
const ak = import.meta.env.VITE_BAIDU_MAP_AK;
|
||
if (!ak) {
|
||
console.warn("未配置 VITE_BAIDU_MAP_AK,百度地图无法加载");
|
||
return;
|
||
}
|
||
|
||
// 若脚本未加载,先动态引入
|
||
if (!baiduScriptLoaded) {
|
||
await new Promise<void>((resolve, reject) => {
|
||
const script = document.createElement("script");
|
||
script.type = "text/javascript";
|
||
script.src = `https://api.map.baidu.com/api?v=4.0&ak=${ak}&callback=onBaiduMapLoaded`;
|
||
// 全局回调:脚本加载完成后标记就绪
|
||
(window as any).onBaiduMapLoaded = () => {
|
||
baiduScriptLoaded = true;
|
||
resolve();
|
||
};
|
||
script.onerror = () => reject(new Error("百度地图脚本加载失败"));
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
// 首次创建实例,后续仅移动中心点
|
||
if (!baiduMap) {
|
||
baiduMap = new (window as any).BMap.Map(mapContainer.value, {
|
||
center: new (window as any).BMap.Point(lng, lat),
|
||
zoom: 16,
|
||
});
|
||
} else {
|
||
baiduMap.centerAndZoom(new (window as any).BMap.Point(lng, lat), 16);
|
||
}
|
||
|
||
// 清除旧标记后重新打点
|
||
baiduMap.clearOverlays();
|
||
const marker = new (window as any).BMap.Marker(new (window as any).BMap.Point(lng, lat));
|
||
baiduMap.addOverlay(marker);
|
||
const desc = [locationInfo.address, locationInfo.locationDescribe].filter(Boolean).join(" ");
|
||
if (desc) {
|
||
marker.addEventListener("click", () => {
|
||
const info = new (window as any).BMap.InfoWindow(desc, {
|
||
width: 220,
|
||
title: locationInfo.sn || props.serialno,
|
||
});
|
||
baiduMap.openInfoWindow(info, new (window as any).BMap.Point(lng, lat));
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 进入界面时分开加载各面板(含头部) */
|
||
async function loadAll(serialno?: string) {
|
||
if (!serialno) return;
|
||
loadDeviceDetail(serialno);
|
||
loadBasicInfo(serialno);
|
||
loadHardwareInfo(serialno);
|
||
loadNetworkInfo(serialno);
|
||
loadSecurityInfo(serialno);
|
||
loadOtherInfo(serialno);
|
||
loadLocationInfo(serialno);
|
||
loadScreenshots(serialno);
|
||
}
|
||
|
||
/** 设备截图列表 */
|
||
async function loadScreenshots(serialno: string) {
|
||
if (!serialno) return;
|
||
currentSn.value = serialno;
|
||
screenshotLoading.value = true;
|
||
try {
|
||
const data = await DeviceAPI.getScreenshotList(serialno);
|
||
screenshots.value = data || [];
|
||
} catch (e) {
|
||
console.error("加载设备截图失败", e);
|
||
screenshots.value = [];
|
||
} finally {
|
||
screenshotLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 刷新设备截图列表 */
|
||
function refreshScreenshots() {
|
||
loadScreenshots(currentSn.value);
|
||
}
|
||
|
||
/** 点击单张截图,全屏预览(保留当前轮播顺序) */
|
||
function openPreview(index: number) {
|
||
previewIndex.value = index;
|
||
previewVisible.value = true;
|
||
}
|
||
|
||
/** 请求设备截图,下发指令后延迟 5 秒自动刷新一次列表 */
|
||
async function captureScreenshot() {
|
||
if (!currentSn.value) return;
|
||
captureLoading.value = true;
|
||
try {
|
||
await DeviceAPI.deviceScreenshot(currentSn.value);
|
||
ElMessage.success("已下发截图指令,5 秒后自动刷新");
|
||
} catch (e) {
|
||
console.error("下发截图指令失败", e);
|
||
ElMessage.error("下发截图指令失败");
|
||
} finally {
|
||
captureLoading.value = false;
|
||
}
|
||
// 延迟 5 秒自动刷新一次截图列表
|
||
setTimeout(() => {
|
||
loadScreenshots(currentSn.value);
|
||
}, 5000);
|
||
}
|
||
|
||
/** 删除单张截图 */
|
||
async function deleteScreenshotItem(item: DeviceScreenshot) {
|
||
if (!item.id || !currentSn.value) return;
|
||
try {
|
||
await ElMessageBox.confirm("确认删除该截图?", "提示", {
|
||
type: "warning",
|
||
confirmButtonText: "删除",
|
||
cancelButtonText: "取消",
|
||
});
|
||
} catch {
|
||
return; // 用户取消
|
||
}
|
||
deletingId.value = item.id;
|
||
try {
|
||
await DeviceAPI.deleteScreenshot(currentSn.value, item.id);
|
||
ElMessage.success("删除成功");
|
||
await loadScreenshots(currentSn.value);
|
||
} catch (e) {
|
||
console.error("删除截图失败", e);
|
||
ElMessage.error("删除截图失败");
|
||
} finally {
|
||
deletingId.value = null;
|
||
}
|
||
}
|
||
|
||
/** 清空设备全部截图 */
|
||
async function clearScreenshotsFn() {
|
||
if (!currentSn.value || screenshots.value.length === 0) return;
|
||
try {
|
||
await ElMessageBox.confirm("确认清空该设备的全部截图?此操作不可恢复。", "提示", {
|
||
type: "warning",
|
||
confirmButtonText: "清空",
|
||
cancelButtonText: "取消",
|
||
});
|
||
} catch {
|
||
return; // 用户取消
|
||
}
|
||
clearLoading.value = true;
|
||
try {
|
||
const count = await DeviceAPI.clearScreenshots(currentSn.value);
|
||
ElMessage.success(`已清空 ${count} 张截图`);
|
||
await loadScreenshots(currentSn.value);
|
||
} catch (e) {
|
||
console.error("清空截图失败", e);
|
||
ElMessage.error("清空截图失败");
|
||
} finally {
|
||
clearLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 应用信息 */
|
||
async function loadApkInfo(serialno: string) {
|
||
loading.apk = true;
|
||
try {
|
||
const data = await DeviceAPI.getApkList(serialno);
|
||
apkList.value = data || [];
|
||
apkLoaded.value = true;
|
||
} catch (e) {
|
||
console.error("加载应用信息失败", e);
|
||
} finally {
|
||
loading.apk = false;
|
||
}
|
||
}
|
||
|
||
/** 切换标签页懒加载应用信息 / 定位信息(仅首次进入时拉取) */
|
||
watch(activeTab, (tab) => {
|
||
if (tab === "appInfo" && props.serialno && !apkLoaded.value) {
|
||
loadApkInfo(props.serialno);
|
||
}
|
||
if (tab === "locationInfo" && props.serialno) {
|
||
loadLocationInfo(props.serialno);
|
||
}
|
||
});
|
||
|
||
/** 打开应用 */
|
||
const handleLaunchApp = (row: ApkInstallInfo) => {
|
||
ElMessageBox.confirm(`确定要打开应用「${row.appName || row.packageName}」吗?`, "提示", {
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
})
|
||
.then(() => {
|
||
DeviceAPI.launchApp(props.serialno, row.packageName ?? "").then(() => {
|
||
ElMessage.success("打开应用指令已发送");
|
||
});
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
/** 清除应用数据 */
|
||
const handleClearAppData = (row: ApkInstallInfo) => {
|
||
ElMessageBox.confirm(
|
||
`确定要清除应用「${row.appName || row.packageName}」的数据吗?此操作不可逆!`,
|
||
"警告",
|
||
{
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
}
|
||
)
|
||
.then(() => {
|
||
DeviceAPI.clearAppData(props.serialno, row.packageName ?? "").then(() => {
|
||
ElMessage.success("清除应用数据指令已发送");
|
||
});
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
/** 卸载应用 */
|
||
const handleUninstallApp = (row: ApkInstallInfo) => {
|
||
ElMessageBox.confirm(
|
||
`确定要卸载应用「${row.appName || row.packageName}」吗?此操作不可逆!`,
|
||
"警告",
|
||
{
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "error",
|
||
}
|
||
)
|
||
.then(() => {
|
||
DeviceAPI.uninstallApp(props.serialno, row.packageName ?? "").then(() => {
|
||
ElMessage.success("卸载应用指令已发送");
|
||
});
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
/** 停止应用 */
|
||
const handleStopApp = (row: ApkInstallInfo) => {
|
||
ElMessageBox.confirm(`确定要停止运行应用「${row.appName || row.packageName}」吗?`, "提示", {
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
})
|
||
.then(() => {
|
||
DeviceAPI.stopApp(props.serialno, row.packageName ?? "").then(() => {
|
||
ElMessage.success("停止应用指令已发送");
|
||
});
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
defineExpose({ loadAll });
|
||
|
||
/**
|
||
* SSE 设备在线状态事件监听取消函数
|
||
*/
|
||
let unsubscribeDeviceOnlineStatus: (() => void) | null = null;
|
||
|
||
/**
|
||
* 根据 SSE 推送的设备在线状态更新详情页的在线状态
|
||
*/
|
||
function handleDeviceOnlineStatus(data: DeviceOnlineVO) {
|
||
if (!data || !data.serialno) {
|
||
return;
|
||
}
|
||
// 仅当推送的设备 SN 与当前详情页设备匹配时才更新
|
||
if (data.serialno === props.serialno) {
|
||
deviceData.onlineStatus = data.online ?? 0;
|
||
deviceData.screenState = data.screenState ?? null;
|
||
if (data.lastHeartbeatTime) {
|
||
deviceData.lastOnline = data.lastHeartbeatTime;
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 监听 SSE 推送的设备在线状态,实时更新详情页
|
||
const sse = useSse();
|
||
unsubscribeDeviceOnlineStatus = sse.on<DeviceOnlineVO>(
|
||
"device-online-status",
|
||
handleDeviceOnlineStatus
|
||
);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
// 组件销毁时取消 SSE 事件监听
|
||
if (unsubscribeDeviceOnlineStatus) {
|
||
unsubscribeDeviceOnlineStatus();
|
||
unsubscribeDeviceOnlineStatus = null;
|
||
}
|
||
});
|
||
|
||
/** 操作按钮事件 */
|
||
const handleReturn = () => {
|
||
emit("update:modelValue", false);
|
||
emit("returnClick");
|
||
};
|
||
const handleEdit = () => {
|
||
ElMessage.success("编辑功能");
|
||
};
|
||
|
||
const handleRefresh = () => {
|
||
loadAll(props.serialno);
|
||
if (apkLoaded.value && props.serialno) {
|
||
loadApkInfo(props.serialno);
|
||
}
|
||
ElMessage.success("刷新数据成功");
|
||
};
|
||
|
||
const handleRemoteLock = () => {
|
||
ElMessageBox.confirm("确定要远程锁定该设备吗?", "提示", {
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
})
|
||
.then(() => {
|
||
ElMessage.success("远程锁定指令已发送");
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
const handleLostMode = () => {
|
||
ElMessageBox.confirm("确定要开启丢失模式吗?", "提示", {
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
})
|
||
.then(() => {
|
||
ElMessage.success("丢失模式已开启");
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
const handleWipeDevice = () => {
|
||
ElMessageBox.confirm("确定要清除该设备数据吗?此操作不可逆!", "警告", {
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "error",
|
||
})
|
||
.then(() => {
|
||
ElMessage.success("清除设备指令已发送");
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
const handleRestartDevice = () => {
|
||
ElMessageBox.confirm("确定要重启该设备吗?", "提示", {
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
})
|
||
.then(() => {
|
||
ElMessage.success("重启设备指令已发送");
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
const handleSyncDevice = () => {
|
||
ElMessage.success("同步设备指令已发送");
|
||
};
|
||
|
||
const handleMoreActions = (command: string) => {
|
||
switch (command) {
|
||
case "assign":
|
||
ElMessage.success("分配设备功能");
|
||
break;
|
||
case "transfer":
|
||
ElMessage.success("转移设备功能");
|
||
break;
|
||
case "batch":
|
||
ElMessage.success("批量操作功能");
|
||
break;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.device-detail {
|
||
min-height: calc(100vh - 84px);
|
||
padding: 20px;
|
||
background: #f5f7fa;
|
||
|
||
.device-header {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 24px;
|
||
align-items: stretch;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||
|
||
.device-info {
|
||
display: flex;
|
||
flex: 1;
|
||
gap: 20px;
|
||
min-width: 360px;
|
||
|
||
.device-image {
|
||
width: 120px;
|
||
height: 240px;
|
||
object-fit: cover;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.info-content {
|
||
flex: 1;
|
||
|
||
.info-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
|
||
.device-name {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
}
|
||
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 12px;
|
||
|
||
.info-item {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.label {
|
||
min-width: 80px;
|
||
font-size: 13px;
|
||
color: #909399;
|
||
}
|
||
|
||
.value {
|
||
font-size: 13px;
|
||
color: #303133;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
flex-basis: 100%;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #ebeef5;
|
||
|
||
// 按钮统一最小宽度并居中,保证「返回/刷新/编辑」与右侧「远程锁定/丢失模式」等宽度
|
||
.el-button {
|
||
flex-shrink: 0;
|
||
justify-content: center;
|
||
min-width: 96px;
|
||
}
|
||
|
||
.secondary-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin-left: auto;
|
||
|
||
.el-button {
|
||
flex-shrink: 0;
|
||
justify-content: center;
|
||
min-width: 96px;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 设备截图轮播(与 device-info 左右排列)
|
||
.device-screenshots {
|
||
display: flex;
|
||
flex: 0 0 480px;
|
||
flex-direction: column;
|
||
min-width: 320px;
|
||
max-width: 480px;
|
||
padding-left: 24px;
|
||
border-left: 1px solid #ebeef5;
|
||
|
||
.screenshot-title {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
|
||
.refresh-btn {
|
||
margin-left: auto;
|
||
}
|
||
}
|
||
|
||
.screenshot-carousel {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.screenshot-item {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
}
|
||
|
||
.screenshot-image {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 320px;
|
||
}
|
||
|
||
.screenshot-meta {
|
||
padding: 8px 0;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.screenshot-error {
|
||
font-size: 14px;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
// 单张截图右下角删除按钮(正圆形,原生 span 不受 EP 样式干扰)
|
||
.delete-btn {
|
||
position: absolute;
|
||
right: 12px;
|
||
bottom: 12px;
|
||
z-index: 2;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
cursor: pointer;
|
||
background-color: rgba(245, 108, 108, 0.75);
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||
transition: background-color 0.2s;
|
||
|
||
.delete-icon {
|
||
width: 14px;
|
||
height: 14px;
|
||
color: #fff;
|
||
}
|
||
|
||
.delete-icon.is-loading {
|
||
animation: rotate 1s linear infinite;
|
||
}
|
||
|
||
&:hover {
|
||
background-color: rgba(245, 108, 108, 0.9);
|
||
}
|
||
}
|
||
|
||
@keyframes rotate {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 窄屏时上下排列,截图区改为顶部分隔样式
|
||
@media (max-width: 900px) {
|
||
.device-info {
|
||
flex-basis: 100%;
|
||
}
|
||
|
||
.device-screenshots {
|
||
flex: 1 1 100%;
|
||
max-width: 100%;
|
||
padding-top: 16px;
|
||
padding-left: 0;
|
||
border-top: 1px solid #ebeef5;
|
||
border-left: none;
|
||
}
|
||
}
|
||
}
|
||
|
||
.device-sn-detail {
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||
|
||
// 各分面板的描述列表统一对齐:
|
||
// 采用固定表格布局,让「标签列 / 内容列」在所有面板中列宽完全一致。
|
||
// 这样即使某个面板的条目数为奇数(最后一行只有一项)或存在 :span="2" 的跨列项,
|
||
// 该单元格也不会拉伸占满整行,从而避免各面板之间竖向分割线参差不齐。
|
||
:deep(.el-descriptions) {
|
||
--el-descriptions-item-bordered-label-width: 130px;
|
||
|
||
.el-descriptions__body .el-descriptions__table {
|
||
width: 100%;
|
||
table-layout: fixed;
|
||
}
|
||
|
||
// 2 列布局共 4 个单元格:标签 130px / 内容自适应 / 标签 130px / 内容自适应
|
||
.el-descriptions__body .el-descriptions__table .el-descriptions__cell {
|
||
width: calc((100% - 260px) / 2);
|
||
}
|
||
|
||
.el-descriptions__label {
|
||
width: 130px;
|
||
min-width: 130px;
|
||
font-weight: 500;
|
||
vertical-align: middle;
|
||
color: #606266;
|
||
text-align: left;
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
.el-descriptions__content {
|
||
vertical-align: middle;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
|
||
// 折叠面板容器自身不显示外边框,避免与外层卡片圆角/边框重叠被裁切
|
||
:deep(.el-collapse) {
|
||
border-top: none;
|
||
border-bottom: none;
|
||
}
|
||
|
||
// 每个分面板显示为带边框卡片,首项/末项也保留圆角,避免边框被遮
|
||
:deep(.el-collapse-item) {
|
||
overflow: hidden;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 6px;
|
||
|
||
& + .el-collapse-item {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.el-collapse-item__header {
|
||
padding: 0 16px;
|
||
font-weight: 600;
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
.el-collapse-item__wrap {
|
||
border-top: 1px solid #ebeef5;
|
||
}
|
||
}
|
||
|
||
.panel-title {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.tabs-container {
|
||
padding: 20px;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||
|
||
.device-tabs {
|
||
:deep(.el-tabs__header) {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
:deep(.el-tabs__content) {
|
||
padding-top: 0;
|
||
}
|
||
}
|
||
|
||
.tab-content {
|
||
min-height: 120px;
|
||
|
||
.apk-toolbar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.apk-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
|
||
.el-button {
|
||
padding: 4px;
|
||
margin-left: 0;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.el-button .el-icon,
|
||
.el-button svg {
|
||
width: 1.2em;
|
||
height: 1.2em;
|
||
}
|
||
}
|
||
|
||
.apk-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
vertical-align: middle;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.apk-icon-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
margin: 0 auto;
|
||
font-size: 12px;
|
||
color: #c0c4cc;
|
||
background: #f5f7fa;
|
||
border-radius: 6px;
|
||
}
|
||
}
|
||
|
||
.location-tab {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 16px;
|
||
align-items: stretch;
|
||
|
||
> .el-card {
|
||
flex: 1 1 50%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.location-overview__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.location-error {
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.location-map {
|
||
position: relative;
|
||
|
||
.baidu-map {
|
||
width: 100%;
|
||
height: 460px;
|
||
overflow: hidden;
|
||
background: #f5f7fa;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.baidu-map__empty {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.status-dot {
|
||
display: inline-block;
|
||
flex-shrink: 0;
|
||
width: 8px;
|
||
height: 8px;
|
||
margin-right: 4px;
|
||
border-radius: 50%;
|
||
}
|
||
</style>
|