Browse Source

b端国际化 围栏

master
hx 1 month ago
parent
commit
5cec2baec9
  1. BIN
      dist/static/img/profile.5528ca1b.png
  2. BIN
      public/favicon.ico
  3. 20
      src/api/device/deviceLocation.js
  4. BIN
      src/assets/images/profile.png
  5. BIN
      src/assets/logo/logo.png
  6. 384
      src/components/device/TrajectoryDialog.vue
  7. 15
      src/components/user/index.vue
  8. 8
      src/lang/app-messages.js
  9. 39
      src/lang/dashboard-messages.js
  10. 129
      src/lang/device-flow-messages.js
  11. 202
      src/lang/device-messages.js
  12. 16
      src/lang/index.js
  13. 59
      src/lang/messages.js
  14. 15
      src/lang/no-permission-messages.js
  15. 48
      src/lang/profile-messages.js
  16. 2
      src/lang/system-messages.js
  17. 43
      src/lang/system-user-device-messages.js
  18. 2
      src/main.js
  19. 3
      src/utils/language.js
  20. 75
      src/views/device/device/index.vue
  21. 241
      src/views/device/device/trajectory/index.vue
  22. 81
      src/views/login.vue

BIN
dist/static/img/profile.5528ca1b.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
public/favicon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 21 KiB

20
src/api/device/deviceLocation.js

