feat(device): 添加设备定位信息面板与百度地图集成
- 新增 VITE_BAIDU_MAP_AK 环境变量配置百度地图密钥 - 新增 DeviceLocationInfo 类型定义与 API 调用 - 在设备详情页新增"定位信息"标签页,支持定位概览展示与百度地图可视化 - 支持刷新定位指令下发并拉取最新坐标 - 修复推送 ID 列文本溢出显示省略号 - 优化操作按钮布局,支持换行与统一宽度 - 优化折叠面板卡片样式,增加边框与圆角
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
1
types/env.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user