Files
vue3-element-admin/src/components/DeviceSn/DeviceSnDetail.vue
TongTongStudio 9bdbefcfe2 refactor(device): 优化设备详情字段与心跳数据加载逻辑
- 新增设备最后心跳查询接口及类型定义
- 设备在线状态字段统一更名为 online,移除重复字段
- 设备详情首次加载时从心跳接口获取最后上线时间
- WebSocket 推送在线状态时同步更新屏幕状态和最后心跳时间
- 编辑设备弹窗精简为仅可修改昵称,其余字段改为只读展示
2026-08-12 08:28:10 +08:00

1914 lines
62 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>
<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>