@ -9,6 +9,24 @@ export function listDeviceLocation(query) {
}) })
} }
// Query device history trajectory list (customer side)
export function listHistoryTrajectory(query) {
return request({
url: '/device/deviceLocation/history/list',
method: 'get',
params: query
})
}
// Query device history trajectory detail by sn (customer side)
export function listHistoryTrajectoryDetail(query) {
return request({
url: '/device/deviceLocation/history/detail',
method: 'get',
params: query
})
}
// 查询设备轨迹详细 // 查询设备轨迹详细
export function getDeviceLocation(id) { export function getDeviceLocation(id) {
return request({ return request({
@ -50,4 +68,4 @@ export function exportDeviceLocation(query) {
method: 'get', method: 'get',
params: query params: query
}) })
} }

BIN
src/assets/images/profile.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
src/assets/logo/logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

384
src/components/device/TrajectoryDialog.vue

@ -1,9 +1,10 @@
<template> <template>
<el-dialog <el-dialog
class="trajectory-dialog-wrapper"
:title="dialogTitle" :title="dialogTitle"
:visible.sync="dialogVisible" :visible.sync="dialogVisible"
width="88%" width="94%"
top="4vh" top="1vh"
custom-class="trajectory-dialog" custom-class="trajectory-dialog"
append-to-body append-to-body
@open="handleOpen" @open="handleOpen"
@ -72,8 +73,9 @@
<div <div
class="trajectory-map-shell" class="trajectory-map-shell"
:class="{ 'is-empty': !loading && !mapLoading && !loadError && !trajectoryPoints.length }"
v-loading="loading || mapLoading" v-loading="loading || mapLoading"
:style="{ minHeight: mapPanelHeight + 'px' }" :style="{ height: mapPanelHeight + 'px' }"
> >
<el-empty <el-empty
v-if="!loading && !mapLoading && !loadError && !trajectoryPoints.length" v-if="!loading && !mapLoading && !loadError && !trajectoryPoints.length"
@ -115,7 +117,7 @@
{{ scope.row.address || "-" }} {{ scope.row.address || "-" }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="$t('device.trajectory.table.battery')" prop="battery" width="100" align="center" /> <!-- <el-table-column :label="$t('device.trajectory.table.battery')" prop="battery" width="100" align="center" /> -->
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@ -132,27 +134,27 @@ import { loadAMap } from "@/utils/loadAMap";
import { loadGoogleMaps } from "@/utils/loadGoogleMaps"; import { loadGoogleMaps } from "@/utils/loadGoogleMaps";
import { loadLeaflet } from "@/utils/loadLeaflet"; import { loadLeaflet } from "@/utils/loadLeaflet";
// // Default map centers before trajectory points load.
const AMAP_DEFAULT_CENTER = [121.4737, 31.2304]; const AMAP_DEFAULT_CENTER = [121.4737, 31.2304];
const GOOGLE_DEFAULT_CENTER = { lat: 31.2304, lng: 121.4737 }; const GOOGLE_DEFAULT_CENTER = { lat: 31.2304, lng: 121.4737 };
const LEAFLET_DEFAULT_CENTER = [31.2304, 121.4737]; const LEAFLET_DEFAULT_CENTER = [31.2304, 121.4737];
// / // Detail zoom levels.
const AMAP_FALLBACK_MAX_ZOOM = 17; const AMAP_FALLBACK_MAX_ZOOM = 17;
const GOOGLE_DETAIL_ZOOM = 16; const GOOGLE_DETAIL_ZOOM = 16;
const LEAFLET_MAX_ZOOM = 16; const LEAFLET_MAX_ZOOM = 16;
const LEAFLET_GOOGLE_MAX_ZOOM = 17; const LEAFLET_GOOGLE_MAX_ZOOM = 17;
// Leaflet + Google 线 // Leaflet Google tile and trajectory styles.
const LEAFLET_GOOGLE_TILE_URL = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}"; const LEAFLET_GOOGLE_TILE_URL = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}";
const LEAFLET_LINE_ARROW_MAX = 36; const LEAFLET_LINE_ARROW_MAX = 36;
const LEAFLET_SHOW_LINE_ARROWS = false; const LEAFLET_SHOW_LINE_ARROWS = false;
const LEAFLET_TRAJECTORY_LINE_COLOR = "#1a73e8"; const LEAFLET_TRAJECTORY_LINE_COLOR = "#1a73e8";
// // Coordinate conversion batch size.
const CONVERT_BATCH_SIZE = 500; const CONVERT_BATCH_SIZE = 500;
const TRAJECTORY_MAP_HEIGHT = 560;
// const TRAJECTORY_TABLE_HEIGHT = 520;
export default { export default {
name: "DeviceTrajectoryDialog", name: "DeviceTrajectoryDialog",
@ -185,6 +187,7 @@ export default {
mapConfig: null, mapConfig: null,
locationTimeRange: [], locationTimeRange: [],
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 900, viewportHeight: typeof window !== "undefined" ? window.innerHeight : 900,
dialogWrapperEl: null,
}; };
}, },
computed: { computed: {
@ -212,15 +215,20 @@ export default {
return !!(this.mapConfig && this.mapConfig.maptilerKey); return !!(this.mapConfig && this.mapConfig.maptilerKey);
}, },
mapPanelHeight() { mapPanelHeight() {
return Math.min(460, Math.max(280, this.viewportHeight - 640)); return TRAJECTORY_MAP_HEIGHT;
}, },
tableMaxHeight() { tableMaxHeight() {
return Math.min(420, Math.max(220, this.viewportHeight - 660)); return TRAJECTORY_TABLE_HEIGHT;
}, },
}, },
beforeDestroy() {
this.unbindViewportResize();
this.unbindDialogWrapper();
},
methods: { methods: {
async handleOpen() { async handleOpen() {
this.bindViewportResize(); this.bindViewportResize();
this.bindDialogWrapper();
this.activePanel = "map"; this.activePanel = "map";
this.initDefaultLocationTimeRange(); this.initDefaultLocationTimeRange();
if (!this.device || !this.device.id) { if (!this.device || !this.device.id) {
@ -240,6 +248,7 @@ export default {
}, },
handleClose() { handleClose() {
this.unbindViewportResize(); this.unbindViewportResize();
this.unbindDialogWrapper();
this.destroyMap(true); this.destroyMap(true);
this.activePanel = "map"; this.activePanel = "map";
this.mapProvider = "amap"; this.mapProvider = "amap";
@ -255,6 +264,24 @@ export default {
window.addEventListener("resize", this.handleViewportResize); window.addEventListener("resize", this.handleViewportResize);
} }
}, },
bindDialogWrapper() {
this.$nextTick(() => {
const dialog = document.querySelector(".trajectory-dialog");
const wrapper = dialog && dialog.closest ? dialog.closest(".el-dialog__wrapper") : null;
if (!wrapper) {
return;
}
this.dialogWrapperEl = wrapper;
wrapper.classList.add("trajectory-dialog-wrapper");
});
},
unbindDialogWrapper() {
if (!this.dialogWrapperEl) {
return;
}
this.dialogWrapperEl.classList.remove("trajectory-dialog-wrapper");
this.dialogWrapperEl = null;
},
unbindViewportResize() { unbindViewportResize() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.removeEventListener("resize", this.handleViewportResize); window.removeEventListener("resize", this.handleViewportResize);
@ -286,18 +313,15 @@ export default {
await this.renderCurrentProviderMap(); await this.renderCurrentProviderMap();
}, },
resolveDefaultProvider() { resolveDefaultProvider() {
if (this.hasGoogleKey) { if (this.hasAmapKey) {
return "google";
}
if (this.hasAmapKey) {
return "amap"; return "amap";
} }
if (this.hasMaptilerKey) { if (this.hasMaptilerKey) {
return "maptiler"; return "maptiler";
} }
if (this.hasGoogleKey) {
return "google";
}
return "amap"; return "amap";
}, },
initDefaultLocationTimeRange() { initDefaultLocationTimeRange() {
@ -368,7 +392,7 @@ export default {
}; };
}); });
this.totalTrajectoryCount = normalizedPoints.length; this.totalTrajectoryCount = normalizedPoints.length;
this.trajectoryPoints = normalizedPoints; this.trajectoryPoints = this.sortTrajectoryByTime(normalizedPoints);
} catch (error) { } catch (error) {
this.trajectoryPoints = []; this.trajectoryPoints = [];
this.totalTrajectoryCount = 0; this.totalTrajectoryCount = 0;
@ -522,6 +546,12 @@ export default {
const startItem = mapPoints[0]; const startItem = mapPoints[0];
const endItem = mapPoints[mapPoints.length - 1]; const endItem = mapPoints[mapPoints.length - 1];
this.markers = []; this.markers = [];
mapPoints.forEach((item) => {
const marker = this.createLeafletTrackPointMarker(item);
if (marker) {
this.markers.push(marker);
}
});
if (path.length > 1) { if (path.length > 1) {
const startAngle = this.getLeafletDirectionAngle(path[0], path[1]); const startAngle = this.getLeafletDirectionAngle(path[0], path[1]);
@ -560,8 +590,27 @@ export default {
this.markers.push(startMarker); this.markers.push(startMarker);
} }
const latestPoint = path[path.length - 1]; this.refreshLeafletViewport(path);
this.map.setView(latestPoint, LEAFLET_MAX_ZOOM); },
createLeafletTrackPointMarker(point) {
if (!point || point.latNum === null || point.lngNum === null) {
return null;
}
const marker = this.mapsApi.circleMarker([point.latNum, point.lngNum], {
radius: 4,
color: "#ffffff",
weight: 1,
fillColor: LEAFLET_TRAJECTORY_LINE_COLOR,
fillOpacity: 0.92,
});
marker.addTo(this.map);
marker.bindTooltip(this.escapeHtml(this.getPointTrackTime(point)), {
permanent: true,
direction: "top",
offset: [0, -6],
className: "leaflet-trajectory-point-tooltip",
});
return marker;
}, },
createLeafletDirectionMarker(point, angle, shortLabel, color, timeLabel, timeText) { createLeafletDirectionMarker(point, angle, shortLabel, color, timeLabel, timeText) {
const icon = this.mapsApi.divIcon({ const icon = this.mapsApi.divIcon({
@ -826,7 +875,15 @@ export default {
const startItem = mapPoints[0]; const startItem = mapPoints[0];
const endItem = mapPoints[mapPoints.length - 1]; const endItem = mapPoints[mapPoints.length - 1];
this.markers = [ this.markers = [];
mapPoints.forEach((item) => {
const marker = this.createAmapTrackPointMarker(item);
if (marker) {
this.markers.push(marker);
}
});
this.markers.push(
new this.mapsApi.Marker({ new this.mapsApi.Marker({
position: startPoint, position: startPoint,
content: this.buildAmapMarkerContent( content: this.buildAmapMarkerContent(
@ -835,8 +892,9 @@ export default {
this.getPointTrackTime(startItem) this.getPointTrackTime(startItem)
), ),
offset: new this.mapsApi.Pixel(-44, -52), offset: new this.mapsApi.Pixel(-44, -52),
}), zIndex: 120,
]; })
);
if (path.length > 1) { if (path.length > 1) {
this.markers.push( this.markers.push(
@ -848,6 +906,7 @@ export default {
this.getPointTrackTime(endItem) this.getPointTrackTime(endItem)
), ),
offset: new this.mapsApi.Pixel(-44, -52), offset: new this.mapsApi.Pixel(-44, -52),
zIndex: 120,
}) })
); );
} }
@ -855,13 +914,89 @@ export default {
this.map.add(this.polyline); this.map.add(this.polyline);
this.map.add(this.markers); this.map.add(this.markers);
// this.refreshAmapViewport(path);
if (path.length > 1) { },
this.map.setFitView([this.polyline].concat(this.markers), false, [60, 60, 60, 60]); refreshLeafletViewport(path) {
} else { if (!this.map || this.mapVendor !== "leaflet" || !this.mapsApi || !Array.isArray(path) || !path.length) {
// return;
this.map.setZoomAndCenter(16, startPoint); }
} const applyView = () => {
if (path.length > 1 && typeof this.map.fitBounds === "function") {
const bounds = this.mapsApi.latLngBounds(path);
this.map.fitBounds(bounds, {
padding: [24, 24],
maxZoom: LEAFLET_MAX_ZOOM,
});
} else if (path[0]) {
this.map.setView(path[0], LEAFLET_MAX_ZOOM);
}
};
this.$nextTick(() => {
if (typeof this.map.invalidateSize === "function") {
this.map.invalidateSize();
}
applyView();
[180, 420].forEach((delay) => {
setTimeout(() => {
if (this.mapVendor !== "leaflet" || !this.map || !this.dialogVisible) {
return;
}
if (typeof this.map.invalidateSize === "function") {
this.map.invalidateSize();
}
applyView();
}, delay);
});
});
},
refreshAmapViewport(path) {
if (!this.map || this.mapVendor !== "amap" || !Array.isArray(path) || !path.length) {
return;
}
const applyView = () => {
if (path.length > 1) {
this.map.setFitView([this.polyline].concat(this.markers), false, [60, 60, 60, 60]);
} else {
this.map.setZoomAndCenter(16, path[0]);
}
};
this.$nextTick(() => {
if (typeof this.map.resize === "function") {
this.map.resize();
}
applyView();
setTimeout(() => {
if (this.mapVendor !== "amap" || !this.map || !this.dialogVisible) {
return;
}
if (typeof this.map.resize === "function") {
this.map.resize();
}
applyView();
}, 180);
});
},
createAmapTrackPointMarker(point) {
if (!point) {
return null;
}
const lng = point.amapLng !== undefined ? point.amapLng : point.lngNum;
const lat = point.amapLat !== undefined ? point.amapLat : point.latNum;
if (!Number.isFinite(Number(lng)) || !Number.isFinite(Number(lat))) {
return null;
}
return new this.mapsApi.Marker({
position: [Number(lng), Number(lat)],
content:
'<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">' +
'<span style="width:8px;height:8px;border-radius:999px;background:#1a73e8;border:1px solid #fff;box-shadow:0 0 0 1px rgba(26,115,232,0.32);display:block;"></span>' +
'<span style="padding:1px 6px;border-radius:10px;background:rgba(255,255,255,0.96);border:1px solid #dbeafe;color:#1f2937;font-size:11px;line-height:1.2;white-space:nowrap;">' +
this.escapeHtml(this.getPointTrackTime(point)) +
"</span>" +
"</div>",
offset: new this.mapsApi.Pixel(-20, -20),
zIndex: 60,
});
}, },
renderGoogleTrajectory() { renderGoogleTrajectory() {
if (!this.map || !this.mapsApi || !this.trajectoryPoints.length) { if (!this.map || !this.mapsApi || !this.trajectoryPoints.length) {
@ -905,46 +1040,79 @@ if (path.length > 1) {
const startItem = mapPoints[0]; const startItem = mapPoints[0];
const endItem = mapPoints[mapPoints.length - 1]; const endItem = mapPoints[mapPoints.length - 1];
this.markers = [ this.markers = [];
mapPoints.forEach((item, index) => {
if (index === 0 || index === mapPoints.length - 1) {
return;
}
const pointMarker = this.createGoogleTrackPointMarker(
{ lat: item.latNum, lng: item.lngNum },
this.getPointTrackTime(item)
);
if (pointMarker) {
this.markers.push(pointMarker);
}
});
this.markers.push(
new this.mapsApi.Marker({ new this.mapsApi.Marker({
position: startPoint, position: startPoint,
map: this.map, map: this.map,
label: this.$t("device.trajectory.marker.startShort"), label: {
}), text: `${this.$t("device.trajectory.marker.startShort")} ${this.getPointTrackTime(startItem)}`,
]; color: "#166534",
fontSize: "11px",
fontWeight: "600",
},
})
);
if (path.length > 1) { if (path.length > 1) {
this.markers.push( this.markers.push(
new this.mapsApi.Marker({ new this.mapsApi.Marker({
position: endPoint, position: endPoint,
map: this.map, map: this.map,
label: this.$t("device.trajectory.marker.endShort"), label: {
text: `${this.$t("device.trajectory.marker.endShort")} ${this.getPointTrackTime(endItem)}`,
color: "#b91c1c",
fontSize: "11px",
fontWeight: "600",
},
}) })
); );
} }
const startInfoWindow = this.openGoogleMarkerInfoWindow( if (path.length > 1 && this.mapsApi.LatLngBounds) {
this.markers[0], const bounds = new this.mapsApi.LatLngBounds();
this.$t("device.trajectory.message.startTime"), path.forEach((point) => bounds.extend(point));
this.getPointTrackTime(startItem) this.map.fitBounds(bounds);
); } else {
if (startInfoWindow) { this.map.setCenter(endPoint);
this.markerInfoWindows.push(startInfoWindow); this.map.setZoom(GOOGLE_DETAIL_ZOOM);
} }
},
if (this.markers.length > 1) { createGoogleTrackPointMarker(position, labelText) {
const endInfoWindow = this.openGoogleMarkerInfoWindow( if (!this.mapsApi || !position) {
this.markers[this.markers.length - 1], return null;
this.$t("device.trajectory.message.endTime"),
this.getPointTrackTime(endItem)
);
if (endInfoWindow) {
this.markerInfoWindows.push(endInfoWindow);
}
} }
return new this.mapsApi.Marker({
this.map.setCenter(endPoint); position,
this.map.setZoom(GOOGLE_DETAIL_ZOOM); map: this.map,
icon: {
path: this.mapsApi.SymbolPath.CIRCLE,
scale: 4,
fillColor: LEAFLET_TRAJECTORY_LINE_COLOR,
fillOpacity: 1,
strokeColor: "#ffffff",
strokeWeight: 1,
},
label: {
text: String(labelText || "-"),
color: "#1f2937",
fontSize: "11px",
fontWeight: "500",
},
});
}, },
openGoogleMarkerInfoWindow(marker, title, timeText) { openGoogleMarkerInfoWindow(marker, title, timeText) {
if (!this.mapsApi || !this.map || !marker || !this.mapsApi.InfoWindow) { if (!this.mapsApi || !this.map || !marker || !this.mapsApi.InfoWindow) {
@ -1096,16 +1264,36 @@ if (path.length > 1) {
if (!Array.isArray(this.trajectoryPoints) || !this.trajectoryPoints.length) { if (!Array.isArray(this.trajectoryPoints) || !this.trajectoryPoints.length) {
return []; return [];
} }
const mapPoints = this.trajectoryPoints.filter((item) => item.latNum !== null && item.lngNum !== null); return this.trajectoryPoints.filter((item) => item.latNum !== null && item.lngNum !== null);
if (mapPoints.length < 2) { },
return mapPoints; sortTrajectoryByTime(points) {
} if (!Array.isArray(points) || points.length < 2) {
const firstTime = this.getTrackTimestamp(mapPoints[0]); return Array.isArray(points) ? points.slice() : [];
const lastTime = this.getTrackTimestamp(mapPoints[mapPoints.length - 1]); }
if (firstTime > 0 && lastTime > 0 && firstTime > lastTime) { return points
return mapPoints.slice().reverse(); .map((item, index) => ({
} item,
return mapPoints; index,
timestamp: this.getTrackTimestamp(item),
}))
.sort((a, b) => {
const aHasTime = a.timestamp > 0;
const bHasTime = b.timestamp > 0;
if (aHasTime && bHasTime) {
if (a.timestamp === b.timestamp) {
return a.index - b.index;
}
return a.timestamp - b.timestamp;
}
if (aHasTime) {
return -1;
}
if (bHasTime) {
return 1;
}
return a.index - b.index;
})
.map((entry) => entry.item);
}, },
getTrackTimestamp(point) { getTrackTimestamp(point) {
if (!point) { if (!point) {
@ -1161,16 +1349,21 @@ if (path.length > 1) {
.trajectory-map-shell { .trajectory-map-shell {
position: relative; position: relative;
min-height: 280px;
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #f5f7fa; background: #f5f7fa;
} }
.trajectory-map-shell.is-empty {
display: flex;
align-items: center;
justify-content: center;
}
.trajectory-map { .trajectory-map {
width: 100%; width: 100%;
height: 280px; height: 100%;
} }
.trajectory-map-disclaimer { .trajectory-map-disclaimer {
@ -1189,21 +1382,56 @@ if (path.length > 1) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
} }
.trajectory-dialog { :deep(.leaflet-trajectory-point-tooltip) {
margin-top: 4vh !important; background: rgba(255, 255, 255, 0.96);
margin-bottom: 4vh; border: 1px solid #dbeafe;
max-height: 90vh; border-radius: 10px;
color: #1f2937;
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.15);
} }
.trajectory-dialog .el-dialog__body { :deep(.leaflet-trajectory-point-tooltip::before) {
max-height: calc(90vh - 120px); border-top-color: #dbeafe !important;
overflow: hidden; }
</style>
<style>
.trajectory-dialog-wrapper {
overflow: hidden !important;
}
.trajectory-dialog-wrapper .trajectory-dialog:not(.is-fullscreen) {
margin-top: 10px !important;
margin-bottom: 10px;
height: 900px;
max-height: 900px;
}
.trajectory-dialog-wrapper .trajectory-dialog .el-dialog__body {
max-height: calc(900px - 114px) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
padding-top: 10px; padding-top: 10px;
padding-bottom: 10px; padding-bottom: 10px;
} }
.trajectory-dialog .el-dialog__footer { .trajectory-dialog-wrapper .trajectory-dialog .el-dialog__footer {
padding-top: 10px; padding-top: 10px;
padding-bottom: 14px; padding-bottom: 14px;
} }
@media (max-height: 900px) {
.trajectory-dialog-wrapper .trajectory-dialog:not(.is-fullscreen) {
height: calc(100vh - 20px);
max-height: calc(100vh - 20px);
}
.trajectory-dialog-wrapper .trajectory-dialog .el-dialog__body {
max-height: calc(100vh - 134px) !important;
}
}
</style> </style>

15
src/components/user/index.vue

@ -111,8 +111,9 @@
row-key="id" row-key="id"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
@row-click="handleRowClick" @row-click="handleRowClick"
@row-dblclick="handleRowDblClick"
> >
<el-table-column type="selection" width="50" align="center" :reserve-selection="selectionMode" /> <el-table-column v-if="!pickMode" type="selection" width="50" align="center" :reserve-selection="selectionMode" />
<el-table-column v-if="columns[0].visible" :label="columns[0].label" align="center" prop="id" width="90" /> <el-table-column v-if="columns[0].visible" :label="columns[0].label" align="center" prop="id" width="90" />
<el-table-column v-if="columns[1].visible" :label="columns[1].label" align="center" prop="account" :show-overflow-tooltip="true" /> <el-table-column v-if="columns[1].visible" :label="columns[1].label" align="center" prop="account" :show-overflow-tooltip="true" />
<el-table-column v-if="columns[2].visible" :label="columns[2].label" align="center" prop="nickName" :show-overflow-tooltip="true" /> <el-table-column v-if="columns[2].visible" :label="columns[2].label" align="center" prop="nickName" :show-overflow-tooltip="true" />
@ -270,6 +271,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
pickMode: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@ -415,11 +420,17 @@ export default {
this.multiple = !selection.length; this.multiple = !selection.length;
}, },
handleRowClick(row) { handleRowClick(row) {
if (!this.selectionMode || !this.$refs.userTable) { if (!this.selectionMode || this.pickMode || !this.$refs.userTable) {
return; return;
} }
this.$refs.userTable.toggleRowSelection(row); this.$refs.userTable.toggleRowSelection(row);
}, },
handleRowDblClick(row) {
if (!this.pickMode) {
return;
}
this.$emit("pick", row);
},
clearSelectionState() { clearSelectionState() {
this.selectedRows = []; this.selectedRows = [];
this.selectedUserMap = {}; this.selectedUserMap = {};

8
src/lang/app-messages.js

@ -15,6 +15,14 @@ const appMessages = {
}, },
}, },
}, },
"ru-RU": {
app: {
sidebarTitle: "Система управления клиентами GeoTag",
headerSearch: {
placeholder: "Поиск меню по названию или URL",
},
},
},
"fr-FR": { "fr-FR": {
app: { app: {
sidebarTitle: "Systeme de gestion des clients GeoTag", sidebarTitle: "Systeme de gestion des clients GeoTag",

39
src/lang/dashboard-messages.js

@ -77,6 +77,45 @@ const dashboardMessages = {
} }
} }
}, },
"ru-RU": {
dashboard: {
overview: {
stats: {
total: "Всего устройств",
enabled: "Активные",
disabled: "Отключенные",
claimed: "Закрепленные",
unclaimed: "Не закрепленные"
},
map: {
title: "Точки траектории устройств",
serviceLabel: "Картографический сервис",
empty: "Нет данных о координатах устройств"
},
provider: {
maptiler: "MapTiler",
amap: "Amap",
google: "Google Maps"
},
popup: {
device: "Устройство",
alias: "Название",
time: "Время",
remark: "Примечание",
coordinates: "Координаты"
},
message: {
mapConfigLoadFailed: "Не удалось загрузить настройки карты",
dataLoadFailed: "Не удалось загрузить данные обзора устройств",
mapLoadFailed: "Не удалось загрузить карту",
amapLoadFailed: "Не удалось загрузить Amap",
missingMaptilerKey: "Для текущей компании не настроен ключ MapTiler",
missingGoogleKey: "Для текущей компании не настроен ключ Google Maps",
missingAmapKey: "Для текущей компании не настроен ключ Amap"
}
}
}
},
"fr-FR": { "fr-FR": {
dashboard: { dashboard: {
overview: { overview: {

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

@ -257,6 +257,135 @@ const deviceFlowMessages = {
}, },
}, },
}, },
"ru-RU": {
device: {
table: {
mac: "MAC устройства",
},
dialog: {
detail: {
id: "ID устройства",
},
},
claim: {
dialogTitle: "Закрепление устройства",
query: {
orderCode: "Номер заказа",
},
placeholder: {
orderCode: "Введите номер заказа",
},
tip: "Найдите устройства по номеру заказа и выберите те, которые нужно закрепить.",
empty: "Нет подходящих данных по устройствам",
selectedCount: "Выбрано устройств: {count}",
confirmButton: "Подтвердить закрепление",
detailTitle: "Информация об устройстве",
detail: {
batchNo: "Номер партии",
},
message: {
enterOrderCode: "Сначала введите номер заказа",
queryFirst: "Сначала выполните поиск устройств по номеру заказа",
selectAtLeastOne: "Выберите хотя бы одно устройство",
confirmClaim: "Подтвердить закрепление {count} выбранных устройств?",
claimSuccess: "Устройство успешно закреплено",
},
},
trajectory: {
dialogTitle: "Траектория устройства",
dialogTitleWithSn: "Траектория устройства - {sn}",
summary: {
id: "ID устройства",
sn: "Серийный номер",
alias: "Название",
remark: "Примечание устройства",
pointCount: "Показано точек",
totalLimit: "Всего {total}, сейчас показаны последние {count}",
},
page: {
title: {
deviceList: "Список устройств",
map: "Карта траектории",
detail: "Детали траектории",
},
placeholder: {
sn: "Введите серийный номер",
alias: "Введите название",
remark: "Введите примечание",
activationStatus: "Введите статус устройства"
},
button: {
search: "Поиск",
reset: "Сброс",
queryTrack: "Запросить траекторию",
},
empty: {
deviceList: "Нет устройств",
selectFromList: "Сначала выберите устройство слева",
selectDevice: "Выберите устройство",
},
status: {
enabled: "Активно",
disabled: "Отключено",
},
list: {
sn: "SN",
updateTime: "Время обновления",
remark: "Примечание",
},
detail: {
device: "Устройство",
status: "Статус",
latestAddress: "Последний адрес",
latestTime: "Последнее время",
},
summary: "Точки траектории: {count} | Начало: {start} | Конец: {end}",
},
filter: {
locationTime: "Время позиции",
startPlaceholder: "Время начала",
endPlaceholder: "Время окончания",
},
tabs: {
map: "Траектория на карте",
table: "Детали траектории",
},
provider: {
label: "Картографический сервис",
maptiler: "MapTiler",
amap: "Amap",
google: "Google Maps",
},
empty: "Нет данных траектории",
table: {
time: "Время позиции",
coordinates: "Координаты",
address: "Адрес",
battery: "Заряд",
},
marker: {
startShort: "Н",
endShort: "К",
},
message: {
missingDevice: "Информация об устройстве недоступна, траекторию нельзя отобразить",
missingMapKey: "Для текущей компании не настроен ключ карты",
mapConfigLoadFailed: "Не удалось загрузить настройки карты",
trajectoryLoadFailed: "Не удалось загрузить траекторию",
missingAmapKey: "Для текущей компании не настроен ключ Amap",
amapLoadFailed: "Не удалось загрузить Amap",
missingGoogleKey: "Для текущей компании не настроен ключ Google Maps",
googleLoadFailed: "Не удалось загрузить Google Maps",
missingMaptilerKey: "Для текущей компании не настроен ключ MapTiler",
maptilerLoadFailed: "Не удалось загрузить карту MapTiler",
amapConvertFailed: "Не удалось преобразовать координаты Amap",
startTime: "Время начала",
endTime: "Время окончания",
disclaimer: "Данные не являются данными реального времени и приведены только для справки",
},
},
},
},
"fr-FR": { "fr-FR": {
device: { device: {
table: { table: {

202
src/lang/device-messages.js

@ -6,6 +6,7 @@ const deviceZh = {
alias: "名称", alias: "名称",
lastAddress: "地址", lastAddress: "地址",
remark: "设备备注", remark: "设备备注",
employee: "员工",
activationStatus: "是否启用", activationStatus: "是否启用",
}, },
placeholder: { placeholder: {
@ -15,6 +16,7 @@ const deviceZh = {
alias: "请输入名称", alias: "请输入名称",
lastAddress: "请输入地址", lastAddress: "请输入地址",
remark: "请输入设备备注", remark: "请输入设备备注",
employee: "请选择员工",
activationStatus: "请选择启用状态", activationStatus: "请选择启用状态",
macAddress: "请输入MAC地址", macAddress: "请输入MAC地址",
privateKey: "请输入私钥", privateKey: "请输入私钥",
@ -89,6 +91,9 @@ const deviceZh = {
title: "分配设备", title: "分配设备",
selectedUsers: "已选择员工:{count} 人", selectedUsers: "已选择员工:{count} 人",
}, },
employeeSelector: {
title: "选择员工",
},
detail: { detail: {
title: "设备详情", title: "设备详情",
macAddress: "MAC地址", macAddress: "MAC地址",
@ -184,6 +189,7 @@ const deviceEn = {
alias: "Name", alias: "Name",
lastAddress: "Address", lastAddress: "Address",
remark: "Remark", remark: "Remark",
employee: "Employee",
activationStatus: "Activation", activationStatus: "Activation",
}, },
placeholder: { placeholder: {
@ -193,6 +199,7 @@ const deviceEn = {
alias: "Enter name", alias: "Enter name",
lastAddress: "Enter address", lastAddress: "Enter address",
remark: "Enter device remark", remark: "Enter device remark",
employee: "Select employee",
activationStatus: "Select activation status", activationStatus: "Select activation status",
macAddress: "Enter MAC address", macAddress: "Enter MAC address",
privateKey: "Enter private key", privateKey: "Enter private key",
@ -267,6 +274,9 @@ const deviceEn = {
title: "Assign Device", title: "Assign Device",
selectedUsers: "Selected employees: {count}", selectedUsers: "Selected employees: {count}",
}, },
employeeSelector: {
title: "Select Employee",
},
detail: { detail: {
title: "Device Detail", title: "Device Detail",
macAddress: "MAC Address", macAddress: "MAC Address",
@ -347,6 +357,182 @@ const deviceEn = {
}, },
}; };
const deviceRu = {
query: {
orderCode: "Номер заказа",
model: "Модель",
sn: "Серийный номер",
alias: "Название",
lastAddress: "Адрес",
remark: "Примечание",
employee: "Сотрудник",
activationStatus: "Активация",
},
placeholder: {
orderCode: "Введите номер заказа",
model: "Введите модель",
sn: "Введите серийный номер",
alias: "Введите название",
lastAddress: "Введите адрес",
remark: "Введите примечание к устройству",
employee: "Выберите сотрудника",
activationStatus: "Выберите статус активации",
macAddress: "Введите MAC-адрес",
privateKey: "Введите приватный ключ",
batchNo: "Введите номер партии",
hashId: "Введите уникальный hash ID",
bindBusinessId: "Введите ID компании",
remarkSimple: "Введите примечание",
locateUpdateTime: "Выберите время обновления позиции",
lastLat: "Введите последнюю широту",
lastLng: "Введите последнюю долготу",
battery: "Введите уровень заряда",
lastReportedTime: "Выберите время последнего отчета",
lastLocationTime: "Выберите время последней позиции",
createTime: "Выберите время создания",
},
status: {
all: "Все",
enabled: "Активно",
disabled: "Отключено",
},
button: {
import: "Импорт",
claim: "Закрепить устройство",
batchEnable: "Массовое включение",
batchDisable: "Массовое отключение",
assign: "Назначить устройство",
export: "Экспорт",
detail: "Детали",
trajectory: "Траектория",
close: "Закрыть",
},
table: {
orderCode: "Номер заказа",
deviceStatus: "Статус устройства",
activationStatusTime: "Время включения/отключения",
model: "Модель",
sn: "Серийный номер",
alias: "Название",
lastAddress: "Адрес",
updateTime: "Время обновления",
coordinates: "Координаты",
remark: "Примечание",
actions: "Действия",
},
dialog: {
import: {
title: "Импорт устройств из Excel",
fileLabel: "Файл Excel",
dragText: "Перетащите файл Excel сюда или ",
clickUpload: "нажмите для загрузки",
tip: "Поддерживаются только файлы .xlsx/.xls и только один файл за раз",
templateTitle: "Шаблон импорта",
templateDesc: "Сначала скачайте шаблон, заполните серийные номера устройств по шаблону и затем импортируйте файл.",
downloadTemplate: "Скачать шаблон",
fieldExample: "Поле шаблона: serial_number",
sampleExample: "Пример значения: SAMPLE_SN_001",
},
importResult: {
title: "Результат импорта",
status: "Статус",
total: "Всего",
successCount: "Успешно",
failCount: "Ошибки",
startTime: "Время начала",
finishTime: "Время завершения",
requestErrors: "Ошибки запроса",
failDetails: "Детали ошибок",
rowIndex: "Строка",
errorMessage: "Сообщение об ошибке",
},
assign: {
title: "Назначение устройств",
selectedUsers: "Выбрано сотрудников: {count}",
},
employeeSelector: {
title: "Выбор сотрудника",
},
detail: {
title: "Информация об устройстве",
macAddress: "MAC-адрес",
business: "Компания",
lastAddressName: "Последний адрес",
assignedUsers: "Назначенные сотрудники",
locateUpdateTime: "Последнее обновление позиции",
lastCoordinates: "Последние координаты",
battery: "Заряд",
lastReportedTime: "Последнее время отчета",
lastLocationTime: "Последнее время позиции",
},
editInfo: {
title: "Изменение данных устройства",
},
form: {
addTitle: "Добавить устройство",
editTitle: "Изменить устройство",
},
},
form: {
privateKey: "Приватный ключ",
batchNo: "Номер партии",
hashId: "Уникальный hash ID",
bindBusinessId: "ID компании",
locateUpdateTime: "Время обновления позиции",
lastLat: "Последняя широта",
lastLng: "Последняя долгота",
},
validation: {
aliasMax: "Длина названия не должна превышать 64 символа",
remarkMax: "Длина примечания не должна превышать 255 символов",
fileRequired: "Выберите файл Excel",
snRequired: "Серийный номер обязателен",
macRequired: "MAC-адрес обязателен",
orderCodeRequired: "Номер заказа обязателен",
privateKeyRequired: "Приватный ключ обязателен",
batchNoRequired: "Номер партии обязателен",
hashIdRequired: "Уникальный hash ID обязателен",
remarkRequired: "Примечание обязательно",
},
importStatus: {
SUCCESS: "Импорт выполнен",
PARTIAL_SUCCESS: "Частичный успех",
FAILED: "Импорт не выполнен",
REQUEST_INVALID: "Некорректный запрос",
UNKNOWN: "Неизвестный статус",
},
message: {
selectDeviceForAssign: "Сначала выберите устройства для назначения",
selectUserForAssign: "Выберите учетные записи сотрудников",
invalidUserSelection: "Не найдено ни одной корректной учетной записи сотрудника",
confirmAssign: "Назначить {deviceCount} устройств {userCount} сотрудникам?",
assignSuccess: "Назначение выполнено успешно",
selectDeviceForEnable: "Сначала выберите устройства для включения",
confirmBatchEnable: "Включить {count} выбранных устройств?",
batchEnableSuccess: "Массовое включение выполнено успешно",
selectDeviceForDisable: "Сначала выберите устройства для отключения",
confirmBatchDisable: "Отключить {count} выбранных устройств?",
batchDisableSuccess: "Массовое отключение выполнено успешно",
deviceInfoMissing: "Информация об устройстве не найдена",
updateSuccess: "Обновление выполнено успешно",
addSuccess: "Добавление выполнено успешно",
warning: "Предупреждение",
confirmDelete: "Удалить запись устройства с ID \"{ids}\"?",
deleteSuccess: "Удаление выполнено успешно",
confirmExport: "Экспортировать все данные устройств?",
fetchBatchNoFailed: "Не удалось получить номер партии: ",
apiException: "Исключение API",
exceedFileLimit: "Разрешен только один файл Excel, сейчас выбрано: {count}",
invalidFileType: "Разрешены только файлы .xlsx или .xls",
selectOneExcel: "Выберите один файл Excel",
removeExtraFiles: "Разрешен только один файл Excel, удалите лишние файлы",
importSuccess: "Импорт выполнен успешно",
importFailed: "Ошибка импорта: ",
serverException: "Ошибка сервера",
importStatusSummary: "{status}, успешно {successCount}, ошибок {failCount}",
},
};
const deviceFr = { const deviceFr = {
query: { query: {
orderCode: "Numero de commande", orderCode: "Numero de commande",
@ -355,6 +541,7 @@ const deviceFr = {
alias: "Nom", alias: "Nom",
lastAddress: "Adresse", lastAddress: "Adresse",
remark: "Remarque appareil", remark: "Remarque appareil",
employee: "Employe",
activationStatus: "Etat d'activation", activationStatus: "Etat d'activation",
}, },
placeholder: { placeholder: {
@ -364,6 +551,7 @@ const deviceFr = {
alias: "Saisir le nom", alias: "Saisir le nom",
lastAddress: "Saisir l'adresse", lastAddress: "Saisir l'adresse",
remark: "Saisir la remarque appareil", remark: "Saisir la remarque appareil",
employee: "Selectionner un employe",
activationStatus: "Selectionner l'etat d'activation", activationStatus: "Selectionner l'etat d'activation",
macAddress: "Saisir l'adresse MAC", macAddress: "Saisir l'adresse MAC",
privateKey: "Saisir la cle privee", privateKey: "Saisir la cle privee",
@ -438,6 +626,9 @@ const deviceFr = {
title: "Affecter appareil", title: "Affecter appareil",
selectedUsers: "Employes selectionnes : {count}", selectedUsers: "Employes selectionnes : {count}",
}, },
employeeSelector: {
title: "Selectionner un employe",
},
detail: { detail: {
title: "Detail appareil", title: "Detail appareil",
macAddress: "Adresse MAC", macAddress: "Adresse MAC",
@ -526,6 +717,7 @@ const deviceEs = {
alias: "Nombre", alias: "Nombre",
lastAddress: "Direccion", lastAddress: "Direccion",
remark: "Observacion del dispositivo", remark: "Observacion del dispositivo",
employee: "Empleado",
activationStatus: "Estado de activacion", activationStatus: "Estado de activacion",
}, },
placeholder: { placeholder: {
@ -535,6 +727,7 @@ const deviceEs = {
alias: "Ingrese nombre", alias: "Ingrese nombre",
lastAddress: "Ingrese direccion", lastAddress: "Ingrese direccion",
remark: "Ingrese observacion del dispositivo", remark: "Ingrese observacion del dispositivo",
employee: "Seleccione empleado",
activationStatus: "Seleccione estado de activacion", activationStatus: "Seleccione estado de activacion",
macAddress: "Ingrese direccion MAC", macAddress: "Ingrese direccion MAC",
privateKey: "Ingrese clave privada", privateKey: "Ingrese clave privada",
@ -609,6 +802,9 @@ const deviceEs = {
title: "Asignar dispositivo", title: "Asignar dispositivo",
selectedUsers: "Empleados seleccionados: {count}", selectedUsers: "Empleados seleccionados: {count}",
}, },
employeeSelector: {
title: "Seleccionar empleado",
},
detail: { detail: {
title: "Detalle del dispositivo", title: "Detalle del dispositivo",
macAddress: "Direccion MAC", macAddress: "Direccion MAC",
@ -697,6 +893,7 @@ const devicePt = {
alias: "Nome", alias: "Nome",
lastAddress: "Endereco", lastAddress: "Endereco",
remark: "Observacao do dispositivo", remark: "Observacao do dispositivo",
employee: "Funcionario",
activationStatus: "Status de ativacao", activationStatus: "Status de ativacao",
}, },
placeholder: { placeholder: {
@ -706,6 +903,7 @@ const devicePt = {
alias: "Digite o nome", alias: "Digite o nome",
lastAddress: "Digite o endereco", lastAddress: "Digite o endereco",
remark: "Digite a observacao do dispositivo", remark: "Digite a observacao do dispositivo",
employee: "Selecione o funcionario",
activationStatus: "Selecione o status de ativacao", activationStatus: "Selecione o status de ativacao",
macAddress: "Digite o endereco MAC", macAddress: "Digite o endereco MAC",
privateKey: "Digite a chave privada", privateKey: "Digite a chave privada",
@ -780,6 +978,9 @@ const devicePt = {
title: "Atribuir dispositivo", title: "Atribuir dispositivo",
selectedUsers: "Funcionarios selecionados: {count}", selectedUsers: "Funcionarios selecionados: {count}",
}, },
employeeSelector: {
title: "Selecionar funcionario",
},
detail: { detail: {
title: "Detalhe do dispositivo", title: "Detalhe do dispositivo",
macAddress: "Endereco MAC", macAddress: "Endereco MAC",
@ -863,6 +1064,7 @@ const devicePt = {
const deviceMessages = { const deviceMessages = {
"zh-CN": { device: deviceZh }, "zh-CN": { device: deviceZh },
"en-US": { device: deviceEn }, "en-US": { device: deviceEn },
"ru-RU": { device: deviceRu },
"fr-FR": { device: deviceFr }, "fr-FR": { device: deviceFr },
"es-ES": { device: deviceEs }, "es-ES": { device: deviceEs },
"pt-BR": { device: devicePt }, "pt-BR": { device: devicePt },

16
src/lang/index.js

@ -7,6 +7,7 @@ import systemMessages from "./system-messages";
import systemUserDeviceMessages from "./system-user-device-messages"; import systemUserDeviceMessages from "./system-user-device-messages";
import profileMessages from "./profile-messages"; import profileMessages from "./profile-messages";
import noPermissionMessages from "./no-permission-messages"; import noPermissionMessages from "./no-permission-messages";
import fenceMessages from "./fence-messages";
import { getLanguage } from "@/utils/language"; import { getLanguage } from "@/utils/language";
const DEFAULT_LANGUAGE = "zh-CN"; const DEFAULT_LANGUAGE = "zh-CN";
@ -59,6 +60,11 @@ const mergedMessagesWithDashboard = mergeLocaleMessages(
dashboardMessages dashboardMessages
); );
const mergedMessagesWithFence = mergeLocaleMessages(
mergedMessagesWithDashboard,
fenceMessages
);
function getByPath(obj, path) { function getByPath(obj, path) {
if (!obj || !path) { if (!obj || !path) {
return undefined; return undefined;
@ -80,14 +86,20 @@ function formatTemplate(text, params = {}) {
export function t(key, params = {}) { export function t(key, params = {}) {
const currentLanguage = getLanguage() || DEFAULT_LANGUAGE; const currentLanguage = getLanguage() || DEFAULT_LANGUAGE;
const currentMessages = mergedMessagesWithDashboard[currentLanguage] || {}; const currentMessages = mergedMessagesWithFence[currentLanguage] || {};
const defaultMessages = mergedMessagesWithDashboard[DEFAULT_LANGUAGE] || {}; const englishMessages = mergedMessagesWithFence["en-US"] || {};
const defaultMessages = mergedMessagesWithFence[DEFAULT_LANGUAGE] || {};
const fromCurrent = getByPath(currentMessages, key); const fromCurrent = getByPath(currentMessages, key);
if (fromCurrent !== undefined) { if (fromCurrent !== undefined) {
return formatTemplate(fromCurrent, params); return formatTemplate(fromCurrent, params);
} }
const fromEnglish = getByPath(englishMessages, key);
if (fromEnglish !== undefined) {
return formatTemplate(fromEnglish, params);
}
const fromDefault = getByPath(defaultMessages, key); const fromDefault = getByPath(defaultMessages, key);
if (fromDefault !== undefined) { if (fromDefault !== undefined) {
return formatTemplate(fromDefault, params); return formatTemplate(fromDefault, params);

59
src/lang/messages.js

@ -18,6 +18,7 @@ const messages = {
title: "语言", title: "语言",
"zh-CN": "中文", "zh-CN": "中文",
"en-US": "英语", "en-US": "英语",
"ru-RU": "俄语",
"fr-FR": "法语", "fr-FR": "法语",
"es-ES": "西班牙语", "es-ES": "西班牙语",
"pt-BR": "葡萄牙语", "pt-BR": "葡萄牙语",
@ -136,6 +137,7 @@ const messages = {
title: "Language", title: "Language",
"zh-CN": "Chinese", "zh-CN": "Chinese",
"en-US": "English", "en-US": "English",
"ru-RU": "Russian",
"fr-FR": "French", "fr-FR": "French",
"es-ES": "Spanish", "es-ES": "Spanish",
"pt-BR": "Portuguese", "pt-BR": "Portuguese",
@ -254,6 +256,7 @@ const messages = {
title: "Langue", title: "Langue",
"zh-CN": "Chinois", "zh-CN": "Chinois",
"en-US": "Anglais", "en-US": "Anglais",
"ru-RU": "Russe",
"fr-FR": "Français", "fr-FR": "Français",
"es-ES": "Espagnol", "es-ES": "Espagnol",
"pt-BR": "Portugais", "pt-BR": "Portugais",
@ -372,6 +375,7 @@ const messages = {
title: "Idioma", title: "Idioma",
"zh-CN": "Chino", "zh-CN": "Chino",
"en-US": "Inglés", "en-US": "Inglés",
"ru-RU": "Ruso",
"fr-FR": "Francés", "fr-FR": "Francés",
"es-ES": "Español", "es-ES": "Español",
"pt-BR": "Portugués", "pt-BR": "Portugués",
@ -490,6 +494,7 @@ const messages = {
title: "Idioma", title: "Idioma",
"zh-CN": "Chinês", "zh-CN": "Chinês",
"en-US": "Inglês", "en-US": "Inglês",
"ru-RU": "Russo",
"fr-FR": "Francês", "fr-FR": "Francês",
"es-ES": "Espanhol", "es-ES": "Espanhol",
"pt-BR": "Português", "pt-BR": "Português",
@ -591,4 +596,58 @@ const messages = {
}, },
}; };
messages["ru-RU"] = {
common: {
confirm: "Подтвердить",
cancel: "Отмена",
tips: "Подсказка",
home: "Главная",
search: "Поиск",
reset: "Сброс",
add: "Добавить",
edit: "Изменить",
remove: "Удалить",
status: "Статус",
createTime: "Время создания",
actions: "Действия",
},
lang: {
title: "Язык",
"zh-CN": "Китайский",
"en-US": "Английский",
"ru-RU": "Русский",
"fr-FR": "Французский",
"es-ES": "Испанский",
"pt-BR": "Португальский",
},
navbar: {
layoutSize: "Размер интерфейса",
profile: "Профиль",
logout: "Выйти",
logoutConfirm: "Подтвердить выход из системы?",
},
login: {
title: "Панель клиентов GeoTag",
usernamePlaceholder: "Учетная запись",
passwordPlaceholder: "Пароль",
googleCodePlaceholder: "Код Google",
login: "Войти",
loggingIn: "Вход...",
usernameRequired: "Введите имя пользователя",
passwordRequired: "Введите пароль",
codeRequired: "Введите код проверки",
humanVerifyRequired: "Пройдите проверку",
},
request: {
reloginTip: "Сеанс истек, войдите снова.",
relogin: "Войти снова",
invalidSession: "Недействительный сеанс, войдите снова.",
backendConnectError: "Ошибка подключения к серверу",
requestTimeout: "Время запроса истекло",
apiErrorStatus: "Ошибка API {status}",
downloading: "Идет загрузка, подождите...",
downloadError: "Ошибка загрузки файла. Свяжитесь с администратором.",
},
};
export default messages; export default messages;

15
src/lang/no-permission-messages.js

@ -29,6 +29,21 @@ const noPermissionMessages = {
} }
} }
}, },
"ru-RU": {
noPermission: {
title: "Нет доступа к страницам",
desc: "Учетная запись вошла в систему, но не имеет доступа ни к одной странице. Нажмите \"Обновить права\", чтобы перезагрузить разрешения, или выйдите и войдите снова.",
button: {
refresh: "Обновить права",
signOut: "Выйти"
},
message: {
updated: "Права обновлены, выполняется переход на главную",
noAccess: "У этой учетной записи нет доступа к страницам",
refreshFailed: "Не удалось обновить права"
}
}
},
"fr-FR": { "fr-FR": {
noPermission: { noPermission: {
title: "Aucune permission de page", title: "Aucune permission de page",

48
src/lang/profile-messages.js

@ -95,6 +95,54 @@ const profileMessages = {
}, },
}, },
}, },
"ru-RU": {
profile: {
title: "Основная информация",
tabs: {
basic: "Основная информация",
password: "Изменить пароль",
},
form: {
account: "Учетная запись для входа",
businessName: "Компания",
nickName: "Псевдоним",
googleKey: "Ключ Google Maps",
gaodeKey: "Ключ Amap",
gaodeSecurityKey: "Ключ безопасности Amap",
oldPassword: "Старый пароль",
newPassword: "Новый пароль",
confirmPassword: "Подтвердите пароль",
},
placeholder: {
googleKey: "Введите ключ Google Maps",
gaodeKey: "Введите ключ Amap",
gaodeSecurityKey: "Введите ключ безопасности Amap",
oldPassword: "Введите старый пароль",
newPassword: "Введите новый пароль",
confirmPassword: "Повторно введите новый пароль",
},
tip: {
businessConfigReadonly: "Эта учетная запись может изменять только собственный псевдоним. Ключи карт компании могут изменять только администраторы предприятия.",
},
button: {
saveProfile: "Сохранить",
reset: "Сброс",
changePassword: "Изменить пароль",
},
message: {
profileUpdated: "Профиль успешно обновлен",
passwordUpdated: "Пароль успешно изменен",
},
validation: {
nickNameRequired: "Псевдоним обязателен",
oldPasswordRequired: "Старый пароль обязателен",
newPasswordRequired: "Новый пароль обязателен",
newPasswordLength: "Длина должна быть от 6 до 20 символов",
confirmPasswordRequired: "Подтвердите пароль",
confirmPasswordMismatch: "Введенные пароли не совпадают",
},
},
},
"fr-FR": { "fr-FR": {
profile: { profile: {
title: "Informations de base", title: "Informations de base",

2
src/lang/system-messages.js

File diff suppressed because one or more lines are too long

43
src/lang/system-user-device-messages.js

@ -85,6 +85,49 @@ const systemUserDeviceMessages = {
}, },
}, },
}, },
"ru-RU": {
systemUser: {
button: {
viewDevices: "Просмотр устройств",
unbindDevices: "Отвязать устройства",
},
deviceDialog: {
title: "Устройства сотрудника",
selectedUser: "Текущий сотрудник",
empty: "Нет назначенных устройств",
query: {
sn: "Серийный номер",
model: "Модель",
orderCode: "Номер заказа",
alias: "Псевдоним устройства",
},
placeholder: {
sn: "Введите серийный номер",
model: "Введите модель",
orderCode: "Введите номер заказа",
alias: "Введите псевдоним устройства",
},
table: {
id: "ID устройства",
sn: "Серийный номер",
model: "Модель",
orderCode: "Номер заказа",
alias: "Псевдоним устройства",
activationStatus: "Статус активации",
lastLocationTime: "Время позиции",
},
status: {
activated: "Активировано",
notActivated: "Не активировано",
},
message: {
selectDeviceFirst: "Сначала выберите устройства для отвязки",
confirmUnbind: "Подтвердить отвязку {count} устройств у сотрудника \"{account}\"?",
unbindSuccess: "Устройства успешно отвязаны",
},
},
},
},
"fr-FR": { "fr-FR": {
systemUser: { systemUser: {
button: { button: {

2
src/main.js

@ -6,6 +6,7 @@ import Element from "element-ui";
import ElementLocale from "element-ui/lib/locale"; import ElementLocale from "element-ui/lib/locale";
import elementZhCN from "element-ui/lib/locale/lang/zh-CN"; import elementZhCN from "element-ui/lib/locale/lang/zh-CN";
import elementEn from "element-ui/lib/locale/lang/en"; import elementEn from "element-ui/lib/locale/lang/en";
import elementRu from "element-ui/lib/locale/lang/ru-RU";
import elementFr from "element-ui/lib/locale/lang/fr"; import elementFr from "element-ui/lib/locale/lang/fr";
import elementEs from "element-ui/lib/locale/lang/es"; import elementEs from "element-ui/lib/locale/lang/es";
import elementPtBr from "element-ui/lib/locale/lang/pt-br"; import elementPtBr from "element-ui/lib/locale/lang/pt-br";
@ -26,6 +27,7 @@ import { getLanguage } from "@/utils/language";
const ELEMENT_LOCALE_MAP = { const ELEMENT_LOCALE_MAP = {
"zh-CN": elementZhCN, "zh-CN": elementZhCN,
"en-US": elementEn, "en-US": elementEn,
"ru-RU": elementRu,
"fr-FR": elementFr, "fr-FR": elementFr,
"es-ES": elementEs, "es-ES": elementEs,
"pt-BR": elementPtBr, "pt-BR": elementPtBr,

3
src/utils/language.js

@ -4,6 +4,7 @@ const DEFAULT_LANGUAGE = "zh-CN";
const SUPPORTED_LANGUAGES = [ const SUPPORTED_LANGUAGES = [
"zh-CN", "zh-CN",
"en-US", "en-US",
"ru-RU",
"fr-FR", "fr-FR",
"es-ES", "es-ES",
"pt-BR", "pt-BR",
@ -24,6 +25,8 @@ const LANGUAGE_ALIAS_MAP = {
"en-gb": "en-US", "en-gb": "en-US",
"en-au": "en-US", "en-au": "en-US",
"en-ca": "en-US", "en-ca": "en-US",
ru: "ru-RU",
"ru-ru": "ru-RU",
fr: "fr-FR", fr: "fr-FR",
"fr-fr": "fr-FR", "fr-fr": "fr-FR",
"fr-ca": "fr-FR", "fr-ca": "fr-FR",

75
src/views/device/device/index.vue

@ -33,9 +33,23 @@
<el-input v-model="queryParams.remark" :placeholder="$t('device.placeholder.remark')" clearable size="small" @keyup.enter.native="handleQuery" /> <el-input v-model="queryParams.remark" :placeholder="$t('device.placeholder.remark')" clearable size="small" @keyup.enter.native="handleQuery" />
</el-form-item> </el-form-item>
<el-form-item v-if="canSearchByEmployee" :label="$t('device.query.employee')" prop="userId">
<el-input
v-model="searchEmployeeName"
:placeholder="$t('device.placeholder.employee')"
clearable
readonly
size="small"
@clear="clearSearchEmployee"
@click.native="openSearchEmployeeSelect"
>
<el-button slot="append" icon="el-icon-search" @click.stop="openSearchEmployeeSelect" />
</el-input>
</el-form-item>
<el-form-item :label="$t('device.query.activationStatus')" prop="activationStatus"> <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-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.all')" :value="activationStatusAllValue" />
<el-option :label="$t('device.status.disabled')" :value="0" /> <el-option :label="$t('device.status.disabled')" :value="0" />
<el-option :label="$t('device.status.enabled')" :value="1" /> <el-option :label="$t('device.status.enabled')" :value="1" />
</el-select> </el-select>
@ -291,6 +305,20 @@
<!-- 核心必须挂载企业选择组件且绑定正确的开关 --> <!-- 核心必须挂载企业选择组件且绑定正确的开关 -->
<BusinessSelect :visible.sync="businessSelectVisible" @select="handleBusinessSelect" /> <BusinessSelect :visible.sync="businessSelectVisible" @select="handleBusinessSelect" />
<BusinessSelect :visible.sync="searchBusinessSelectVisible" @select="handleSearchBusinessSelect" /> <BusinessSelect :visible.sync="searchBusinessSelectVisible" @select="handleSearchBusinessSelect" />
<el-dialog
:title="$t('device.dialog.employeeSelector.title')"
:visible.sync="searchEmployeeSelectVisible"
width="1200px"
append-to-body
@close="handleSearchEmployeeDialogClose"
>
<UserSelector
ref="searchUserSelector"
selection-mode
pick-mode
@pick="handleSearchEmployeePick"
/>
</el-dialog>
<DeviceClaimDialog :visible.sync="claimDeviceOpen" @success="handleClaimSuccess" /> <DeviceClaimDialog :visible.sync="claimDeviceOpen" @success="handleClaimSuccess" />
<el-dialog :title="$t('device.dialog.assign.title')" :visible.sync="assignDeviceOpen" width="1200px" append-to-body @close="handleAssignDialogClose"> <el-dialog :title="$t('device.dialog.assign.title')" :visible.sync="assignDeviceOpen" width="1200px" append-to-body @close="handleAssignDialogClose">
<UserSelector ref="userSelector" selection-mode @select="handleAssignUserSelect" /> <UserSelector ref="userSelector" selection-mode @select="handleAssignUserSelect" />
@ -469,6 +497,7 @@ function getDefaultQueryParams() {
return { return {
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 10,
userId: null,
sn: null, sn: null,
alias: null, alias: null,
remark: null, remark: null,
@ -502,6 +531,7 @@ export default {
data() { data() {
return { return {
baseBatchNo: "", baseBatchNo: "",
activationStatusAllValue: ACTIVATION_STATUS_ALL,
// //
loading: true, loading: true,
// //
@ -578,6 +608,7 @@ export default {
bindBusinessId: "", // ID bindBusinessId: "", // ID
businessName: "", // businessName: "", //
searchBusinessName: "", searchBusinessName: "",
searchEmployeeName: "",
// //
importForm: getDefaultImportForm(), importForm: getDefaultImportForm(),
// //
@ -647,6 +678,7 @@ export default {
// //
businessSelectVisible: false, businessSelectVisible: false,
searchBusinessSelectVisible: false, searchBusinessSelectVisible: false,
searchEmployeeSelectVisible: false,
}; };
}, },
created() { created() {
@ -656,6 +688,12 @@ export default {
importResultColumns() { importResultColumns() {
return this.$store.getters.device === "mobile" ? 1 : 2; return this.$store.getters.device === "mobile" ? 1 : 2;
}, },
canSearchByEmployee() {
const permissions = this.$store.getters.permissions || [];
return permissions.includes("*:*:*")
|| permissions.includes("business:businessUser:list")
|| permissions.includes("system:user:list");
}
}, },
deactivated() { deactivated() {
this.closeBusinessSelectDialogs(); this.closeBusinessSelectDialogs();
@ -668,6 +706,12 @@ export default {
const requestQueryParams = { const requestQueryParams = {
...this.queryParams ...this.queryParams
}; };
if (!this.canSearchByEmployee
|| requestQueryParams.userId === undefined
|| requestQueryParams.userId === null
|| requestQueryParams.userId === "") {
delete requestQueryParams.userId;
}
if ( if (
requestQueryParams.activationStatus === ACTIVATION_STATUS_ALL || requestQueryParams.activationStatus === ACTIVATION_STATUS_ALL ||
requestQueryParams.activationStatus === undefined || requestQueryParams.activationStatus === undefined ||
@ -681,6 +725,7 @@ export default {
closeBusinessSelectDialogs() { closeBusinessSelectDialogs() {
this.businessSelectVisible = false; this.businessSelectVisible = false;
this.searchBusinessSelectVisible = false; this.searchBusinessSelectVisible = false;
this.searchEmployeeSelectVisible = false;
}, },
/** 查询系统设备主列表 */ /** 查询系统设备主列表 */
getList() { getList() {
@ -745,6 +790,8 @@ export default {
} }
this.searchBusinessName = ""; this.searchBusinessName = "";
this.searchBusinessSelectVisible = false; this.searchBusinessSelectVisible = false;
this.searchEmployeeName = "";
this.searchEmployeeSelectVisible = false;
this.handleQuery(); this.handleQuery();
}, },
handleTableSortChange({ prop, order }) { handleTableSortChange({ prop, order }) {
@ -1251,6 +1298,32 @@ export default {
this.searchBusinessName = row.name; this.searchBusinessName = row.name;
this.searchBusinessSelectVisible = false; this.searchBusinessSelectVisible = false;
}, },
openSearchEmployeeSelect() {
if (!this.canSearchByEmployee) {
return;
}
this.searchEmployeeSelectVisible = true;
},
clearSearchEmployee() {
this.queryParams.userId = null;
this.searchEmployeeName = "";
},
handleSearchEmployeeDialogClose() {
if (this.$refs.searchUserSelector && this.$refs.searchUserSelector.clearSelectionState) {
this.$refs.searchUserSelector.clearSelectionState();
}
},
handleSearchEmployeePick(row) {
if (!row || row.id === undefined || row.id === null) {
return;
}
this.queryParams.userId = row.id;
const account = row.account || "-";
const nickName = row.nickName || "-";
this.searchEmployeeName = `${nickName} (${account})`;
this.searchEmployeeSelectVisible = false;
this.handleQuery();
},
normalizeImportResult(data) { normalizeImportResult(data) {
const safeData = data || {}; const safeData = data || {};
return { return {

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

@ -22,7 +22,7 @@
:placeholder="$t('device.placeholder.activationStatus')" :placeholder="$t('device.placeholder.activationStatus')"
@keyup.enter.native="searchDevices" @keyup.enter.native="searchDevices"
> >
<el-option :label="$t('device.status.all')" :value="ACTIVATION_STATUS_ALL" /> <el-option :label="$t('device.status.all')" :value="activationStatusAllValue" />
<el-option :label="$t('device.status.disabled')" :value="0" /> <el-option :label="$t('device.status.disabled')" :value="0" />
<el-option :label="$t('device.status.enabled')" :value="1" /> <el-option :label="$t('device.status.enabled')" :value="1" />
</el-select> </el-select>
@ -39,7 +39,7 @@
<el-scrollbar v-else class="device-scroll"> <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 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"> <div class="device-item__top">
<span>{{ item.alias || item.sn || "-" }}</span> <span>{{ item.alias || "-" }}</span>
<el-tag size="mini" :type="item.activationStatus ? 'success' : 'info'"> <el-tag size="mini" :type="item.activationStatus ? 'success' : 'info'">
{{ item.activationStatus ? $t("device.trajectory.page.status.enabled") : $t("device.trajectory.page.status.disabled") }} {{ item.activationStatus ? $t("device.trajectory.page.status.enabled") : $t("device.trajectory.page.status.disabled") }}
</el-tag> </el-tag>
@ -124,6 +124,7 @@ import {
const GOOGLE_TILE_URL = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}"; 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="; const MAPTILER_URL = "https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=";
const ACTIVATION_STATUS_ALL = "__ALL__"; const ACTIVATION_STATUS_ALL = "__ALL__";
const TRAJECTORY_TIME_DOT_MAX_COUNT = 80;
export default { export default {
name: "DeviceTrajectoryPage", name: "DeviceTrajectoryPage",
@ -137,6 +138,7 @@ export default {
remark: null, remark: null,
activationStatus: 1 activationStatus: 1
}, },
activationStatusAllValue: ACTIVATION_STATUS_ALL,
devices: [], devices: [],
total: 0, total: 0,
loadingDevices: false, loadingDevices: false,
@ -153,6 +155,10 @@ export default {
mapsApi: null, mapsApi: null,
tileLayer: null, tileLayer: null,
overlays: [], overlays: [],
timeDotOverlays: [],
mapTrajectoryPoints: [],
leafletZoomListener: null,
amapZoomListener: null,
availableHeight: 760 availableHeight: 760
}; };
}, },
@ -340,6 +346,7 @@ export default {
const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null); const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null);
const path = validPoints.map((point) => [point.latNum, point.lngNum]); const path = validPoints.map((point) => [point.latNum, point.lngNum]);
if (!path.length) return; if (!path.length) return;
this.mapTrajectoryPoints = validPoints;
const startPoint = validPoints[validPoints.length - 1]; const startPoint = validPoints[validPoints.length - 1];
const endPoint = validPoints[0]; const endPoint = validPoints[0];
@ -354,26 +361,45 @@ export default {
const endMarkerText = `${this.$t("device.trajectory.marker.endShort")} ${endTimeText}`; const endMarkerText = `${this.$t("device.trajectory.marker.endShort")} ${endTimeText}`;
const startPopupText = `${this.$t("device.trajectory.message.startTime")}<br/>${startTimeText}`; const startPopupText = `${this.$t("device.trajectory.message.startTime")}<br/>${startTimeText}`;
const endPopupText = `${this.$t("device.trajectory.message.endTime")}<br/>${endTimeText}`; const endPopupText = `${this.$t("device.trajectory.message.endTime")}<br/>${endTimeText}`;
const startMarker = L.marker([startPoint.latNum, startPoint.lngNum]) const startMarker = L.circleMarker([startPoint.latNum, startPoint.lngNum], {
radius: 7,
color: "#ffffff",
weight: 2,
opacity: 1,
fillColor: "#67c23a",
fillOpacity: 0.95
})
.addTo(this.map) .addTo(this.map)
.bindPopup(startPopupText) .bindPopup(startPopupText)
.bindTooltip(startMarkerText, { .bindTooltip(startMarkerText, {
permanent: true, permanent: true,
direction: "top", direction: "top",
offset: [0, -12], offset: [0, -12],
className: "trajectory-point-tooltip" className: "trajectory-point-tooltip trajectory-point-tooltip-start"
}); });
const endMarker = L.marker([endPoint.latNum, endPoint.lngNum]) const endMarker = L.circleMarker([endPoint.latNum, endPoint.lngNum], {
radius: 7,
color: "#ffffff",
weight: 2,
opacity: 1,
fillColor: "#f56c6c",
fillOpacity: 0.95
})
.addTo(this.map) .addTo(this.map)
.bindPopup(endPopupText) .bindPopup(endPopupText)
.bindTooltip(endMarkerText, { .bindTooltip(endMarkerText, {
permanent: true, permanent: true,
direction: "top", direction: "top",
offset: [0, -12], offset: [0, -12],
className: "trajectory-point-tooltip" className: "trajectory-point-tooltip trajectory-point-tooltip-end"
}); });
this.overlays = [line, startMarker, endMarker]; this.overlays = [line, startMarker, endMarker];
this.refreshLeafletTimeDots();
if (!this.leafletZoomListener && this.map && typeof this.map.on === "function") {
this.leafletZoomListener = () => this.refreshLeafletTimeDots();
this.map.on("zoomend", this.leafletZoomListener);
}
this.map.fitBounds(L.latLngBounds(path), { this.map.fitBounds(L.latLngBounds(path), {
padding: [30, 30], padding: [30, 30],
maxZoom: 16 maxZoom: 16
@ -409,6 +435,7 @@ export default {
const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null); const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null);
const path = validPoints.map((point) => [point.lngNum, point.latNum]); const path = validPoints.map((point) => [point.lngNum, point.latNum]);
if (!path.length) return; if (!path.length) return;
this.mapTrajectoryPoints = validPoints;
const startPoint = validPoints[validPoints.length - 1]; const startPoint = validPoints[validPoints.length - 1];
const endPoint = validPoints[0]; const endPoint = validPoints[0];
@ -426,21 +453,31 @@ export default {
}); });
const startMarker = new AMap.Marker({ const startMarker = new AMap.Marker({
position: [startPoint.lngNum, startPoint.latNum], position: [startPoint.lngNum, startPoint.latNum],
offset: new AMap.Pixel(-8, -8),
content:
'<div style="width:14px;height:14px;border-radius:50%;background:#67c23a;border:2px solid #ffffff;box-shadow:0 2px 6px rgba(0,0,0,0.22);"></div>',
label: { label: {
content: startMarkerText, content: `<span style="color:#67c23a;font-weight:600;">${this.escapeHtml(startMarkerText)}</span>`,
direction: "top" direction: "top"
} }
}); });
const endMarker = new AMap.Marker({ const endMarker = new AMap.Marker({
position: [endPoint.lngNum, endPoint.latNum], position: [endPoint.lngNum, endPoint.latNum],
offset: new AMap.Pixel(-8, -8),
content:
'<div style="width:14px;height:14px;border-radius:50%;background:#f56c6c;border:2px solid #ffffff;box-shadow:0 2px 6px rgba(0,0,0,0.22);"></div>',
label: { label: {
content: endMarkerText, content: `<span style="color:#f56c6c;font-weight:600;">${this.escapeHtml(endMarkerText)}</span>`,
direction: "top" direction: "top"
} }
}); });
this.map.add([line, startMarker, endMarker]); this.map.add([line, startMarker, endMarker]);
this.overlays = [line, startMarker, endMarker]; this.overlays = [line, startMarker, endMarker];
this.refreshAmapTimeDots();
if (!this.amapZoomListener && this.map && typeof this.map.on === "function") {
this.amapZoomListener = () => this.refreshAmapTimeDots();
this.map.on("zoomend", this.amapZoomListener);
}
this.map.setFitView(this.overlays, false, [50, 50, 50, 50]); this.map.setFitView(this.overlays, false, [50, 50, 50, 50]);
} catch (e) { } catch (e) {
this.mapError = (e && e.message) || this.$t("device.trajectory.message.amapLoadFailed"); this.mapError = (e && e.message) || this.$t("device.trajectory.message.amapLoadFailed");
@ -448,24 +485,92 @@ export default {
this.loadingMap = false; this.loadingMap = false;
} }
}, },
getCurrentMapZoom() {
if (!this.map) {
return 0;
}
if (this.mapVendor === "leaflet" && typeof this.map.getZoom === "function") {
return Number(this.map.getZoom()) || 0;
}
if (this.mapVendor === "amap" && typeof this.map.getZoom === "function") {
return Number(this.map.getZoom()) || 0;
}
return 0;
},
getTimeDotMaxCountByZoom(zoom) {
if (!Number.isFinite(zoom)) {
return Number.MAX_SAFE_INTEGER;
}
return Number.MAX_SAFE_INTEGER;
},
refreshLeafletTimeDots() {
if (this.mapVendor !== "leaflet" || !this.map || !this.mapsApi || !this.mapTrajectoryPoints.length) {
return;
}
this.clearTimeDotOverlays();
const maxCount = this.getTimeDotMaxCountByZoom(this.getCurrentMapZoom());
if (maxCount <= 2) {
return;
}
this.timeDotOverlays = this.createLeafletTimeDotMarkers(this.mapTrajectoryPoints, maxCount);
},
refreshAmapTimeDots() {
if (this.mapVendor !== "amap" || !this.map || !this.mapsApi || !this.mapTrajectoryPoints.length) {
return;
}
this.clearTimeDotOverlays();
const maxCount = this.getTimeDotMaxCountByZoom(this.getCurrentMapZoom());
if (maxCount <= 2) {
return;
}
this.timeDotOverlays = this.createAmapTimeDotMarkers(this.mapTrajectoryPoints, maxCount);
if (this.timeDotOverlays.length) {
this.map.add(this.timeDotOverlays);
}
},
clearTimeDotOverlays() {
if (!this.map || !this.timeDotOverlays.length) {
this.timeDotOverlays = [];
return;
}
if (this.mapVendor === "leaflet") {
this.timeDotOverlays.forEach((overlay) => this.map.removeLayer(overlay));
} else if (this.mapVendor === "amap") {
this.map.remove(this.timeDotOverlays);
}
this.timeDotOverlays = [];
},
clearOverlays() { clearOverlays() {
if (!this.map || !this.overlays.length) return; this.clearTimeDotOverlays();
if (!this.map || !this.overlays.length) {
this.mapTrajectoryPoints = [];
return;
}
if (this.mapVendor === "leaflet") { if (this.mapVendor === "leaflet") {
this.overlays.forEach((overlay) => this.map.removeLayer(overlay)); this.overlays.forEach((overlay) => this.map.removeLayer(overlay));
} else if (this.mapVendor === "amap") { } else if (this.mapVendor === "amap") {
this.map.remove(this.overlays); this.map.remove(this.overlays);
} }
this.overlays = []; this.overlays = [];
this.mapTrajectoryPoints = [];
}, },
destroyMap() { destroyMap() {
this.clearOverlays(); this.clearOverlays();
if (!this.map) return; if (!this.map) return;
if (this.mapVendor === "leaflet" && this.leafletZoomListener && typeof this.map.off === "function") {
this.map.off("zoomend", this.leafletZoomListener);
}
if (this.mapVendor === "amap" && this.amapZoomListener && typeof this.map.off === "function") {
this.map.off("zoomend", this.amapZoomListener);
}
if (this.mapVendor === "leaflet") this.map.remove(); if (this.mapVendor === "leaflet") this.map.remove();
else if (this.mapVendor === "amap") this.map.destroy(); else if (this.mapVendor === "amap") this.map.destroy();
this.map = null; this.map = null;
this.mapVendor = ""; this.mapVendor = "";
this.mapsApi = null; this.mapsApi = null;
this.tileLayer = null; this.tileLayer = null;
this.leafletZoomListener = null;
this.amapZoomListener = null;
}, },
formatTime(value) { formatTime(value) {
return value ? this.parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-"; return value ? this.parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-";
@ -478,6 +583,82 @@ export default {
const lng = point && (point.lng !== undefined ? point.lng : point.lngNum); const lng = point && (point.lng !== undefined ? point.lng : point.lngNum);
return `${this.formatCoordinateValue(lat)} / ${this.formatCoordinateValue(lng)}`; return `${this.formatCoordinateValue(lat)} / ${this.formatCoordinateValue(lng)}`;
}, },
getSampleIndexes(total, maxCount = TRAJECTORY_TIME_DOT_MAX_COUNT) {
if (!Number.isFinite(total) || total <= 0) {
return [];
}
if (total <= maxCount) {
return Array.from({ length: total }, (_, index) => index);
}
const indexes = new Set([0, total - 1]);
const step = (total - 1) / (maxCount - 1);
for (let i = 1; i < maxCount - 1; i++) {
indexes.add(Math.round(step * i));
}
return Array.from(indexes).sort((a, b) => a - b);
},
getTrajectoryPointLabel(point) {
return this.pointTime(point);
},
createLeafletTimeDotMarkers(validPoints, maxCount = TRAJECTORY_TIME_DOT_MAX_COUNT) {
if (!this.mapsApi || !this.map || !Array.isArray(validPoints) || validPoints.length < 2) {
return [];
}
const indexes = this.getSampleIndexes(validPoints.length, maxCount);
return indexes
.filter((index) => index !== 0 && index !== validPoints.length - 1)
.map((index) => {
const point = validPoints[index];
const marker = this.mapsApi.circleMarker([point.latNum, point.lngNum], {
radius: 4,
color: "#ffffff",
weight: 1,
opacity: 0.9,
fillColor: "#1a73e8",
fillOpacity: 0.85
});
marker.addTo(this.map).bindTooltip(this.getTrajectoryPointLabel(point), {
permanent: true,
sticky: false,
direction: "top",
offset: [0, -8],
className: "trajectory-time-dot-tooltip"
});
return marker;
});
},
createAmapTimeDotMarkers(validPoints, maxCount = TRAJECTORY_TIME_DOT_MAX_COUNT) {
if (!this.mapsApi || !this.map || !Array.isArray(validPoints) || validPoints.length < 2) {
return [];
}
const indexes = this.getSampleIndexes(validPoints.length, maxCount);
return indexes
.filter((index) => index !== 0 && index !== validPoints.length - 1)
.map((index) => {
const point = validPoints[index];
const marker = new this.mapsApi.Marker({
position: [point.lngNum, point.latNum],
offset: new this.mapsApi.Pixel(-4, -4),
content:
'<div style="width:8px;height:8px;border-radius:50%;background:#1a73e8;border:1px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.24);"></div>',
label: {
content:
`<div style="padding:2px 6px;border-radius:6px;background:rgba(255,255,255,0.95);border:1px solid #d9e2ef;color:#1f2d3d;font-size:12px;line-height:1.3;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.16);">${this.escapeHtml(this.getTrajectoryPointLabel(point))}</div>`,
direction: "top"
},
zIndex: 110
});
return marker;
});
},
escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
},
formatCoordinateValue(value) { formatCoordinateValue(value) {
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
const strValue = String(value).trim(); const strValue = String(value).trim();
@ -747,6 +928,41 @@ export default {
border-top-color: rgba(32, 45, 64, 0.9); border-top-color: rgba(32, 45, 64, 0.9);
} }
:deep(.trajectory-point-tooltip-start) {
background: rgba(236, 245, 255, 0.98);
color: #67c23a;
border: 1px solid #67c23a;
}
:deep(.trajectory-point-tooltip-start:before) {
border-top-color: #67c23a;
}
:deep(.trajectory-point-tooltip-end) {
background: rgba(255, 241, 240, 0.98);
color: #f56c6c;
border: 1px solid #f56c6c;
}
:deep(.trajectory-point-tooltip-end:before) {
border-top-color: #f56c6c;
}
:deep(.trajectory-time-dot-tooltip) {
background: rgba(255, 255, 255, 0.95);
color: #1f2d3d;
border: 1px solid #d9e2ef;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
padding: 3px 6px;
font-size: 12px;
line-height: 1.3;
}
:deep(.trajectory-time-dot-tooltip:before) {
border-top-color: #d9e2ef;
}
@media (max-width: 1280px) { @media (max-width: 1280px) {
.trajectory-layout { .trajectory-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -770,8 +986,3 @@ export default {
} }
} }
</style> </style>

81
src/views/login.vue

@ -1,11 +1,11 @@
<template> <template>
<div class="login"> <div class="login">
<!-- 左侧动态背景区 --> <!-- 左侧动态背景区?-->
<div class="login-left" v-once> <div class="login-left" v-once>
<!-- 网格背景 --> <!-- 网格背景 -->
<div class="grid-overlay"></div> <div class="grid-overlay"></div>
<!-- 地图网格线 SVG --> <!-- 地图网格?SVG -->
<svg class="map-grid" viewBox="0 0 100 100" preserveAspectRatio="none"> <svg class="map-grid" viewBox="0 0 100 100" preserveAspectRatio="none">
<line x1="20" y1="0" x2="20" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/> <line x1="20" y1="0" x2="20" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="40" y1="0" x2="40" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/> <line x1="40" y1="0" x2="40" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
@ -28,7 +28,7 @@
<path d="M0,150 Q200,200 400,150 T800,200" stroke="rgba(76,175,80,0.25)" fill="none" stroke-width="1"/> <path d="M0,150 Q200,200 400,150 T800,200" stroke="rgba(76,175,80,0.25)" fill="none" stroke-width="1"/>
</svg> </svg>
<!-- 连接线 SVG --> <!-- 连接?SVG -->
<svg class="connection-lines" viewBox="0 0 100 100" preserveAspectRatio="none"> <svg class="connection-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line x1="12" y1="15" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/> <line x1="12" y1="15" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/>
<line x1="78" y1="25" x2="50" y2="50" stroke="rgba(33,150,243,0.6)" stroke-width="0.15"/> <line x1="78" y1="25" x2="50" y2="50" stroke="rgba(33,150,243,0.6)" stroke-width="0.15"/>
@ -62,7 +62,7 @@
<div class="ripple"></div> <div class="ripple"></div>
<div class="ripple"></div> <div class="ripple"></div>
<!-- 定位 --> <!-- 定位?-->
<div class="location-pins"> <div class="location-pins">
<div class="pin"> <div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor"> <svg viewBox="0 0 24 24" fill="currentColor">
@ -183,11 +183,29 @@
<span v-else>{{ $t("login.loggingIn") }}</span> <span v-else>{{ $t("login.loggingIn") }}</span>
</el-button> </el-button>
</el-form-item> </el-form-item>
<div class="login-form-lang">
<el-dropdown size="mini" trigger="click" @command="changeLanguage">
<span class="lang-trigger">
<i class="el-icon-connection"></i>
<span>{{ $t(`lang.${currentLanguage}`) }}</span>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-for="item in languageOptions"
:key="item"
:command="item"
:disabled="item === currentLanguage"
>
{{ $t(`lang.${item}`) }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</el-form> </el-form>
<!-- 底部 --> <!-- 底部 -->
<div class="el-login-footer"> <div class="el-login-footer">
<span>Copyright © 2026 GeoTag All Rights Reserved</span> <span class="login-footer-text">Copyright © 2026 GeoTag All Rights Reserved</span>
</div> </div>
</div> </div>
</div> </div>
@ -196,6 +214,7 @@
<script> <script>
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { encrypt, decrypt } from '@/utils/jsencrypt' import { encrypt, decrypt } from '@/utils/jsencrypt'
import { getLanguage, setLanguage, languageOptions } from '@/utils/language'
export default { export default {
name: "Login", name: "Login",
@ -211,6 +230,7 @@ export default {
codeUrl: "", codeUrl: "",
cookiePassword: "", cookiePassword: "",
key: '6LcBoGUaAAAAABUnZINfh4j6FgqpQR-yHakZepIR', key: '6LcBoGUaAAAAABUnZINfh4j6FgqpQR-yHakZepIR',
languageOptions: languageOptions,
loginForm: { loginForm: {
username: "", username: "",
password: "", password: "",
@ -239,6 +259,11 @@ export default {
particleTaskType: "" particleTaskType: ""
}; };
}, },
computed: {
currentLanguage() {
return this.$store.state.settings.language || getLanguage()
}
},
watch: { watch: {
$route: { $route: {
handler: function (route) { handler: function (route) {
@ -311,6 +336,14 @@ export default {
link.href = icoUrl link.href = icoUrl
document.getElementsByTagName('head')[0].appendChild(link); document.getElementsByTagName('head')[0].appendChild(link);
}, },
changeLanguage(lang) {
const normalized = setLanguage(lang)
this.$store.dispatch('settings/changeSetting', { key: 'language', value: normalized })
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', normalized)
}
window.location.reload()
},
getValidateCode(value) { getValidateCode(value) {
this.loginForm.validateCode = value this.loginForm.validateCode = value
}, },
@ -358,7 +391,7 @@ export default {
height: 100%; height: 100%;
} }
/* 左侧动态背景区*/ /* 左侧动态背景区�?*/
.login-left { .login-left {
flex: 0 0 60%; flex: 0 0 60%;
position: relative; position: relative;
@ -391,7 +424,7 @@ export default {
opacity: 0.15; opacity: 0.15;
} }
/* 定位点动*/ /* 定位点动�?*/
.location-pins { .location-pins {
position: absolute; position: absolute;
top: 0; top: 0;
@ -428,7 +461,7 @@ export default {
50% { transform: scale(1.15); opacity: 1; } 50% { transform: scale(1.15); opacity: 1; }
} }
/* 连接线 */ /* 连接�?*/
.connection-lines { .connection-lines {
position: absolute; position: absolute;
top: 0; top: 0;
@ -438,7 +471,7 @@ export default {
opacity: 0.2; opacity: 0.2;
} }
/* 中央大定位标*/ /* 中央大定位标�?*/
.center-marker { .center-marker {
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -521,7 +554,7 @@ export default {
pointer-events: none; pointer-events: none;
} }
/* 地图网格线 */ /* 地图网格�?*/
.map-grid { .map-grid {
position: absolute; position: absolute;
top: 0; top: 0;
@ -585,7 +618,7 @@ export default {
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
width: 400px; width: 400px;
padding: 40px 35px 15px 35px; padding: 40px 35px 20px 35px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
.el-input { .el-input {
@ -603,6 +636,23 @@ export default {
} }
} }
.login-form-lang {
display: flex;
justify-content: flex-end;
margin-top: 2px;
padding-right: 2px;
}
.lang-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
color: #606266;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.login-tip { .login-tip {
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
@ -633,11 +683,15 @@ export default {
letter-spacing: 1px; letter-spacing: 1px;
} }
.login-footer-text {
pointer-events: none;
}
.login-code-img { .login-code-img {
height: 38px; height: 38px;
} }
/* 响应*/ /* 响应�?*/
@media (max-width: 1024px) { @media (max-width: 1024px) {
.login-left { .login-left {
flex: 0 0 50%; flex: 0 0 50%;
@ -663,3 +717,4 @@ export default {
} }
} }
</style> </style>

Loading…
Cancel
Save