feat(device): 添加设备定位信息面板与百度地图集成

- 新增 VITE_BAIDU_MAP_AK 环境变量配置百度地图密钥
- 新增 DeviceLocationInfo 类型定义与 API 调用
- 在设备详情页新增"定位信息"标签页,支持定位概览展示与百度地图可视化
- 支持刷新定位指令下发并拉取最新坐标
- 修复推送 ID 列文本溢出显示省略号
- 优化操作按钮布局,支持换行与统一宽度
- 优化折叠面板卡片样式,增加边框与圆角
This commit is contained in:
TongTongStudio
2026-08-05 23:15:04 +08:00
parent af98e7d88d
commit 899574ba3e
6 changed files with 319 additions and 6 deletions

View File

@@ -16,3 +16,8 @@ VITE_MOCK_DEV_SERVER=false
# 多租户开关(true:开启 false:关闭)
VITE_APP_TENANT_ENABLED=false
# 百度地图 JavaScript API v4.0 密钥AK
# 申请地址https://lbsyun.baidu.com/apiconsole/key
# 注意:需在「应用管理-我的应用」中为该 AK 配置「浏览器端」白名单Referer 域名/IP
VITE_BAIDU_MAP_AK=DaKEjkzMRHg87GZgEJqAmxUxcBbJaYvS

View File

@@ -16,6 +16,7 @@ import type {
DeviceOtherInfo,
DeviceSecurityInfo,
ApkInstallInfo,
DeviceLocationInfo,
} from "./types";
import type { ExcelResult, PageResult, OptionItem } from "@/api/common";
@@ -365,6 +366,19 @@ const DeviceAPI = {
});
},
/**
* 获取设备最新定位信息
* 对应后端 GET /{sn}/location (getLocation)
*
* @param sn 设备SN号
*/
getLocationInfo(sn: string) {
return request<any, DeviceLocationInfo>({
url: `${DEVICE_BASE_URL}/${sn}/location`,
method: "get",
});
},
/** 打开应用 */
launchApp(sn: string, packageName: string) {
return request({

View File

@@ -359,3 +359,45 @@ export interface ApkInstallInfo {
/** 应用图标访问地址 */
iconUrl?: string;
}
// ===================== 设备定位信息 =====================
/** 设备定位信息 */
export interface DeviceLocationInfo {
/** 设备序列号 */
sn?: string;
/** 国家 */
country?: string;
/** 国家代码 */
countryCode?: string;
/** 省 */
province?: string;
/** 市 */
city?: string;
/** 城市代码 */
cityCode?: string;
/** 区/县 */
district?: string;
/** 街道 */
street?: string;
/** 门牌号 */
streetNumber?: string;
/** 详细地址 */
address?: string;
/** 区域编码 */
adCode?: string;
/** 镇 */
town?: string;
/** 镇级行政区划编码 */
townCode?: string;
/** 位置描述 */
locationDescribe?: string;
/** 经度 */
longitude?: string;
/** 纬度 */
latitude?: string;
/** 定位失败原因,成功时为 '-' */
mapError?: string;
/** 上次定位成功时间 */
lastSuccessfulTime?: string;
}

View File

@@ -564,8 +564,76 @@
</div>
</el-tab-pane>
<el-tab-pane label="定位信息" name="locationInfo">
<div class="tab-content">
<el-empty description="定位信息加载中..." />
<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>
@@ -599,8 +667,9 @@ import type {
DeviceNetworkInfo,
DeviceOtherInfo,
DeviceSecurityInfo,
DeviceLocationInfo,
} from "@/api/system/device/types";
import { reactive, ref, watch } from "vue";
import { nextTick, reactive, ref, watch } from "vue";
import {
Back,
Edit,
@@ -678,6 +747,16 @@ 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,
@@ -685,6 +764,7 @@ const loading = reactive({
security: false,
other: false,
apk: false,
location: false,
});
/** 设备已安装应用列表 */
@@ -847,6 +927,95 @@ async function loadOtherInfo(serialno: string) {
}
}
/** 定位信息 */
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;
@@ -856,6 +1025,7 @@ async function loadAll(serialno?: string) {
loadNetworkInfo(serialno);
loadSecurityInfo(serialno);
loadOtherInfo(serialno);
loadLocationInfo(serialno);
}
/** 应用信息 */
@@ -872,11 +1042,14 @@ async function loadApkInfo(serialno: string) {
}
}
/** 切换标签页懒加载应用信息(仅首次进入时拉取) */
/** 切换标签页懒加载应用信息 / 定位信息(仅首次进入时拉取) */
watch(activeTab, (tab) => {
if (tab === "appInfo" && props.serialno && !apkLoaded.value) {
loadApkInfo(props.serialno);
}
if (tab === "locationInfo" && props.serialno) {
loadLocationInfo(props.serialno);
}
});
/** 打开应用 */
@@ -1101,16 +1274,31 @@ const handleMoreActions = (command: string) => {
.action-buttons {
display: flex;
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;
}
}
}
}
@@ -1118,7 +1306,9 @@ const handleMoreActions = (command: string) => {
.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);
@@ -1155,11 +1345,31 @@ const handleMoreActions = (command: string) => {
}
}
// 分面板之间保留统一间距,避免参差不齐
// 折叠面板容器自身不显示外边框,避免与外层卡片圆角/边框重叠被裁切
: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 {
@@ -1233,6 +1443,47 @@ const handleMoreActions = (command: string) => {
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%);
}
}
}
}
}
</style>

View File

@@ -62,7 +62,7 @@ const contentConfig: IContentConfig<DeviceQueryParams, DeviceItem> = {
},
{ label: "设备型号", align: "center", prop: "snModel", width: 200 },
{ label: "设备昵称", align: "center", prop: "snName", width: 200 },
{ label: "推送ID", align: "center", prop: "pushId", width: 200 },
{ label: "推送ID", align: "center", prop: "pushId", width: 200, showOverflowTooltip: true },
// { label: "设备 IMEI", align: "center", prop: "snImei", width: 200 },
// { label: "设备版本号", align: "center", prop: "snDispalyId", width: 400 },
// { label: "头像", align: "center", prop: "avatar", templet: "image" },

1
types/env.d.ts vendored
View File

@@ -11,6 +11,7 @@ interface ImportMetaEnv {
readonly VITE_APP_TITLE?: string;
readonly VITE_APP_TENANT_ENABLED?: string;
readonly VITE_MOCK_DEV_SERVER: boolean;
readonly VITE_BAIDU_MAP_AK?: string;
}
interface ImportMeta {