diff --git a/package.json b/package.json index d699381..77aad6d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruoyi", "version": "3.8.9", - "description": "客户GeoTag管理系统", + "description": "2222客户GeoTag管理系统", "author": "GeoTag", "license": "MIT", "scripts": { diff --git a/src/api/system/user.js b/src/api/system/user.js index 0384e2b..71c55dd 100644 --- a/src/api/system/user.js +++ b/src/api/system/user.js @@ -153,3 +153,19 @@ export function listEmployeeUser(query) { params: query }) } + +export function listUserDevices(userId, query) { + return request({ + url: '/business/businessUser/employee/' + userId + '/devices', + method: 'get', + params: query + }) +} + +export function unbindUserDevices(userId, data) { + return request({ + url: '/business/businessUser/employee/' + userId + '/devices/unbind', + method: 'post', + data: data + }) +} diff --git a/src/components/HeaderSearch/index.vue b/src/components/HeaderSearch/index.vue index a936bdf..20d612e 100644 --- a/src/components/HeaderSearch/index.vue +++ b/src/components/HeaderSearch/index.vue @@ -14,7 +14,7 @@ size="large" @input="querySearch" prefix-icon="el-icon-search" - placeholder="菜单搜索,支持标题、URL模糊查询" + :placeholder="$t('app.headerSearch.placeholder')" clearable @keyup.enter.native="selectActiveResult" @keydown.up.native="navigateResult('up')" @@ -23,7 +23,14 @@
-
+
@@ -35,84 +42,84 @@ {{ item.path }}
- +
- +
- - diff --git a/src/components/device/TrajectoryDialog.vue b/src/components/device/TrajectoryDialog.vue index 024dc13..be57b63 100644 --- a/src/components/device/TrajectoryDialog.vue +++ b/src/components/device/TrajectoryDialog.vue @@ -54,6 +54,9 @@
{{ $t("device.trajectory.provider.label") }} + + {{ $t("device.trajectory.provider.maptiler") }} + {{ $t("device.trajectory.provider.amap") }} @@ -111,9 +114,17 @@ import { getDeviceTrajectory, getDeviceTrajectoryMapConfig } from "@/api/device/device"; import { loadAMap } from "@/utils/loadAMap"; import { loadGoogleMaps } from "@/utils/loadGoogleMaps"; +import { loadLeaflet } from "@/utils/loadLeaflet"; const AMAP_DEFAULT_CENTER = [121.4737, 31.2304]; const GOOGLE_DEFAULT_CENTER = { lat: 31.2304, lng: 121.4737 }; +const LEAFLET_DEFAULT_CENTER = [31.2304, 121.4737]; +const AMAP_FALLBACK_MAX_ZOOM = 20; +const GOOGLE_DETAIL_ZOOM = 21; +const LEAFLET_MAX_ZOOM = 22; +const LEAFLET_LINE_ARROW_MAX = 36; +const LEAFLET_SHOW_LINE_ARROWS = false; +const LEAFLET_TRAJECTORY_LINE_COLOR = "#1a73e8"; const CONVERT_BATCH_SIZE = 40; const MAX_TRAJECTORY_POINTS = 100; @@ -143,6 +154,7 @@ export default { polyline: null, markers: [], markerInfoWindows: [], + tileLayer: null, mapsApi: null, mapConfig: null, locationTimeRange: [], @@ -169,6 +181,9 @@ export default { hasGoogleKey() { return !!(this.mapConfig && this.mapConfig.googleKey); }, + hasMaptilerKey() { + return !!(this.mapConfig && this.mapConfig.maptilerKey); + }, }, methods: { async handleOpen() { @@ -213,12 +228,16 @@ export default { await this.renderCurrentProviderMap(); }, resolveDefaultProvider() { + if (this.hasMaptilerKey) { + return "maptiler"; + } if (this.hasAmapKey) { return "amap"; } if (this.hasGoogleKey) { return "google"; } + return "amap"; }, initDefaultLocationTimeRange() { @@ -267,7 +286,7 @@ export default { try { const response = await getDeviceTrajectoryMapConfig(); const data = response && response.data ? response.data : {}; - if (!data.gaodeKey && !data.googleKey) { + if (!data.gaodeKey && !data.googleKey && !data.maptilerKey) { this.loadError = this.$t("device.trajectory.message.missingMapKey"); return; } @@ -294,8 +313,11 @@ export default { }; }) .filter((item) => item.latNum !== null && item.lngNum !== null); - this.totalTrajectoryCount = validPoints.length; - this.trajectoryPoints = validPoints.slice(-MAX_TRAJECTORY_POINTS); + const sortedPoints = validPoints.slice().sort((left, right) => { + return this.getTrackTimestamp(left) - this.getTrackTimestamp(right); + }); + this.totalTrajectoryCount = sortedPoints.length; + this.trajectoryPoints = sortedPoints.slice(-MAX_TRAJECTORY_POINTS); } catch (error) { this.trajectoryPoints = []; this.totalTrajectoryCount = 0; @@ -310,6 +332,13 @@ export default { } this.loadError = ""; await this.$nextTick(); + if (this.mapProvider === "maptiler") { + await this.ensureLeafletMap(); + if (!this.loadError) { + this.renderLeafletTrajectory(); + } + return; + } if (this.mapProvider === "google") { await this.ensureGoogleMap(); if (!this.loadError) { @@ -323,6 +352,211 @@ export default { this.renderAmapTrajectory(); } }, + async ensureLeafletMap() { + if (!this.hasMaptilerKey) { + this.loadError = this.$t("device.trajectory.message.missingMaptilerKey"); + return; + } + this.mapLoading = true; + try { + const mapsApi = await loadLeaflet(); + if (!this.$refs.map) { + return; + } + if (!this.map || this.mapVendor !== "leaflet") { + this.destroyMap(); + this.mapsApi = mapsApi; + this.map = mapsApi.map(this.$refs.map, { + center: LEAFLET_DEFAULT_CENTER, + zoom: LEAFLET_MAX_ZOOM, + zoomControl: true, + }); + this.tileLayer = mapsApi.tileLayer( + "https://api.maptiler.com/maps/streets/{z}/{x}/{y}{r}.png?key=" + + encodeURIComponent(this.mapConfig.maptilerKey), + { + attribution: + '© MapTiler © OpenStreetMap', + maxZoom: LEAFLET_MAX_ZOOM, + maxNativeZoom: LEAFLET_MAX_ZOOM, + detectRetina: true, + } + ); + this.tileLayer.addTo(this.map); + this.mapVendor = "leaflet"; + } else { + this.mapsApi = mapsApi; + if (typeof this.map.invalidateSize === "function") { + this.map.invalidateSize(); + } + } + } catch (error) { + this.loadError = error && error.message ? error.message : this.$t("device.trajectory.message.maptilerLoadFailed"); + } finally { + this.mapLoading = false; + } + }, + renderLeafletTrajectory() { + if (!this.map || !this.mapsApi || !this.trajectoryPoints.length) { + return; + } + const mapPoints = this.getMapOrderedTrajectoryPoints(); + if (!mapPoints.length) { + return; + } + + this.clearOverlays(); + + const path = mapPoints.map((item) => [item.latNum, item.lngNum]); + this.polyline = this.createLeafletTrajectoryLayer(path); + if (this.polyline) { + this.polyline.addTo(this.map); + } + + const startItem = mapPoints[0]; + const endItem = mapPoints[mapPoints.length - 1]; + this.markers = []; + + if (path.length > 1) { + const startAngle = this.getLeafletDirectionAngle(path[0], path[1]); + const endAngle = this.getLeafletDirectionAngle(path[path.length - 2], path[path.length - 1]); + const startMarker = this.createLeafletDirectionMarker( + path[0], + startAngle, + this.$t("device.trajectory.marker.startShort"), + "#67c23a", + this.$t("device.trajectory.message.startTime"), + this.getPointTrackTime(startItem) + ); + const endMarker = this.createLeafletDirectionMarker( + path[path.length - 1], + endAngle, + this.$t("device.trajectory.marker.endShort"), + "#f56c6c", + this.$t("device.trajectory.message.endTime"), + this.getPointTrackTime(endItem) + ); + this.markers.push(startMarker, endMarker); + if (LEAFLET_SHOW_LINE_ARROWS) { + this.addLeafletLineArrows(path); + } + } else { + const startMarker = this.mapsApi.marker(path[0], { + title: this.$t("device.trajectory.marker.startShort"), + }); + startMarker + .addTo(this.map) + .bindPopup( + `${this.escapeHtml(this.$t("device.trajectory.message.startTime"))}
${this.escapeHtml( + this.getPointTrackTime(startItem) + )}` + ); + this.markers.push(startMarker); + } + + const latestPoint = path[path.length - 1]; + this.map.setView(latestPoint, LEAFLET_MAX_ZOOM); + }, + createLeafletDirectionMarker(point, angle, shortLabel, color, timeLabel, timeText) { + const icon = this.mapsApi.divIcon({ + className: "leaflet-trajectory-arrow", + iconSize: [30, 30], + iconAnchor: [15, 15], + html: this.buildLeafletDirectionHtml(angle, color), + }); + const marker = this.mapsApi.marker(point, { + icon, + title: shortLabel, + }); + marker + .addTo(this.map) + .bindPopup(`${this.escapeHtml(timeLabel)}
${this.escapeHtml(timeText || "-")}`); + return marker; + }, + addLeafletLineArrows(path) { + if (!Array.isArray(path) || path.length < 2) { + return; + } + const segmentCount = path.length - 1; + const step = Math.max(1, Math.ceil(segmentCount / LEAFLET_LINE_ARROW_MAX)); + for (let i = step; i < path.length; i += step) { + const fromPoint = path[i - 1]; + const toPoint = path[i]; + const marker = this.createLeafletLineArrowMarker(fromPoint, toPoint); + if (marker) { + this.markers.push(marker); + } + } + }, + createLeafletLineArrowMarker(fromPoint, toPoint) { + if (!Array.isArray(fromPoint) || !Array.isArray(toPoint)) { + return null; + } + const midPoint = [ + (Number(fromPoint[0]) + Number(toPoint[0])) / 2, + (Number(fromPoint[1]) + Number(toPoint[1])) / 2, + ]; + if (!Number.isFinite(midPoint[0]) || !Number.isFinite(midPoint[1])) { + return null; + } + const angle = this.getLeafletDirectionAngle(fromPoint, toPoint); + const icon = this.mapsApi.divIcon({ + className: "leaflet-trajectory-line-arrow", + iconSize: [20, 20], + iconAnchor: [10, 10], + html: + `
` + + '' + + "
", + }); + const marker = this.mapsApi.marker(midPoint, { + icon, + interactive: false, + keyboard: false, + }); + marker.addTo(this.map); + return marker; + }, + getLeafletDirectionAngle(fromPoint, toPoint) { + if (!Array.isArray(fromPoint) || !Array.isArray(toPoint)) { + return 0; + } + const deltaLat = Number(toPoint[0]) - Number(fromPoint[0]); + const deltaLng = Number(toPoint[1]) - Number(fromPoint[1]); + if (!Number.isFinite(deltaLat) || !Number.isFinite(deltaLng) || (deltaLat === 0 && deltaLng === 0)) { + return 0; + } + return (Math.atan2(deltaLat, deltaLng) * 180) / Math.PI; + }, + buildLeafletDirectionHtml(angle, color) { + return ( + `
` + + `` + + "
" + ); + }, + createLeafletTrajectoryLayer(path) { + if (!Array.isArray(path) || !path.length) { + return null; + } + return this.mapsApi.polyline(path, { + color: LEAFLET_TRAJECTORY_LINE_COLOR, + opacity: 0.95, + weight: 4, + lineJoin: "round", + }); + }, + getAmapMaxZoom() { + if (!this.map || typeof this.map.getZooms !== "function") { + return AMAP_FALLBACK_MAX_ZOOM; + } + const zoomRange = this.map.getZooms(); + if (!Array.isArray(zoomRange) || zoomRange.length < 2) { + return AMAP_FALLBACK_MAX_ZOOM; + } + const maxZoom = Number(zoomRange[1]); + return Number.isFinite(maxZoom) ? maxZoom : AMAP_FALLBACK_MAX_ZOOM; + }, async ensureAmap() { if (!this.hasAmapKey) { this.loadError = this.$t("device.trajectory.message.missingAmapKey"); @@ -515,11 +749,8 @@ export default { this.map.add(this.polyline); this.map.add(this.markers); - if (path.length === 1) { - this.map.setZoomAndCenter(15, startPoint); - } else { - this.map.setFitView([this.polyline].concat(this.markers), false, [60, 60, 60, 60]); - } + const detailZoom = this.getAmapMaxZoom(); + this.map.setZoomAndCenter(detailZoom, endPoint); }, renderGoogleTrajectory() { if (!this.map || !this.mapsApi || !this.trajectoryPoints.length) { @@ -531,8 +762,6 @@ export default { } this.clearOverlays(); const path = mapPoints.map((item) => ({ lat: item.latNum, lng: item.lngNum })); - const bounds = new this.mapsApi.LatLngBounds(); - path.forEach((point) => bounds.extend(point)); const polylineOptions = { path, @@ -603,12 +832,8 @@ export default { } } - if (path.length === 1) { - this.map.setCenter(startPoint); - this.map.setZoom(15); - } else { - this.map.fitBounds(bounds); - } + this.map.setCenter(endPoint); + this.map.setZoom(GOOGLE_DETAIL_ZOOM); }, openGoogleMarkerInfoWindow(marker, title, timeText) { if (!this.mapsApi || !this.map || !marker || !this.mapsApi.InfoWindow) { @@ -667,6 +892,13 @@ export default { } }); } + } else if (this.mapVendor === "leaflet") { + if (this.polyline && typeof this.map.removeLayer === "function") { + this.map.removeLayer(this.polyline); + } + if (this.markers.length && typeof this.map.removeLayer === "function") { + this.markers.forEach((marker) => this.map.removeLayer(marker)); + } } else { if (this.polyline) { this.map.remove(this.polyline); @@ -690,10 +922,13 @@ export default { } if (this.mapVendor === "amap" && typeof this.map.destroy === "function") { this.map.destroy(); + } else if (this.mapVendor === "leaflet" && typeof this.map.remove === "function") { + this.map.remove(); } else if (this.$refs.map) { this.$refs.map.innerHTML = ""; } this.map = null; + this.tileLayer = null; if (resetVendor) { this.mapVendor = ""; this.mapsApi = null; @@ -817,4 +1052,4 @@ export default { width: 100%; height: 420px; } - \ No newline at end of file + diff --git a/src/main.js b/src/main.js index 7a97f7d..939380a 100644 --- a/src/main.js +++ b/src/main.js @@ -20,7 +20,7 @@ import directive from "./directive"; // directive import plugins from "./plugins"; // plugins import { download } from "@/utils/ruoyi"; import { math } from "@/utils/math.js"; -import I18nPlugin from "@/lang"; +import I18nPlugin, { t as i18nT } from "@/lang"; import { getLanguage } from "@/utils/language"; const ELEMENT_LOCALE_MAP = { @@ -125,6 +125,10 @@ Vue.config.productionTip = false; if (typeof document !== "undefined") { document.documentElement.setAttribute("lang", currentLanguage); + const appTitle = i18nT("app.sidebarTitle"); + document.title = appTitle && appTitle !== "app.sidebarTitle" + ? appTitle + : process.env.VUE_APP_TITLE; } new Vue({ diff --git a/src/utils/dynamicTitle.js b/src/utils/dynamicTitle.js index 1b57efb..10574da 100644 --- a/src/utils/dynamicTitle.js +++ b/src/utils/dynamicTitle.js @@ -1,13 +1,24 @@ import store from '@/store' import defaultSettings from '@/settings' +import { t as i18nT } from '@/lang' + +function resolveAppTitle() { + const translated = i18nT('app.sidebarTitle') + return translated && translated !== 'app.sidebarTitle' + ? translated + : defaultSettings.title +} /** * 动态修改标题 */ export function useDynamicTitle() { + const appTitle = resolveAppTitle() if (store.state.settings.dynamicTitle) { - document.title = store.state.settings.title + ' - ' + defaultSettings.title + document.title = store.state.settings.title + ? (store.state.settings.title + ' - ' + appTitle) + : appTitle } else { - document.title = defaultSettings.title + document.title = appTitle } -} \ No newline at end of file +} diff --git a/src/views/device/device/index.vue b/src/views/device/device/index.vue index 308059a..2b9a3c2 100644 --- a/src/views/device/device/index.vue +++ b/src/views/device/device/index.vue @@ -69,10 +69,10 @@ {{ $t("device.button.batchEnable") }} - {{ $t("device.button.batchDisable") }} + {{ $t("device.button.batchDisable") }} - {{ $t("device.button.assign") }} + {{ $t("device.button.assign") }} diff --git a/src/views/system/user/components/ProfileSettingsCard.vue b/src/views/system/user/components/ProfileSettingsCard.vue index 3c366ef..133900c 100644 --- a/src/views/system/user/components/ProfileSettingsCard.vue +++ b/src/views/system/user/components/ProfileSettingsCard.vue @@ -32,23 +32,21 @@ - + - + @@ -57,22 +55,12 @@ - - {{ $t("profile.button.saveProfile") }} @@ -137,6 +125,7 @@ function createDefaultProfileForm() { return { id: undefined, businessId: undefined, + parentId: undefined, account: "", nickName: "", businessName: "", @@ -168,6 +157,18 @@ export default { passwordRules: {}, }; }, + computed: { + isEmployeeByParentId() { + const parentId = this.profileForm && this.profileForm.parentId; + if (parentId === undefined || parentId === null || parentId === "") { + return false; + } + return String(parentId) !== "0"; + }, + canEditMapConfig() { + return !this.isEmployeeByParentId; + }, + }, created() { this.initI18nState(); this.loadProfile(); @@ -213,10 +214,12 @@ export default { } const payload = { nickName: this.profileForm.nickName, - gaodeKey: this.profileForm.gaodeKey, - gaodeSecurityKey: this.profileForm.gaodeSecurityKey, - googleKey: this.profileForm.googleKey, }; + if (this.canEditMapConfig) { + payload.gaodeKey = this.profileForm.gaodeKey; + payload.gaodeSecurityKey = this.profileForm.gaodeSecurityKey; + payload.googleKey = this.profileForm.googleKey; + } this.profileSaving = true; updateUserProfile(payload) .then(() => { @@ -258,8 +261,4 @@ export default { .profile-settings-card { margin-bottom: 16px; } - -.profile-tip { - margin-bottom: 18px; -} diff --git a/src/views/system/user/index.vue b/src/views/system/user/index.vue index a7ccbb1..50898b4 100644 --- a/src/views/system/user/index.vue +++ b/src/views/system/user/index.vue @@ -129,10 +129,20 @@ @@ -251,6 +270,123 @@
+ + + + + + + + + + + + + + + + + + {{ $t("common.search") }} + {{ $t("common.reset") }} + + + + + + + {{ $t("systemUser.button.unbindDevices") }} + + + + + + + + + + + + + + + + + + + + + + + +