Browse Source

b端国际化 首页

master
hx 2 months ago
parent
commit
91f42f766a
  1. 15
      src/api/device/device.js
  2. 24
      src/components/device/TrajectoryDialog.vue
  3. 4
      src/lang/app-messages.js
  4. 194
      src/lang/dashboard-messages.js
  5. 104
      src/lang/device-flow-messages.js
  6. 273
      src/lang/device-messages.js
  7. 10
      src/lang/index.js
  8. 4
      src/lang/system-messages.js
  9. 4
      src/layout/components/Sidebar/Logo.vue
  10. 706
      src/views/dashboard/index.vue
  11. 24
      src/views/device/device/index.vue
  12. 472
      src/views/device/device/trajectory/index.vue

15
src/api/device/device.js

@ -23,6 +23,14 @@ export function getDeviceTrajectory(id, params) {
})
}
export function listTrajectoryDevices(query) {
return request({
url: '/device/device/trajectory/devices',
method: 'get',
params: query
})
}
export function getDeviceTrajectoryMapConfig() {
return request({
url: '/device/device/trajectory/map-config',
@ -30,6 +38,13 @@ export function getDeviceTrajectoryMapConfig() {
})
}
export function getDashboardOverview() {
return request({
url: '/device/device/dashboard/overview',
method: 'get'
})
}
export function addDevice(data) {
return request({
url: '/device/device',

24
src/components/device/TrajectoryDialog.vue

@ -54,15 +54,17 @@
<div class="trajectory-toolbar">
<span class="page-inline-filter__label">{{ $t("device.trajectory.provider.label") }}</span>
<el-radio-group v-model="mapProvider" size="small" @change="handleProviderChange">
<el-radio-button label="maptiler" :disabled="!hasMaptilerKey">
{{ $t("device.trajectory.provider.maptiler") }}
<el-radio-button label="google" :disabled="!hasGoogleKey">
{{ $t("device.trajectory.provider.google") }}
</el-radio-button>
<el-radio-button label="amap" :disabled="!hasAmapKey">
<el-radio-button label="amap" :disabled="!hasAmapKey">
{{ $t("device.trajectory.provider.amap") }}
</el-radio-button>
<el-radio-button label="google" :disabled="!hasGoogleKey">
{{ $t("device.trajectory.provider.google") }}
<el-radio-button label="maptiler" :disabled="!hasMaptilerKey">
{{ $t("device.trajectory.provider.maptiler") }}
</el-radio-button>
</el-radio-group>
</div>
@ -238,15 +240,17 @@ export default {
await this.renderCurrentProviderMap();
},
resolveDefaultProvider() {
if (this.hasMaptilerKey) {
return "maptiler";
if (this.hasGoogleKey) {
return "google";
}
if (this.hasAmapKey) {
if (this.hasAmapKey) {
return "amap";
}
if (this.hasGoogleKey) {
return "google";
if (this.hasMaptilerKey) {
return "maptiler";
}
return "amap";
},

4
src/lang/app-messages.js

@ -1,9 +1,9 @@
const appMessages = {
"zh-CN": {
app: {
sidebarTitle: "\u5ba2\u6237 GeoTag\u7ba1\u7406\u7cfb\u7edf",
sidebarTitle: "客户 GeoTag管理系统",
headerSearch: {
placeholder: "\u83dc\u5355\u641c\u7d22\uff0c\u652f\u6301\u6807\u9898\u3001URL\u6a21\u7cca\u67e5\u8be2",
placeholder: "菜单搜索,支持标题、URL模糊查询",
},
},
},

194
src/lang/dashboard-messages.js

@ -0,0 +1,194 @@
const dashboardMessages = {
"zh-CN": {
dashboard: {
overview: {
stats: {
total: "设备总数",
enabled: "启用数",
disabled: "禁用数",
claimed: "已认领数",
unclaimed: "未认领数",
},
map: {
title: "设备轨迹点",
serviceLabel: "地图服务",
empty: "暂无设备坐标数据",
},
provider: {
maptiler: "MapTiler",
amap: "高德地图",
google: "谷歌地图",
},
popup: {
device: "设备",
time: "时间",
remark: "备注",
coordinates: "坐标",
},
message: {
mapConfigLoadFailed: "地图配置加载失败",
dataLoadFailed: "设备概览数据加载失败",
mapLoadFailed: "地图加载失败",
amapLoadFailed: "高德地图加载失败",
missingMaptilerKey: "当前企业未配置 MapTiler Key",
missingGoogleKey: "当前企业未配置谷歌地图 Key",
missingAmapKey: "当前企业未配置高德地图 Key",
},
},
},
},
"en-US": {
dashboard: {
overview: {
stats: {
total: "Total Devices",
enabled: "Enabled",
disabled: "Disabled",
claimed: "Claimed",
unclaimed: "Unclaimed",
},
map: {
title: "Device Track Points",
serviceLabel: "Map Service",
empty: "No device coordinate data",
},
provider: {
maptiler: "MapTiler",
amap: "Amap",
google: "Google Maps",
},
popup: {
device: "Device",
time: "Time",
remark: "Remark",
coordinates: "Coordinates",
},
message: {
mapConfigLoadFailed: "Failed to load map configuration",
dataLoadFailed: "Failed to load dashboard overview data",
mapLoadFailed: "Failed to load map",
amapLoadFailed: "Failed to load Amap",
missingMaptilerKey: "MapTiler key is not configured for this business",
missingGoogleKey: "Google Maps key is not configured for this business",
missingAmapKey: "Amap key is not configured for this business",
},
},
},
},
"fr-FR": {
dashboard: {
overview: {
stats: {
total: "Total appareils",
enabled: "Actifs",
disabled: "Desactives",
claimed: "Reclames",
unclaimed: "Non reclames",
},
map: {
title: "Points de trajectoire",
serviceLabel: "Service de carte",
empty: "Aucune coordonnee appareil",
},
provider: {
maptiler: "MapTiler",
amap: "Amap",
google: "Google Maps",
},
popup: {
device: "Appareil",
time: "Heure",
remark: "Remarque",
coordinates: "Coordonnees",
},
message: {
mapConfigLoadFailed: "Echec du chargement de la configuration de carte",
dataLoadFailed: "Echec du chargement des donnees d'apercu",
mapLoadFailed: "Echec du chargement de la carte",
amapLoadFailed: "Echec du chargement d'Amap",
missingMaptilerKey: "La cle MapTiler n'est pas configuree",
missingGoogleKey: "La cle Google Maps n'est pas configuree",
missingAmapKey: "La cle Amap n'est pas configuree",
},
},
},
},
"es-ES": {
dashboard: {
overview: {
stats: {
total: "Total de dispositivos",
enabled: "Habilitados",
disabled: "Deshabilitados",
claimed: "Reclamados",
unclaimed: "No reclamados",
},
map: {
title: "Puntos de trayectoria",
serviceLabel: "Servicio de mapa",
empty: "No hay coordenadas de dispositivos",
},
provider: {
maptiler: "MapTiler",
amap: "Amap",
google: "Google Maps",
},
popup: {
device: "Dispositivo",
time: "Hora",
remark: "Observacion",
coordinates: "Coordenadas",
},
message: {
mapConfigLoadFailed: "No se pudo cargar la configuracion del mapa",
dataLoadFailed: "No se pudo cargar el resumen del panel",
mapLoadFailed: "No se pudo cargar el mapa",
amapLoadFailed: "No se pudo cargar Amap",
missingMaptilerKey: "MapTiler key no esta configurada",
missingGoogleKey: "Google Maps key no esta configurada",
missingAmapKey: "Amap key no esta configurada",
},
},
},
},
"pt-BR": {
dashboard: {
overview: {
stats: {
total: "Total de dispositivos",
enabled: "Ativos",
disabled: "Desativados",
claimed: "Reivindicados",
unclaimed: "Nao reivindicados",
},
map: {
title: "Pontos de trajeto",
serviceLabel: "Servico de mapa",
empty: "Sem dados de coordenadas de dispositivo",
},
provider: {
maptiler: "MapTiler",
amap: "Amap",
google: "Google Maps",
},
popup: {
device: "Dispositivo",
time: "Hora",
remark: "Observacao",
coordinates: "Coordenadas",
},
message: {
mapConfigLoadFailed: "Falha ao carregar configuracao do mapa",
dataLoadFailed: "Falha ao carregar dados de visao geral",
mapLoadFailed: "Falha ao carregar mapa",
amapLoadFailed: "Falha ao carregar Amap",
missingMaptilerKey: "MapTiler key nao configurada",
missingGoogleKey: "Google Maps key nao configurada",
missingAmapKey: "Amap key nao configurada",
},
},
},
},
};
export default dashboardMessages;

104
src/lang/device-flow-messages.js

@ -2,88 +2,88 @@ const deviceFlowMessages = {
"zh-CN": {
device: {
table: {
mac: "\u8bbe\u5907MAC",
mac: "设备MAC",
},
dialog: {
detail: {
id: "\u8bbe\u5907ID",
id: "设备ID",
},
},
claim: {
dialogTitle: "\u8ba4\u9886\u8bbe\u5907",
dialogTitle: "认领设备",
query: {
orderCode: "\u8ba2\u5355\u53f7",
orderCode: "订单号",
},
placeholder: {
orderCode: "\u8bf7\u8f93\u5165\u8ba2\u5355\u53f7",
orderCode: "请输入订单号",
},
tip: "\u8bf7\u6839\u636e\u8ba2\u5355\u53f7\u67e5\u627e\u8bbe\u5907\u5e76\u9009\u62e9\u9700\u8981\u8ba4\u9886\u7684\u8bbe\u5907",
empty: "\u6682\u65e0\u5339\u914d\u7684\u8bbe\u5907\u6570\u636e",
selectedCount: "\u5df2\u9009\u62e9\u8bbe\u5907\uff1a{count} \u53f0",
confirmButton: "\u786e\u8ba4\u8ba4\u9886",
detailTitle: "\u8bbe\u5907\u8be6\u60c5",
tip: "请根据订单号查找设备并选择需要认领的设备",
empty: "暂无匹配的设备数据",
selectedCount: "已选择设备:{count} 台",
confirmButton: "确认认领",
detailTitle: "设备详情",
detail: {
batchNo: "\u6279\u6b21\u53f7",
batchNo: "批次号",
},
message: {
enterOrderCode: "\u8bf7\u5148\u8f93\u5165\u8ba2\u5355\u53f7",
queryFirst: "\u8bf7\u5148\u6839\u636e\u8ba2\u5355\u53f7\u67e5\u8be2\u8bbe\u5907",
selectAtLeastOne: "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u53f0\u8bbe\u5907",
confirmClaim: "\u786e\u8ba4\u8ba4\u9886\u5df2\u9009\u4e2d\u7684 {count} \u53f0\u8bbe\u5907\u5417\uff1f",
claimSuccess: "\u8ba4\u9886\u6210\u529f",
enterOrderCode: "请先输入订单号",
queryFirst: "请先根据订单号查询设备",
selectAtLeastOne: "请至少选择一台设备",
confirmClaim: "确认认领已选中的 {count} 台设备吗?",
claimSuccess: "认领成功",
},
},
trajectory: {
dialogTitle: "\u8bbe\u5907\u8f68\u8ff9",
dialogTitleWithSn: "\u8bbe\u5907\u8f68\u8ff9 - {sn}",
dialogTitle: "设备轨迹",
dialogTitleWithSn: "设备轨迹 - {sn}",
summary: {
id: "\u8bbe\u5907ID",
sn: "\u5e8f\u5217\u53f7",
alias: "\u540d\u79f0",
remark: "\u8bbe\u5907\u5907\u6ce8",
pointCount: "\u5df2\u5c55\u793a\u8f68\u8ff9\u70b9",
totalLimit: "\u5171 {total} \u6761\uff0c\u5f53\u524d\u4ec5\u5c55\u793a\u6700\u8fd1 {count} \u6761",
id: "设备ID",
sn: "序列号",
alias: "名称",
remark: "设备备注",
pointCount: "已展示轨迹点",
totalLimit: "共 {total} 条,当前仅展示最近 {count} 条",
},
filter: {
locationTime: "\u4f4d\u7f6e\u65f6\u95f4",
startPlaceholder: "\u5f00\u59cb\u65f6\u95f4",
endPlaceholder: "\u7ed3\u675f\u65f6\u95f4",
locationTime: "位置时间",
startPlaceholder: "开始时间",
endPlaceholder: "结束时间",
},
tabs: {
map: "\u5730\u56fe\u8f68\u8ff9",
table: "\u8f68\u8ff9\u660e\u7ec6",
map: "地图轨迹",
table: "轨迹明细",
},
provider: {
label: "\u5730\u56fe\u670d\u52a1",
label: "地图服务",
maptiler: "MapTiler",
amap: "\u9ad8\u5fb7\u5730\u56fe",
google: "\u8c37\u6b4c\u5730\u56fe",
amap: "高德地图",
google: "谷歌地图",
},
empty: "\u6682\u65e0\u8f68\u8ff9\u6570\u636e",
empty: "暂无轨迹数据",
table: {
time: "\u4f4d\u7f6e\u65f6\u95f4",
coordinates: "\u7ecf\u7eac\u5ea6",
address: "\u5730\u5740",
battery: "\u7535\u91cf",
time: "位置时间",
coordinates: "经纬度",
address: "地址",
battery: "电量",
},
marker: {
startShort: "\u8d77",
endShort: "\u7ec8",
startShort: "",
endShort: "",
},
message: {
missingDevice: "\u672a\u83b7\u53d6\u5230\u8bbe\u5907\u4fe1\u606f\uff0c\u65e0\u6cd5\u67e5\u770b\u8f68\u8ff9",
missingMapKey: "\u5f53\u524d\u4f01\u4e1a\u672a\u914d\u7f6e\u5730\u56fe Key",
mapConfigLoadFailed: "\u5730\u56fe\u914d\u7f6e\u52a0\u8f7d\u5931\u8d25",
trajectoryLoadFailed: "\u8f68\u8ff9\u52a0\u8f7d\u5931\u8d25",
missingAmapKey: "\u5f53\u524d\u4f01\u4e1a\u672a\u914d\u7f6e\u9ad8\u5fb7\u5730\u56fe Key",
amapLoadFailed: "\u9ad8\u5fb7\u5730\u56fe\u52a0\u8f7d\u5931\u8d25",
missingGoogleKey: "\u5f53\u524d\u4f01\u4e1a\u672a\u914d\u7f6e\u8c37\u6b4c\u5730\u56fe Key",
googleLoadFailed: "\u8c37\u6b4c\u5730\u56fe\u52a0\u8f7d\u5931\u8d25",
missingMaptilerKey: "\u5f53\u524d\u4f01\u4e1a\u672a\u914d\u7f6e MapTiler Key",
maptilerLoadFailed: "MapTiler \u5730\u56fe\u52a0\u8f7d\u5931\u8d25",
amapConvertFailed: "\u9ad8\u5fb7\u5750\u6807\u8f6c\u6362\u5931\u8d25",
startTime: "\u5f00\u59cb\u65f6\u95f4",
endTime: "\u7ed3\u675f\u65f6\u95f4",
missingDevice: "未获取到设备信息,无法查看轨迹",
missingMapKey: "当前企业未配置地图 Key",
mapConfigLoadFailed: "地图配置加载失败",
trajectoryLoadFailed: "轨迹加载失败",
missingAmapKey: "当前企业未配置高德地图 Key",
amapLoadFailed: "高德地图加载失败",
missingGoogleKey: "当前企业未配置谷歌地图 Key",
googleLoadFailed: "谷歌地图加载失败",
missingMaptilerKey: "当前企业未配置 MapTiler Key",
maptilerLoadFailed: "MapTiler 地图加载失败",
amapConvertFailed: "高德坐标转换失败",
startTime: "开始时间",
endTime: "结束时间",
},
},
},

273
src/lang/device-messages.js

@ -1,176 +1,177 @@
const deviceZh = {
query: {
orderCode: "\u8ba2\u5355\u53f7",
model: "\u578b\u53f7",
sn: "\u5e8f\u5217\u53f7",
alias: "\u540d\u79f0",
lastAddress: "\u5730\u5740",
remark: "\u8bbe\u5907\u5907\u6ce8",
activationStatus: "\u662f\u5426\u542f\u7528",
orderCode: "订单号",
model: "型号",
sn: "序列号",
alias: "名称",
lastAddress: "地址",
remark: "设备备注",
activationStatus: "是否启用",
},
placeholder: {
orderCode: "\u8bf7\u8f93\u5165\u8ba2\u5355\u53f7",
model: "\u8bf7\u8f93\u5165\u578b\u53f7",
sn: "\u8bf7\u8f93\u5165\u5e8f\u5217\u53f7",
alias: "\u8bf7\u8f93\u5165\u540d\u79f0",
lastAddress: "\u8bf7\u8f93\u5165\u5730\u5740",
remark: "\u8bf7\u8f93\u5165\u8bbe\u5907\u5907\u6ce8",
activationStatus: "\u8bf7\u9009\u62e9\u542f\u7528\u72b6\u6001",
macAddress: "\u8bf7\u8f93\u5165MAC\u5730\u5740",
privateKey: "\u8bf7\u8f93\u5165\u79c1\u94a5",
batchNo: "\u8bf7\u8f93\u5165\u6240\u5c5e\u6279\u6b21",
hashId: "\u8bf7\u8f93\u5165\u8bbe\u5907\u552f\u4e00\u54c8\u5e0c ID",
bindBusinessId: "\u8bf7\u8f93\u5165\u7ed1\u5b9a\u4f01\u4e1aID",
remarkSimple: "\u8bf7\u8f93\u5165\u5907\u6ce8",
locateUpdateTime: "\u9009\u62e9\u6700\u540e\u4f4d\u7f6e\u66f4\u65b0\u65f6\u95f4",
lastLat: "\u8bf7\u8f93\u5165\u6700\u540e\u7eac\u5ea6",
lastLng: "\u8bf7\u8f93\u5165\u6700\u540e\u7ecf\u5ea6",
battery: "\u8bf7\u8f93\u5165\u7535\u91cf",
lastReportedTime: "\u9009\u62e9\u6700\u540e\u4e0a\u62a5\u65f6\u95f4",
lastLocationTime: "\u9009\u62e9\u6700\u540e\u4f4d\u7f6e\u65f6\u95f4",
createTime: "\u9009\u62e9\u521b\u5efa\u65f6\u95f4",
orderCode: "请输入订单号",
model: "请输入型号",
sn: "请输入序列号",
alias: "请输入名称",
lastAddress: "请输入地址",
remark: "请输入设备备注",
activationStatus: "请选择启用状态",
macAddress: "请输入MAC地址",
privateKey: "请输入私钥",
batchNo: "请输入所属批次",
hashId: "请输入设备唯一哈希 ID",
bindBusinessId: "请输入绑定企业ID",
remarkSimple: "请输入备注",
locateUpdateTime: "选择最后位置更新时间",
lastLat: "请输入最后纬度",
lastLng: "请输入最后经度",
battery: "请输入电量",
lastReportedTime: "选择最后上报时间",
lastLocationTime: "选择最后位置时间",
createTime: "选择创建时间",
},
status: {
enabled: "\u542f\u7528",
disabled: "\u7981\u7528",
all: "全部",
enabled: "启用",
disabled: "禁用",
},
button: {
import: "\u5bfc\u5165",
claim: "\u8ba4\u9886\u8bbe\u5907",
batchEnable: "\u6279\u91cf\u542f\u7528",
batchDisable: "\u6279\u91cf\u7981\u7528",
assign: "\u5206\u914d\u8bbe\u5907",
export: "\u5bfc\u51fa",
detail: "\u8be6\u60c5",
trajectory: "\u8f68\u8ff9",
close: "\u5173\u95ed",
import: "导入",
claim: "认领设备",
batchEnable: "批量启用",
batchDisable: "批量禁用",
assign: "分配设备",
export: "导出",
detail: "详情",
trajectory: "轨迹",
close: "关闭",
},
table: {
orderCode: "\u8ba2\u5355\u53f7",
deviceStatus: "\u8bbe\u5907\u72b6\u6001",
model: "\u578b\u53f7",
sn: "\u5e8f\u5217\u53f7",
alias: "\u540d\u79f0",
lastAddress: "\u5730\u5740",
updateTime: "\u66f4\u65b0\u65f6\u95f4",
coordinates: "\u7ecf\u7eac\u5ea6",
remark: "\u5907\u6ce8",
actions: "\u64cd\u4f5c",
orderCode: "订单号",
deviceStatus: "设备状态",
model: "型号",
sn: "序列号",
alias: "名称",
lastAddress: "地址",
updateTime: "更新时间",
coordinates: "经纬度",
remark: "备注",
actions: "操作",
},
dialog: {
import: {
title: "\u8bbe\u5907Excel\u5bfc\u5165",
fileLabel: "Excel\u6587\u4ef6",
dragText: "\u5c06Excel\u6587\u4ef6\u62d6\u5230\u6b64\u5904\uff0c\u6216",
clickUpload: "\u70b9\u51fb\u4e0a\u4f20",
tip: "\u4ec5\u652f\u6301 .xlsx / .xls \u683c\u5f0f\u6587\u4ef6\uff0c\u4e14\u53ea\u80fd\u4e0a\u4f201\u4e2a\u6587\u4ef6",
templateTitle: "\u5bfc\u5165\u6a21\u677f",
templateDesc: "\u8bf7\u5148\u4e0b\u8f7d\u6a21\u677f\uff0c\u6309\u6a21\u677f\u683c\u5f0f\u586b\u5199\u8bbe\u5907\u5e8f\u5217\u53f7\u540e\u518d\u5bfc\u5165",
downloadTemplate: "\u4e0b\u8f7d\u6a21\u677f",
fieldExample: "\u6a21\u677f\u5b57\u6bb5\uff1aserial_number",
sampleExample: "\u793a\u4f8b\u503c\uff1aSAMPLE_SN_001",
title: "设备Excel导入",
fileLabel: "Excel文件",
dragText: "将Excel文件拖到此处,或",
clickUpload: "点击上传",
tip: "仅支持 .xlsx / .xls 格式文件,且只能上传1个文件",
templateTitle: "导入模板",
templateDesc: "请先下载模板,按模板格式填写设备序列号后再导入",
downloadTemplate: "下载模板",
fieldExample: "模板字段:serial_number",
sampleExample: "示例值:SAMPLE_SN_001",
},
importResult: {
title: "\u5bfc\u5165\u7ed3\u679c",
status: "\u72b6\u6001",
total: "\u603b\u6570",
successCount: "\u6210\u529f\u6570",
failCount: "\u5931\u8d25\u6570",
startTime: "\u5f00\u59cb\u65f6\u95f4",
finishTime: "\u7ed3\u675f\u65f6\u95f4",
requestErrors: "\u8bf7\u6c42\u9519\u8bef",
failDetails: "\u5931\u8d25\u660e\u7ec6",
rowIndex: "\u884c\u53f7",
errorMessage: "\u9519\u8bef\u4fe1\u606f",
title: "导入结果",
status: "状态",
total: "总数",
successCount: "成功数",
failCount: "失败数",
startTime: "开始时间",
finishTime: "结束时间",
requestErrors: "请求错误",
failDetails: "失败明细",
rowIndex: "行号",
errorMessage: "错误信息",
},
assign: {
title: "\u5206\u914d\u8bbe\u5907",
selectedUsers: "\u5df2\u9009\u62e9\u5458\u5de5\uff1a{count} \u4eba",
title: "分配设备",
selectedUsers: "已选择员工:{count} 人",
},
detail: {
title: "\u8bbe\u5907\u8be6\u60c5",
macAddress: "MAC\u5730\u5740",
business: "\u4f01\u4e1a",
lastAddressName: "\u6700\u540e\u5730\u5740\u540d\u79f0",
assignedUsers: "\u5206\u914d\u5458\u5de5",
locateUpdateTime: "\u6700\u540e\u4f4d\u7f6e\u66f4\u65b0\u65f6\u95f4",
lastCoordinates: "\u6700\u540e\u7ecf\u7eac\u5ea6",
battery: "\u7535\u91cf",
lastReportedTime: "\u6700\u540e\u4e0a\u62a5\u65f6\u95f4",
lastLocationTime: "\u6700\u540e\u4f4d\u7f6e\u65f6\u95f4",
title: "设备详情",
macAddress: "MAC地址",
business: "企业",
lastAddressName: "最后地址名称",
assignedUsers: "分配员工",
locateUpdateTime: "最后位置更新时间",
lastCoordinates: "最后经纬度",
battery: "电量",
lastReportedTime: "最后上报时间",
lastLocationTime: "最后位置时间",
},
editInfo: {
title: "\u4fee\u6539\u8bbe\u5907\u4fe1\u606f",
title: "修改设备信息",
},
form: {
addTitle: "\u6dfb\u52a0\u7cfb\u7edf\u8bbe\u5907",
editTitle: "\u4fee\u6539\u7cfb\u7edf\u8bbe\u5907",
addTitle: "添加系统设备",
editTitle: "修改系统设备",
},
},
form: {
privateKey: "\u79c1\u94a5",
batchNo: "\u6240\u5c5e\u6279\u6b21",
hashId: "\u8bbe\u5907\u7684\u552f\u4e00\u54c8\u5e0c ID",
bindBusinessId: "\u7ed1\u5b9a\u4f01\u4e1aID",
locateUpdateTime: "\u6700\u540e\u4f4d\u7f6e\u66f4\u65b0\u65f6\u95f4",
lastLat: "\u6700\u540e\u7eac\u5ea6",
lastLng: "\u6700\u540e\u7ecf\u5ea6",
privateKey: "私钥",
batchNo: "所属批次",
hashId: "设备的唯一哈希 ID",
bindBusinessId: "绑定企业ID",
locateUpdateTime: "最后位置更新时间",
lastLat: "最后纬度",
lastLng: "最后经度",
},
validation: {
aliasMax: "\u540d\u79f0\u957f\u5ea6\u4e0d\u80fd\u8d85\u8fc764\u4e2a\u5b57\u7b26",
remarkMax: "\u8bbe\u5907\u5907\u6ce8\u957f\u5ea6\u4e0d\u80fd\u8d85\u8fc7255\u4e2a\u5b57\u7b26",
fileRequired: "\u8bf7\u9009\u62e9\u8981\u4e0a\u4f20\u7684Excel\u6587\u4ef6",
snRequired: "\u5e8f\u5217\u53f7\u4e0d\u80fd\u4e3a\u7a7a",
macRequired: "MAC\u5730\u5740\u4e0d\u80fd\u4e3a\u7a7a",
orderCodeRequired: "\u8ba2\u5355\u53f7\u4e0d\u80fd\u4e3a\u7a7a",
privateKeyRequired: "\u79c1\u94a5\u4e0d\u80fd\u4e3a\u7a7a",
batchNoRequired: "\u6240\u5c5e\u6279\u6b21\u4e0d\u80fd\u4e3a\u7a7a",
hashIdRequired: "\u8bbe\u5907\u7684\u552f\u4e00\u54c8\u5e0c ID\u4e0d\u80fd\u4e3a\u7a7a",
remarkRequired: "\u5907\u6ce8\u4e0d\u80fd\u4e3a\u7a7a",
aliasMax: "名称长度不能超过64个字符",
remarkMax: "设备备注长度不能超过255个字符",
fileRequired: "请选择要上传的Excel文件",
snRequired: "序列号不能为空",
macRequired: "MAC地址不能为空",
orderCodeRequired: "订单号不能为空",
privateKeyRequired: "私钥不能为空",
batchNoRequired: "所属批次不能为空",
hashIdRequired: "设备的唯一哈希 ID不能为空",
remarkRequired: "备注不能为空",
},
importStatus: {
SUCCESS: "\u5bfc\u5165\u6210\u529f",
PARTIAL_SUCCESS: "\u90e8\u5206\u6210\u529f",
FAILED: "\u5bfc\u5165\u5931\u8d25",
REQUEST_INVALID: "\u8bf7\u6c42\u6821\u9a8c\u5931\u8d25",
UNKNOWN: "\u672a\u77e5\u72b6\u6001",
SUCCESS: "导入成功",
PARTIAL_SUCCESS: "部分成功",
FAILED: "导入失败",
REQUEST_INVALID: "请求校验失败",
UNKNOWN: "未知状态",
},
message: {
selectDeviceForAssign: "\u8bf7\u5148\u52fe\u9009\u9700\u8981\u5206\u914d\u7684\u8bbe\u5907",
selectUserForAssign: "\u8bf7\u9009\u62e9\u5458\u5de5\u8d26\u6237",
invalidUserSelection: "\u672a\u83b7\u53d6\u5230\u6709\u6548\u7684\u5458\u5de5\u8d26\u53f7",
selectDeviceForAssign: "请先勾选需要分配的设备",
selectUserForAssign: "请选择员工账户",
invalidUserSelection: "未获取到有效的员工账号",
confirmAssign:
"\u786e\u8ba4\u5c06\u9009\u4e2d\u7684 {deviceCount} \u53f0\u8bbe\u5907\u5206\u914d\u7ed9 {userCount} \u540d\u5458\u5de5\u5417\uff1f",
assignSuccess: "\u5206\u914d\u6210\u529f",
selectDeviceForEnable: "\u8bf7\u5148\u52fe\u9009\u9700\u8981\u542f\u7528\u7684\u8bbe\u5907",
confirmBatchEnable: "\u786e\u8ba4\u542f\u7528\u9009\u4e2d\u7684 {count} \u53f0\u8bbe\u5907\u5417\uff1f",
batchEnableSuccess: "\u6279\u91cf\u542f\u7528\u6210\u529f",
selectDeviceForDisable: "\u8bf7\u5148\u52fe\u9009\u9700\u8981\u7981\u7528\u7684\u8bbe\u5907",
confirmBatchDisable: "\u786e\u8ba4\u7981\u7528\u9009\u4e2d\u7684 {count} \u53f0\u8bbe\u5907\u5417\uff1f",
batchDisableSuccess: "\u6279\u91cf\u7981\u7528\u6210\u529f",
deviceInfoMissing: "\u672a\u83b7\u53d6\u5230\u8bbe\u5907\u4fe1\u606f",
updateSuccess: "\u4fee\u6539\u6210\u529f",
addSuccess: "\u65b0\u589e\u6210\u529f",
warning: "\u8b66\u544a",
"确认将选中的 {deviceCount} 台设备分配给 {userCount} 名员工吗?",
assignSuccess: "分配成功",
selectDeviceForEnable: "请先勾选需要启用的设备",
confirmBatchEnable: "确认启用选中的 {count} 台设备吗?",
batchEnableSuccess: "批量启用成功",
selectDeviceForDisable: "请先勾选需要禁用的设备",
confirmBatchDisable: "确认禁用选中的 {count} 台设备吗?",
batchDisableSuccess: "批量禁用成功",
deviceInfoMissing: "未获取到设备信息",
updateSuccess: "修改成功",
addSuccess: "新增成功",
warning: "警告",
confirmDelete:
"\u662f\u5426\u786e\u8ba4\u5220\u9664\u7cfb\u7edf\u8bbe\u5907\u4e3b\u7f16\u53f7\u4e3a\"{ids}\"\u7684\u6570\u636e\u9879?",
deleteSuccess: "\u5220\u9664\u6210\u529f",
confirmExport: "\u662f\u5426\u786e\u8ba4\u5bfc\u51fa\u6240\u6709\u7cfb\u7edf\u8bbe\u5907\u4e3b\u6570\u636e\u9879?",
fetchBatchNoFailed: "\u83b7\u53d6\u6279\u6b21\u53f7\u5931\u8d25\uff1a",
apiException: "\u63a5\u53e3\u5f02\u5e38",
"是否确认删除系统设备主编号为\"{ids}\"的数据项?",
deleteSuccess: "删除成功",
confirmExport: "是否确认导出所有系统设备主数据项?",
fetchBatchNoFailed: "获取批次号失败:",
apiException: "接口异常",
exceedFileLimit:
"\u6700\u591a\u53ea\u80fd\u4e0a\u4f201\u4e2aExcel\u6587\u4ef6\uff0c\u5f53\u524d\u5df2\u9009\u62e9 {count} \u4e2a",
"最多只能上传1个Excel文件,当前已选择 {count} 个",
invalidFileType:
"\u53ea\u80fd\u4e0a\u4f20 .xlsx \u6216 .xls \u683c\u5f0f\u7684Excel\u6587\u4ef6\uff01",
"只能上传 .xlsx 或 .xls 格式的Excel文件!",
selectOneExcel:
"\u8bf7\u9009\u62e9\u8981\u4e0a\u4f20\u7684Excel\u6587\u4ef6\uff08\u4ec5\u652f\u63011\u4e2a\u6587\u4ef6\uff09\uff01",
"请选择要上传的Excel文件(仅支持1个文件)!",
removeExtraFiles:
"\u6700\u591a\u53ea\u80fd\u4e0a\u4f201\u4e2aExcel\u6587\u4ef6\uff0c\u8bf7\u5220\u9664\u591a\u4f59\u6587\u4ef6\uff01",
importSuccess: "\u5bfc\u5165\u6210\u529f\uff01",
importFailed: "\u5bfc\u5165\u5931\u8d25\uff1a",
serverException: "\u670d\u52a1\u5668\u5f02\u5e38",
"最多只能上传1个Excel文件,请删除多余文件!",
importSuccess: "导入成功!",
importFailed: "导入失败:",
serverException: "服务器异常",
importStatusSummary:
"{status}\uff0c\u6210\u529f {successCount} \u6761\uff0c\u5931\u8d25 {failCount} \u6761",
"{status},成功 {successCount} 条,失败 {failCount} 条",
},
};
@ -207,6 +208,7 @@ const deviceEn = {
createTime: "Select create time",
},
status: {
all: "All",
enabled: "Enabled",
disabled: "Disabled",
},
@ -376,6 +378,7 @@ const deviceFr = {
createTime: "Selectionner l'heure de creation",
},
status: {
all: "Tous",
enabled: "Active",
disabled: "Desactive",
},
@ -545,6 +548,7 @@ const deviceEs = {
createTime: "Seleccione hora de creacion",
},
status: {
all: "Todos",
enabled: "Habilitado",
disabled: "Deshabilitado",
},
@ -714,6 +718,7 @@ const devicePt = {
createTime: "Selecione a hora de criacao",
},
status: {
all: "Todos",
enabled: "Ativado",
disabled: "Desativado",
},

10
src/lang/index.js

@ -2,6 +2,7 @@ import messages from "./messages";
import deviceMessages from "./device-messages";
import deviceFlowMessages from "./device-flow-messages";
import appMessages from "./app-messages";
import dashboardMessages from "./dashboard-messages";
import systemMessages from "./system-messages";
import systemUserDeviceMessages from "./system-user-device-messages";
import profileMessages from "./profile-messages";
@ -52,6 +53,11 @@ const mergedMessages = mergeLocaleMessages(
profileMessages
);
const mergedMessagesWithDashboard = mergeLocaleMessages(
mergedMessages,
dashboardMessages
);
function getByPath(obj, path) {
if (!obj || !path) {
return undefined;
@ -73,8 +79,8 @@ function formatTemplate(text, params = {}) {
export function t(key, params = {}) {
const currentLanguage = getLanguage() || DEFAULT_LANGUAGE;
const currentMessages = mergedMessages[currentLanguage] || {};
const defaultMessages = mergedMessages[DEFAULT_LANGUAGE] || {};
const currentMessages = mergedMessagesWithDashboard[currentLanguage] || {};
const defaultMessages = mergedMessagesWithDashboard[DEFAULT_LANGUAGE] || {};
const fromCurrent = getByPath(currentMessages, key);
if (fromCurrent !== undefined) {

4
src/lang/system-messages.js

File diff suppressed because one or more lines are too long

4
src/layout/components/Sidebar/Logo.vue

@ -1,4 +1,4 @@
<template>
<template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<transition name="sidebarLogoFade">
<router-link
@ -27,7 +27,7 @@ import logoImg from '@/assets/logo/logo.png'
import variables from '@/assets/styles/variables.scss'
const SIDEBAR_TITLE_MAP = {
'zh-CN': '\u5ba2\u6237 GeoTag\u7ba1\u7406\u7cfb\u7edf',
'zh-CN': '客户 GeoTag管理系统',
'en-US': 'GeoTag Customer Management System',
'fr-FR': 'Systeme de gestion client GeoTag',
'es-ES': 'Sistema de gestion de clientes GeoTag',

706
src/views/dashboard/index.vue

@ -0,0 +1,706 @@
<template>
<div class="app-container dashboard-overview" v-loading="loading">
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-card__label">{{ $t("dashboard.overview.stats.total") }}</div>
<div class="stat-card__value">{{ stats.total }}</div>
</div>
<div class="stat-card stat-card--enabled">
<div class="stat-card__label">{{ $t("dashboard.overview.stats.enabled") }}</div>
<div class="stat-card__value">{{ stats.enabled }}</div>
</div>
<div class="stat-card stat-card--disabled">
<div class="stat-card__label">{{ $t("dashboard.overview.stats.disabled") }}</div>
<div class="stat-card__value">{{ stats.disabled }}</div>
</div>
<div class="stat-card stat-card--claimed">
<div class="stat-card__label">{{ $t("dashboard.overview.stats.claimed") }}</div>
<div class="stat-card__value">{{ stats.claimed }}</div>
</div>
<div class="stat-card stat-card--unclaimed">
<div class="stat-card__label">{{ $t("dashboard.overview.stats.unclaimed") }}</div>
<div class="stat-card__value">{{ stats.unclaimed }}</div>
</div>
</div>
<div class="dashboard-map-panel">
<div class="dashboard-map-panel__header">
<div class="dashboard-map-panel__title">{{ $t("dashboard.overview.map.title") }}</div>
<div class="dashboard-map-panel__toolbar">
<span class="dashboard-map-panel__toolbar-label">{{ $t("dashboard.overview.map.serviceLabel") }}</span>
<el-radio-group v-model="mapProvider" size="small" @change="handleProviderChange">
<el-radio-button label="google" :disabled="!hasGoogleKey">
{{ $t("dashboard.overview.provider.google") }}
</el-radio-button>
<el-radio-button label="amap" :disabled="!hasAmapKey">
{{ $t("dashboard.overview.provider.amap") }}
</el-radio-button>
<el-radio-button label="maptiler" :disabled="!hasMaptilerKey">
{{ $t("dashboard.overview.provider.maptiler") }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="mapLoadError && !loading"
:title="mapLoadError"
type="warning"
show-icon
:closable="false"
class="dashboard-map-panel__alert"
/>
<el-empty
v-if="!devicePoints.length && !loading"
:description="$t('dashboard.overview.map.empty')"
:image-size="84"
/>
<div class="dashboard-map-shell" v-loading="mapLoading" v-show="devicePoints.length">
<div ref="map" class="dashboard-map"></div>
</div>
</div>
</div>
</template>
<script>
import { getDashboardOverview, getDeviceTrajectoryMapConfig } from "@/api/device/device";
import { loadAMap } from "@/utils/loadAMap";
import { loadLeaflet } from "@/utils/loadLeaflet";
const DEFAULT_CENTER = [31.2304, 121.4737];
const AMAP_DEFAULT_CENTER = [121.4737, 31.2304];
const DEFAULT_ZOOM = 4;
const LEAFLET_MAX_ZOOM = 18;
const MAP_POINT_MAX_ZOOM = 14;
const LEAFLET_GOOGLE_TILE_URL = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}";
const LEAFLET_MAPTILER_TILE_URL = "https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=";
function getDefaultStats() {
return {
total: 0,
enabled: 0,
disabled: 0,
claimed: 0,
unclaimed: 0,
};
}
export default {
name: "DashboardOverview",
data() {
return {
loading: false,
mapLoading: false,
mapLoadError: "",
stats: getDefaultStats(),
devicePoints: [],
mapProvider: "amap",
mapConfig: null,
map: null,
mapsApi: null,
mapVendor: "",
mapMarkers: [],
tileLayer: null,
mapInfoWindow: null,
mapReady: false,
resizeTimer: null,
};
},
computed: {
hasAmapKey() {
return !!(this.mapConfig && this.mapConfig.gaodeKey);
},
hasGoogleKey() {
return !!(this.mapConfig && this.mapConfig.googleKey);
},
hasMaptilerKey() {
return !!(this.mapConfig && this.mapConfig.maptilerKey);
},
},
async mounted() {
await this.fetchMapConfig();
this.mapProvider = this.resolveDefaultProvider();
await this.loadDashboardData();
await this.waitLayoutStable();
await this.initCurrentMap();
this.renderMapPoints();
window.addEventListener("resize", this.handleWindowResize);
},
activated() {
this.$nextTick(() => {
this.handleWindowResize();
});
},
deactivated() {
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
},
beforeDestroy() {
window.removeEventListener("resize", this.handleWindowResize);
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
this.destroyMap();
},
methods: {
waitLayoutStable() {
return new Promise((resolve) => {
this.$nextTick(() => {
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve);
});
return;
}
setTimeout(resolve, 34);
});
});
},
handleWindowResize() {
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
}
this.resizeTimer = setTimeout(() => {
this.resizeTimer = null;
if (!this.map || !this.mapReady) {
return;
}
if (this.mapVendor === "leaflet" && typeof this.map.invalidateSize === "function") {
this.map.invalidateSize(true);
} else if (this.mapVendor === "amap" && typeof this.map.resize === "function") {
this.map.resize();
}
this.renderMapPoints();
}, 120);
},
resolveDefaultProvider() {
if (this.hasGoogleKey) {
return "google";
}
if (this.hasAmapKey) {
return "amap";
}
if (this.hasMaptilerKey) {
return "maptiler";
}
return "maptiler";
},
async fetchMapConfig() {
try {
const response = await getDeviceTrajectoryMapConfig();
this.mapConfig = (response && response.data) || {};
} catch (error) {
this.mapConfig = {};
this.$message.warning((error && error.message) || this.$t("dashboard.overview.message.mapConfigLoadFailed"));
}
},
async handleProviderChange() {
await this.initCurrentMap();
this.renderMapPoints();
},
async loadDashboardData() {
this.loading = true;
try {
const response = await getDashboardOverview();
const data = response && response.data ? response.data : {};
this.stats = {
total: Number(data.totalCount) || 0,
enabled: Number(data.enabledCount) || 0,
disabled: Number(data.disabledCount) || 0,
claimed: Number(data.claimedCount) || 0,
unclaimed: Number(data.unclaimedCount) || 0,
};
this.devicePoints = this.buildDevicePoints(data.points);
this.renderMapPoints();
} catch (error) {
this.stats = getDefaultStats();
this.devicePoints = [];
this.clearMapMarkers();
this.$message.error((error && error.message) || this.$t("dashboard.overview.message.dataLoadFailed"));
} finally {
this.loading = false;
}
},
buildDevicePoints(points) {
if (!Array.isArray(points)) {
return [];
}
return points
.map((item) => {
const latNum = this.normalizeCoordinate(item.lastLat, "lat");
const lngNum = this.normalizeCoordinate(item.lastLng, "lng");
return {
id: item.id,
sn: item.sn,
alias: item.alias,
remark: item.remark,
lastLocationTime: item.lastLocationTime,
latNum,
lngNum,
};
})
.filter((item) => item.latNum !== null && item.lngNum !== null);
},
normalizeCoordinate(value, type) {
if (value === undefined || value === null || value === "") {
return null;
}
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
return null;
}
const directLimit = type === "lat" ? 90 : 180;
const normalizedValue =
Math.abs(numberValue) > directLimit ? numberValue / 10000000 : numberValue;
if (Math.abs(normalizedValue) > directLimit) {
return null;
}
return Number(normalizedValue.toFixed(7));
},
formatDateTime(value) {
if (!value) {
return "-";
}
return this.parseTime ? this.parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : String(value);
},
async initCurrentMap() {
this.mapLoadError = "";
if (!this.$refs.map) {
return;
}
if (this.mapProvider === "maptiler") {
await this.initLeafletMap("maptiler");
return;
}
if (this.mapProvider === "google") {
await this.initLeafletMap("google");
return;
}
await this.initAmapMap();
},
async initLeafletMap(provider) {
this.mapLoading = true;
try {
const L = await loadLeaflet();
if (!this.$refs.map) {
return;
}
if (!this.map || this.mapVendor !== "leaflet") {
this.destroyMap();
this.mapsApi = L;
this.map = L.map(this.$refs.map, {
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
zoomControl: true,
});
this.mapVendor = "leaflet";
} else {
this.mapsApi = L;
if (this.tileLayer && typeof this.map.removeLayer === "function") {
this.map.removeLayer(this.tileLayer);
}
}
if (provider === "maptiler" && !this.hasMaptilerKey) {
throw new Error(this.$t("dashboard.overview.message.missingMaptilerKey"));
}
if (provider === "google" && !this.hasGoogleKey) {
throw new Error(this.$t("dashboard.overview.message.missingGoogleKey"));
}
const tileUrl =
provider === "maptiler"
? LEAFLET_MAPTILER_TILE_URL + encodeURIComponent(this.mapConfig.maptilerKey)
: LEAFLET_GOOGLE_TILE_URL;
const tileOptions =
provider === "maptiler"
? {
attribution:
'&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>',
maxZoom: LEAFLET_MAX_ZOOM,
maxNativeZoom: LEAFLET_MAX_ZOOM,
tileSize: 256,
detectRetina: false,
}
: {
attribution: "&copy; Google",
maxZoom: LEAFLET_MAX_ZOOM,
};
this.tileLayer = this.mapsApi.tileLayer(tileUrl, tileOptions);
this.tileLayer.addTo(this.map);
if (typeof this.map.invalidateSize === "function") {
this.map.invalidateSize();
this.$nextTick(() => {
setTimeout(() => {
if (this.map && typeof this.map.invalidateSize === "function") {
this.map.invalidateSize(true);
}
}, 60);
});
}
this.mapReady = true;
this.handleWindowResize();
} catch (error) {
this.mapReady = false;
this.mapLoadError = (error && error.message) || this.$t("dashboard.overview.message.mapLoadFailed");
} finally {
this.mapLoading = false;
}
},
async initAmapMap() {
this.mapLoading = true;
try {
if (!this.hasAmapKey) {
throw new Error(this.$t("dashboard.overview.message.missingAmapKey"));
}
const mapsApi = await loadAMap({
key: this.mapConfig.gaodeKey,
securityJsCode: this.mapConfig.gaodeSecurityKey || "",
plugins: ["AMap.Scale", "AMap.ToolBar"],
});
if (!this.$refs.map) {
return;
}
if (!this.map || this.mapVendor !== "amap") {
this.destroyMap();
this.mapsApi = mapsApi;
this.map = new mapsApi.Map(this.$refs.map, {
center: AMAP_DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
zooms: [3, 20],
});
if (mapsApi.Scale) {
this.map.addControl(new mapsApi.Scale());
}
if (mapsApi.ToolBar) {
this.map.addControl(new mapsApi.ToolBar());
}
this.mapVendor = "amap";
} else {
this.mapsApi = mapsApi;
if (typeof this.map.resize === "function") {
this.map.resize();
}
}
this.mapReady = true;
this.handleWindowResize();
} catch (error) {
this.mapReady = false;
this.mapLoadError = (error && error.message) || this.$t("dashboard.overview.message.amapLoadFailed");
} finally {
this.mapLoading = false;
}
},
renderMapPoints() {
if (!this.mapReady || !this.map || !this.mapsApi) {
return;
}
if (this.mapVendor === "amap") {
this.renderAmapPoints();
return;
}
this.renderLeafletPoints();
},
renderLeafletPoints() {
this.clearMapMarkers();
if (!this.devicePoints.length) {
this.map.setView(DEFAULT_CENTER, DEFAULT_ZOOM);
return;
}
const bounds = [];
this.devicePoints.forEach((point) => {
const marker = this.mapsApi.marker([point.latNum, point.lngNum], {
title: point.alias || point.sn || String(point.id || ""),
});
marker.bindPopup(this.buildPopupContent(point));
marker.addTo(this.map);
this.mapMarkers.push(marker);
bounds.push([point.latNum, point.lngNum]);
});
const latLngBounds = this.mapsApi.latLngBounds(bounds);
this.$nextTick(() => {
if (this.map && typeof this.map.invalidateSize === "function") {
this.map.invalidateSize();
}
if (latLngBounds.isValid()) {
this.map.fitBounds(latLngBounds, {
padding: [30, 30],
maxZoom: MAP_POINT_MAX_ZOOM,
});
}
});
},
renderAmapPoints() {
this.clearMapMarkers();
if (!this.devicePoints.length) {
if (typeof this.map.setZoomAndCenter === "function") {
this.map.setZoomAndCenter(DEFAULT_ZOOM, AMAP_DEFAULT_CENTER);
}
return;
}
this.mapMarkers = this.devicePoints.map((point) => {
const marker = new this.mapsApi.Marker({
position: [point.lngNum, point.latNum],
title: point.alias || point.sn || String(point.id || ""),
});
marker.on("click", () => {
if (!this.mapInfoWindow) {
this.mapInfoWindow = new this.mapsApi.InfoWindow({
offset: new this.mapsApi.Pixel(0, -24),
});
}
this.mapInfoWindow.setContent(this.buildPopupContent(point));
this.mapInfoWindow.open(this.map, [point.lngNum, point.latNum]);
});
return marker;
});
if (this.mapMarkers.length) {
this.map.add(this.mapMarkers);
this.$nextTick(() => {
if (this.map && typeof this.map.setFitView === "function") {
this.map.setFitView(this.mapMarkers, false, [40, 40, 40, 40]);
}
});
}
},
buildPopupContent(point) {
return (
'<div class="dashboard-map-popup">' +
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.device"))}</strong>锛?{this.escapeHtml(
point.alias || point.sn || point.id || "-"
)}</div>` +
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.time"))}</strong>锛?{this.escapeHtml(
this.formatDateTime(point.lastLocationTime)
)}</div>` +
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.remark"))}</strong>锛?{this.escapeHtml(
point.remark || "-"
)}</div>` +
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.coordinates"))}</strong>锛?{this.escapeHtml(
point.latNum
)} / ${this.escapeHtml(point.lngNum)}</div>` +
"</div>"
);
},
escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
},
clearMapMarkers() {
if (!this.map || !Array.isArray(this.mapMarkers) || !this.mapMarkers.length) {
this.mapMarkers = [];
return;
}
if (this.mapVendor === "amap") {
if (this.mapInfoWindow && typeof this.mapInfoWindow.close === "function") {
this.mapInfoWindow.close();
}
this.mapInfoWindow = null;
if (typeof this.map.remove === "function") {
this.map.remove(this.mapMarkers);
}
this.mapMarkers = [];
return;
}
this.mapMarkers.forEach((marker) => {
if (marker && typeof marker.remove === "function") {
marker.remove();
return;
}
if (marker && typeof this.map.removeLayer === "function") {
this.map.removeLayer(marker);
}
});
this.mapMarkers = [];
},
destroyMap() {
this.clearMapMarkers();
if (this.mapVendor === "amap" && this.map && typeof this.map.destroy === "function") {
this.map.destroy();
} else if (this.mapVendor === "leaflet" && this.map && typeof this.map.remove === "function") {
this.map.remove();
} else if (this.$refs.map) {
this.$refs.map.innerHTML = "";
}
this.map = null;
this.mapsApi = null;
this.mapVendor = "";
this.tileLayer = null;
this.mapInfoWindow = null;
this.mapReady = false;
},
},
};
</script>
<style scoped>
.dashboard-overview {
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.stat-card {
padding: 16px;
border-radius: 8px;
background: #f7fbff;
border: 1px solid #e1ebf5;
}
.stat-card__label {
font-size: 13px;
color: #606266;
}
.stat-card__value {
margin-top: 8px;
font-size: 28px;
line-height: 1;
font-weight: 700;
color: #303133;
}
.stat-card--enabled {
background: #f1fbf5;
border-color: #d8f1e2;
}
.stat-card--disabled {
background: #fff6f6;
border-color: #f5dddd;
}
.stat-card--claimed {
background: #f4f8ff;
border-color: #dfe8fa;
}
.stat-card--unclaimed {
background: #fffaf2;
border-color: #f5e8ce;
}
.dashboard-map-panel {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px;
}
.dashboard-map-panel__header {
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.dashboard-map-panel__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.dashboard-map-panel__toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.dashboard-map-panel__toolbar-label {
font-size: 13px;
color: #606266;
}
.dashboard-map-panel__alert {
margin-bottom: 10px;
}
.dashboard-map {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
border: none;
}
.dashboard-map-shell {
position: relative;
width: 100%;
height: 560px;
max-width: 100%;
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ebeef5;
}
:deep(.dashboard-map-popup) {
line-height: 1.8;
font-size: 12px;
color: #303133;
}
:deep(.dashboard-map.leaflet-container) {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
overflow: hidden;
}
:deep(.dashboard-map .leaflet-tile),
:deep(.dashboard-map .leaflet-marker-icon),
:deep(.dashboard-map .leaflet-marker-shadow) {
max-width: none !important;
max-height: none !important;
}
@media (max-width: 1280px) {
.dashboard-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.dashboard-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-map {
height: 100%;
}
.dashboard-map-shell {
height: 420px;
}
}
</style>

24
src/views/device/device/index.vue

@ -35,6 +35,7 @@
<el-form-item :label="$t('device.query.activationStatus')" prop="activationStatus">
<el-select v-model="queryParams.activationStatus" :placeholder="$t('device.placeholder.activationStatus')" clearable size="small" @keyup.enter.native="handleQuery">
<el-option :label="$t('device.status.all')" :value="ACTIVATION_STATUS_ALL" />
<el-option :label="$t('device.status.disabled')" :value="0" />
<el-option :label="$t('device.status.enabled')" :value="1" />
</el-select>
@ -446,6 +447,8 @@ import DeviceClaimDialog from "@/components/device";
import DeviceTrajectoryDialog from "@/components/device/TrajectoryDialog";
import UserSelector from "@/components/user";
const ACTIVATION_STATUS_ALL = "__ALL__";
function getDefaultImportForm() {
return {
file: null,
@ -469,7 +472,7 @@ function getDefaultQueryParams() {
batchNo: null,
hashid: null,
model: null,
activationStatus: undefined,
activationStatus: 1,
bindBusinessId: null,
locateUpdateTime: null,
lastLat: null,
@ -656,6 +659,20 @@ export default {
this.closeBusinessSelectDialogs();
},
methods: {
buildRequestQueryParams() {
const requestQueryParams = {
...this.queryParams
};
if (
requestQueryParams.activationStatus === ACTIVATION_STATUS_ALL ||
requestQueryParams.activationStatus === undefined ||
requestQueryParams.activationStatus === null ||
requestQueryParams.activationStatus === ""
) {
delete requestQueryParams.activationStatus;
}
return requestQueryParams;
},
closeBusinessSelectDialogs() {
this.businessSelectVisible = false;
this.searchBusinessSelectVisible = false;
@ -663,7 +680,8 @@ export default {
/** 查询系统设备主列表 */
getList() {
this.loading = true;
listDevice(this.queryParams)
const requestQueryParams = this.buildRequestQueryParams();
listDevice(requestQueryParams)
.then((response) => {
const data = response.data || {};
this.deviceList = Array.isArray(data.list) ? data.list : [];
@ -1056,7 +1074,7 @@ export default {
},
/** 导出按钮操作 */
handleExport() {
const queryParams = this.queryParams;
const queryParams = this.buildRequestQueryParams();
this.$confirm(this.$t("device.message.confirmExport"), this.$t("device.message.warning"), {
confirmButtonText: this.$t("common.confirm"),
cancelButtonText: this.$t("common.cancel"),

472
src/views/device/device/trajectory/index.vue

@ -0,0 +1,472 @@
<template>
<div class="app-container trajectory-page">
<div class="trajectory-layout">
<section class="panel left">
<div class="panel-title">设备列表</div>
<el-form :model="query" label-position="top" class="query-form" @submit.native.prevent>
<el-form-item label="序列号">
<el-input
v-model="query.sn"
size="small"
clearable
placeholder="请输入序列号"
@keyup.enter.native="searchDevices"
/>
</el-form-item>
<el-form-item label="名称">
<el-input
v-model="query.alias"
size="small"
clearable
placeholder="请输入名称"
@keyup.enter.native="searchDevices"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="query.remark"
size="small"
clearable
placeholder="请输入备注"
@keyup.enter.native="searchDevices"
/>
</el-form-item>
<div class="query-actions">
<el-button type="primary" size="mini" @click="searchDevices">搜索</el-button>
<el-button size="mini" @click="resetDevices">重置</el-button>
</div>
</el-form>
<div class="device-list" v-loading="loadingDevices">
<el-empty v-if="!loadingDevices && !devices.length" description="暂无设备" :image-size="66" />
<el-scrollbar v-else class="device-scroll">
<div
v-for="item in devices"
:key="item.id"
class="device-item"
:class="{ active: currentDevice && currentDevice.id === item.id }"
@click="selectDevice(item)"
>
<div class="device-item__top">
<span>{{ item.alias || item.sn || "-" }}</span>
<el-tag size="mini" :type="item.activationStatus ? 'success' : 'info'">
{{ item.activationStatus ? "启用" : "禁用" }}
</el-tag>
</div>
<div class="device-item__line">SN{{ item.sn || "-" }}</div>
<div class="device-item__line">时间{{ formatTime(item.lastLocationTime) }}</div>
<div class="device-item__line ellipsis">备注{{ item.remark || "-" }}</div>
</div>
</el-scrollbar>
</div>
<div class="panel-pagination" v-show="total > 0">
<pagination
:total="total"
:page.sync="query.pageNum"
:limit.sync="query.pageSize"
layout="prev, pager, next"
:pager-count="5"
:auto-scroll="false"
@pagination="getDevices"
/>
</div>
</section>
<section class="panel center">
<div class="panel-title">轨迹地图</div>
<div class="map-toolbar">
<el-radio-group v-model="provider" size="small" @change="renderMap">
<el-radio-button label="google">谷歌地图</el-radio-button>
<el-radio-button label="amap" :disabled="!hasAmap">高德地图</el-radio-button>
<el-radio-button label="maptiler" :disabled="!hasMaptiler">MapTiler</el-radio-button>
</el-radio-group>
<el-date-picker
v-model="range"
size="small"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
:clearable="false"
class="range"
/>
<el-button type="primary" size="mini" :loading="loadingTrack" @click="loadTrack">查询轨迹</el-button>
</div>
<el-alert v-if="mapError" :title="mapError" type="warning" :closable="false" show-icon class="map-error" />
<div class="map-shell" v-loading="loadingTrack || loadingMap">
<el-empty v-if="!currentDevice" description="请先选择左侧设备" :image-size="84" />
<el-empty v-else-if="!points.length" description="当前范围暂无轨迹" :image-size="84" />
<div v-show="currentDevice && points.length" ref="map" class="map"></div>
</div>
</section>
<section class="panel right">
<div class="panel-title">轨迹详情</div>
<el-empty v-if="!currentDevice" description="请选择设备" :image-size="66" />
<template v-else>
<el-descriptions :column="1" size="mini" border class="detail-box">
<el-descriptions-item label="设备">{{ currentDevice.alias || currentDevice.sn || "-" }}</el-descriptions-item>
<el-descriptions-item label="SN">{{ currentDevice.sn || "-" }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ currentDevice.activationStatus ? "启用" : "禁用" }}</el-descriptions-item>
<el-descriptions-item label="最新地址">{{ currentDevice.lastAddress || "-" }}</el-descriptions-item>
<el-descriptions-item label="最新时间">{{ formatTime(currentDevice.lastLocationTime) }}</el-descriptions-item>
</el-descriptions>
<div class="summary">轨迹点数{{ points.length }} | 开始{{ startTime }} | 结束{{ endTime }}</div>
<el-table :data="points" border size="mini" height="460">
<el-table-column type="index" width="52" />
<el-table-column label="位置时间" min-width="130">
<template slot-scope="scope">{{ pointTime(scope.row) }}</template>
</el-table-column>
<el-table-column label="经纬度" min-width="160">
<template slot-scope="scope">{{ pointCoord(scope.row) }}</template>
</el-table-column>
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="battery" label="电量" width="70" />
</el-table>
</template>
</section>
</div>
</div>
</template>
<script>
import { listTrajectoryDevices, getDeviceTrajectory, getDeviceTrajectoryMapConfig } from "@/api/device/device";
import { loadAMap } from "@/utils/loadAMap";
import { loadLeaflet } from "@/utils/loadLeaflet";
const GOOGLE_TILE_URL = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}";
const MAPTILER_URL = "https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=";
export default {
name: "DeviceTrajectoryPage",
data() {
return {
query: { pageNum: 1, pageSize: 10, sn: null, alias: null, remark: null },
devices: [],
total: 0,
loadingDevices: false,
currentDevice: null,
points: [],
loadingTrack: false,
range: [],
provider: "google",
mapConfig: {},
loadingMap: false,
mapError: "",
map: null,
mapVendor: "",
mapsApi: null,
tileLayer: null,
overlays: []
};
},
computed: {
hasAmap() {
return !!(this.mapConfig && this.mapConfig.gaodeKey);
},
hasMaptiler() {
return !!(this.mapConfig && this.mapConfig.maptilerKey);
},
startTime() {
return this.points.length ? this.pointTime(this.points[this.points.length - 1]) : "-";
},
endTime() {
return this.points.length ? this.pointTime(this.points[0]) : "-";
}
},
async created() {
this.initRange();
await this.fetchMapConfig();
await this.getDevices();
},
beforeDestroy() {
this.destroyMap();
},
methods: {
initRange() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
const format = (date) => this.parseTime(date, "{y}-{m}-{d} {h}:{i}:{s}");
this.range = [format(start), format(end)];
},
async fetchMapConfig() {
try {
const res = await getDeviceTrajectoryMapConfig();
this.mapConfig = (res && res.data) || {};
this.provider = "google";
} catch (e) {
this.mapConfig = {};
}
},
async getDevices() {
this.loadingDevices = true;
try {
const res = await listTrajectoryDevices(this.query);
const data = (res && res.data) || res || {};
this.devices = Array.isArray(data.list) ? data.list : Array.isArray(data.rows) ? data.rows : [];
this.total = Number(data.total || 0);
if (this.devices.length && (!this.currentDevice || !this.devices.find((x) => x.id === this.currentDevice.id))) {
await this.selectDevice(this.devices[0]);
}
} finally {
this.loadingDevices = false;
}
},
searchDevices() {
this.query.pageNum = 1;
this.getDevices();
},
resetDevices() {
this.query = { pageNum: 1, pageSize: 10, sn: null, alias: null, remark: null };
this.getDevices();
},
async selectDevice(item) {
this.currentDevice = item;
await this.loadTrack();
},
async loadTrack() {
if (!this.currentDevice) return;
this.loadingTrack = true;
this.mapError = "";
try {
const res = await getDeviceTrajectory(this.currentDevice.id, {
startLocationTime: this.range[0],
endLocationTime: this.range[1]
});
const source = Array.isArray(res && res.data) ? res.data : [];
this.points = source.map((point) => ({
...point,
latNum: this.norm(point.lat, "lat"),
lngNum: this.norm(point.lng, "lng")
}));
await this.renderMap();
} catch (e) {
this.points = [];
this.mapError = (e && e.message) || "轨迹加载失败";
this.clearOverlays();
} finally {
this.loadingTrack = false;
}
},
async renderMap() {
if (!this.points.length) {
this.clearOverlays();
return;
}
if (this.provider === "amap") {
await this.renderAmap();
return;
}
await this.renderLeaflet(this.provider === "maptiler");
},
async renderLeaflet(useMaptiler) {
this.loadingMap = true;
try {
const L = await loadLeaflet();
if (!this.map || this.mapVendor !== "leaflet") {
this.destroyMap();
this.map = L.map(this.$refs.map, { center: [31.2304, 121.4737], zoom: 5, zoomControl: true });
this.mapVendor = "leaflet";
this.mapsApi = L;
}
this.clearOverlays();
if (this.tileLayer) this.map.removeLayer(this.tileLayer);
const tileUrl = useMaptiler ? MAPTILER_URL + encodeURIComponent(this.mapConfig.maptilerKey || "") : GOOGLE_TILE_URL;
this.tileLayer = L.tileLayer(tileUrl, { maxZoom: 18, detectRetina: false, tileSize: 256 });
this.tileLayer.addTo(this.map);
const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null);
const path = validPoints.map((point) => [point.latNum, point.lngNum]);
if (!path.length) return;
const startPoint = validPoints[validPoints.length - 1];
const endPoint = validPoints[0];
const startTimeText = this.pointTime(startPoint);
const endTimeText = this.pointTime(endPoint);
const line = L.polyline(path, { color: "#1a73e8", weight: 4 }).addTo(this.map);
const startMarker = L.marker([startPoint.latNum, startPoint.lngNum])
.addTo(this.map)
.bindPopup(`起点<br/>${startTimeText}`)
.bindTooltip(`起点 ${startTimeText}`, {
permanent: true,
direction: "top",
offset: [0, -12],
className: "trajectory-point-tooltip"
});
const endMarker = L.marker([endPoint.latNum, endPoint.lngNum])
.addTo(this.map)
.bindPopup(`终点<br/>${endTimeText}`)
.bindTooltip(`终点 ${endTimeText}`, {
permanent: true,
direction: "top",
offset: [0, -12],
className: "trajectory-point-tooltip"
});
this.overlays = [line, startMarker, endMarker];
this.map.fitBounds(L.latLngBounds(path), { padding: [30, 30], maxZoom: 16 });
this.map.invalidateSize(true);
} catch (e) {
this.mapError = (e && e.message) || "地图加载失败";
} finally {
this.loadingMap = false;
}
},
async renderAmap() {
this.loadingMap = true;
try {
if (!this.hasAmap) throw new Error("当前企业未配置高德地图 Key");
const AMap = await loadAMap({
key: this.mapConfig.gaodeKey,
securityJsCode: this.mapConfig.gaodeSecurityKey || "",
plugins: ["AMap.Scale"]
});
if (!this.map || this.mapVendor !== "amap") {
this.destroyMap();
this.map = new AMap.Map(this.$refs.map, { center: [121.4737, 31.2304], zoom: 5, resizeEnable: true });
this.mapVendor = "amap";
this.mapsApi = AMap;
}
this.clearOverlays();
const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null);
const path = validPoints.map((point) => [point.lngNum, point.latNum]);
if (!path.length) return;
const startPoint = validPoints[validPoints.length - 1];
const endPoint = validPoints[0];
const startTimeText = this.pointTime(startPoint);
const endTimeText = this.pointTime(endPoint);
const line = new AMap.Polyline({
path,
strokeColor: "#1a73e8",
strokeOpacity: 0.9,
strokeWeight: 5,
showDir: true
});
const startMarker = new AMap.Marker({
position: [startPoint.lngNum, startPoint.latNum],
label: { content: `起点 ${startTimeText}`, direction: "top" }
});
const endMarker = new AMap.Marker({
position: [endPoint.lngNum, endPoint.latNum],
label: { content: `终点 ${endTimeText}`, direction: "top" }
});
this.map.add([line, startMarker, endMarker]);
this.overlays = [line, startMarker, endMarker];
this.map.setFitView(this.overlays, false, [50, 50, 50, 50]);
} catch (e) {
this.mapError = (e && e.message) || "高德地图加载失败";
} finally {
this.loadingMap = false;
}
},
clearOverlays() {
if (!this.map || !this.overlays.length) return;
if (this.mapVendor === "leaflet") {
this.overlays.forEach((overlay) => this.map.removeLayer(overlay));
} else if (this.mapVendor === "amap") {
this.map.remove(this.overlays);
}
this.overlays = [];
},
destroyMap() {
this.clearOverlays();
if (!this.map) return;
if (this.mapVendor === "leaflet") this.map.remove();
else if (this.mapVendor === "amap") this.map.destroy();
this.map = null;
this.mapVendor = "";
this.mapsApi = null;
this.tileLayer = null;
},
formatTime(value) {
return value ? this.parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-";
},
pointTime(point) {
return this.formatTime(point.locationTime || point.reportedTime || point.createTime);
},
pointCoord(point) {
const lat = point && (point.lat !== undefined ? point.lat : point.latNum);
const lng = point && (point.lng !== undefined ? point.lng : point.lngNum);
return `${this.formatCoordinateValue(lat)} / ${this.formatCoordinateValue(lng)}`;
},
formatCoordinateValue(value) {
if (value === null || value === undefined) return "-";
const strValue = String(value).trim();
if (!strValue || strValue === "-") return "-";
const match = strValue.match(/^([+-]?\d+)(?:\.(\d+))?$/);
if (!match) return "-";
const integerPart = match[1];
const decimalPart = match[2] || "";
const fixedDecimal = decimalPart.slice(0, 2).padEnd(2, "0");
return `${integerPart}.${fixedDecimal}`;
},
norm(value, type) {
if (value === undefined || value === null || value === "") return null;
const num = Number(value);
if (!Number.isFinite(num)) return null;
const max = type === "lat" ? 90 : 180;
const normalized = Math.abs(num) > max ? num / 10000000 : num;
if (Math.abs(normalized) > max) return null;
return Number(normalized.toFixed(7));
}
}
};
</script>
<style scoped>
.trajectory-layout { display: grid; grid-template-columns: 340px minmax(0, 1fr) 380px; gap: 14px; min-height: calc(100vh - 170px); }
.panel { background: #fff; border: 1px solid #e8edf3; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 10px rgba(31,45,61,.05); }
.left { display: flex; flex-direction: column; min-height: 0; }
.panel-title { font-size: 16px; font-weight: 600; color: #1f2d3d; padding: 14px 16px; border-bottom: 1px solid #eef2f7; background: linear-gradient(180deg,#f8fbff 0,#fff 100%); }
.query-form { padding: 12px 14px 0; }
.query-actions { display: flex; gap: 8px; margin-bottom: 10px; }
.device-list { flex: 1; min-height: 240px; padding: 0 10px 10px; overflow: hidden; }
.device-scroll { height: 100%; }
.panel-pagination { flex: 0 0 auto; padding: 8px 10px 10px; border-top: 1px solid #eef2f7; background: #fff; }
.panel-pagination :deep(.pagination-container) { margin-top: 0; }
.panel-pagination :deep(.el-pagination) { display: flex; justify-content: center; }
.device-item { border: 1px solid #eef2f7; border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; cursor: pointer; transition: all .2s ease; }
.device-item:hover { border-color: #c6e2ff; box-shadow: 0 2px 8px rgba(64,158,255,.12); }
.device-item.active { border-color: #409eff; background: #ecf5ff; }
.device-item__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 14px; font-weight: 600; color: #303133; }
.device-item__line { font-size: 12px; line-height: 1.6; color: #606266; }
.ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.map-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid #eef2f7; }
.range { width: 380px; max-width: 100%; }
.map-error { margin: 10px 12px 0; }
.map-shell { position: relative; min-height: 520px; margin: 10px 12px 12px; border: 1px solid #ebeef5; border-radius: 10px; background: #f7f9fc; overflow: hidden; }
.map { position: absolute; inset: 0; width: 100%; height: 100%; }
.detail-box { margin: 12px; }
.summary { margin: 0 12px 12px; font-size: 12px; color: #606266; line-height: 1.6; background: #fafcff; border: 1px solid #e8edf3; border-radius: 8px; padding: 8px 10px; }
.right :deep(.el-table) { margin: 0 12px 12px; }
:deep(.map.leaflet-container) { position: absolute; inset: 0; width: 100% !important; height: 100% !important; overflow: hidden; }
:deep(.map .leaflet-tile),
:deep(.map .leaflet-marker-icon),
:deep(.map .leaflet-marker-shadow) { max-width: none !important; max-height: none !important; }
:deep(.trajectory-point-tooltip) {
background: rgba(32, 45, 64, 0.9);
color: #fff;
border: none;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
padding: 3px 6px;
font-size: 12px;
line-height: 1.3;
}
:deep(.trajectory-point-tooltip:before) { border-top-color: rgba(32, 45, 64, 0.9); }
@media (max-width: 1280px) {
.trajectory-layout { grid-template-columns: 1fr; min-height: auto; }
.left { min-height: 0; }
.device-list { min-height: 320px; }
.map-shell { min-height: 460px; }
}
</style>
Loading…
Cancel
Save