更新优化

This commit is contained in:
磷叶 2025-04-26 18:41:39 +08:00
parent 905350d0e7
commit 4f254348b8
13 changed files with 1310 additions and 1015 deletions

View File

@ -8,6 +8,7 @@ import './assets/styles/element-variables.scss';
import '@/assets/styles/index.scss'; // global css
import '@/assets/styles/ipad.scss'; // ipad global css
import '@/assets/styles/ruoyi.scss'; // ruoyi css
import globalConfig from '@/utils/config/globalConfig';
import { download } from '@/utils/request';
import App from './App';
import directive from './directive'; // directive
@ -99,6 +100,14 @@ new Vue({
})
// 高德地图
window._AMapSecurityConfig = {
securityJsCode: globalConfig.aMap.secret,
};
// 全局添加table左右拖动效果的指令
Vue.directive('tableMove', {
bind: function (el, binding, vnode) {

View File

@ -1,31 +1,5 @@
import { parseTime } from '@/utils/ruoyi';
/**
* 计算周年
*/
export function calcFullYear(date) {
if (date == null ) {
return 0;
}
let start = toDate(date);
let end = new Date();
return Math.floor((end.getTime() - start.getTime()) / 31536000000)
}
/**
* 计算年龄
*/
export function calcBirthDay(birthday) {
if (birthday === null) {
return 0;
}
let now = new Date();
let start = toDate(birthday);
return now.getFullYear() - start.getFullYear() + 1;
}
/**
* 转为日期
*/
@ -49,8 +23,6 @@ export function calcSecond(start, end) {
return Math.floor((endDate.getTime() - startDate.getTime()) / 1000);
}
// 获取前n天的日期
export function getLastDate(n) {
let now = new Date();

View File

@ -43,7 +43,7 @@
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
@ -83,6 +83,7 @@ export default {
AreaJoinType,
span: 24,
title: '',
submitLoading: false,
form: {
permissions: []
},
@ -154,12 +155,15 @@ export default {
submitForm() {
this.$refs.form.validate(valid => {
if (valid) {
this.submitLoading = true;
const promise = this.form.id != null ? updateAreaJoin(this.form) : addAreaJoin(this.form);
promise.then(response => {
this.$modal.msgSuccess(this.form.id != null ? "修改成功" : "新增成功");
this.dialogVisible = false;
this.$emit('success');
});
}).finally(() => {
this.submitLoading = false;
})
}
});
},

View File

@ -0,0 +1,212 @@
<template>
<div class="address-search">
<el-input
v-model="keyword"
placeholder="请输入地址"
size="mini"
clearable
@input="handleSearch"
@clear="clearSearch"
>
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
<!-- 搜索结果列表 -->
<div class="search-result" v-show="searchResults.length > 0">
<ul>
<li
v-for="(item, index) in searchResults"
:key="index"
@click="handleSelect(item)"
class="result-item"
>
<div class="item-name">{{ item.name }}</div>
<div class="item-address">{{ item.address }}</div>
</li>
</ul>
</div>
</div>
</template>
<script>
/**
* 地址搜索组件
* @component AddressSearch
* @description 用于在地图上搜索地址并定位
*/
export default {
name: 'AddressSearch',
props: {
//
map: {
type: Object,
required: true
},
AMap: {
type: Object,
required: true
}
},
data() {
return {
keyword: '', //
searchResults: [], //
autoComplete: null, //
placeSearch: null, //
searchTimeout: null //
}
},
mounted() {
this.initSearchPlugins()
},
methods: {
//
async initSearchPlugins() {
try {
//
this.autoComplete = new this.AMap.AutoComplete({
type: '', //
city: '全国', //
datatype: 'all', //
citylimit: false, //
input: 'keyword' //
});
this.placeSearch = new this.AMap.PlaceSearch({
map: this.map,
pageSize: 10,
citylimit: false,
autoFitView: true
});
//
this.autoComplete.on('error', (error) => {
console.error('自动完成搜索错误:', error);
this.$message.error('搜索服务出现错误,请稍后重试');
});
this.placeSearch.on('error', (error) => {
console.error('地点搜索错误:', error);
this.$message.error('搜索服务出现错误,请稍后重试');
});
} catch (error) {
console.error('搜索插件初始化失败:', error);
this.$message.error('搜索服务初始化失败');
}
},
//
handleSearch() {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
//
this.searchTimeout = setTimeout(() => {
if (this.keyword.trim()) {
// 使 Autocomplete
this.autoComplete.search(this.keyword, (status, result) => {
if (status === 'complete' && result.tips) {
this.searchResults = result.tips.map(tip => ({
name: tip.name || '',
address: (tip.district || '') + (tip.address || ''),
location: tip.location
}));
} else {
this.searchResults = [];
console.warn('搜索无结果或失败');
}
});
} else {
this.clearSearch();
}
}, 300);
},
//
clearSearch() {
this.searchResults = []
this.placeSearch.clear()
},
//
handleSelect(item) {
if (item.location) {
// 使
this.map.setCenter([item.location.lng, item.location.lat]);
this.map.setZoom(15);
this.searchResults = [];
this.keyword = item.name;
} else {
// 使 PlaceSearch
this.placeSearch.search(item.name, (status, result) => {
if (status === 'complete' && result.poiList && result.poiList.pois.length > 0) {
const poi = result.poiList.pois[0];
this.map.setCenter([poi.location.lng, poi.location.lat]);
this.map.setZoom(15);
this.searchResults = [];
this.keyword = item.name;
} else {
this.$message.warning('无法获取该地址的具体位置');
}
});
}
}
}
}
</script>
<style lang="scss" scoped>
.address-search {
position: absolute;
top: 15px;
left: 15px;
width: 300px;
.search-result {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
border-radius: 4px;
margin-top: 5px;
max-height: 300px;
overflow-y: auto;
ul {
list-style: none;
padding: 0;
margin: 0;
}
.result-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #eee;
&:hover {
background-color: #f5f7fa;
}
&:last-child {
border-bottom: none;
}
.item-name {
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}
.item-address {
font-size: 12px;
color: #909399;
}
}
}
}
</style>

View File

@ -60,6 +60,34 @@ export const LABEL_STYLES = {
}
};
// 设备图标配置
const icons = {
disabled_offline: 'https://lxnapi.ccttiot.com/bike/img/static/ucBKG3ebYRAToVweJihu', // 禁用(离线)
disabled: "https://lxnapi.ccttiot.com/bike/img/static/uyK7Vg4Lu8xb3oNVuG2l", // 禁用
dispatching_offline: "https://lxnapi.ccttiot.com/bike/img/static/uhZudZM3nEKj0tYKlho2", // 调度中(离线)
dispatching: "https://lxnapi.ccttiot.com/bike/img/static/ujur6TezvPf4buFAqPHo", // 调度中
temp_lock_offline: "https://lxnapi.ccttiot.com/bike/img/static/uRod2zf3t9dAOYafWoWt", // 临时锁车(离线)
temp_lock: "https://lxnapi.ccttiot.com/bike/img/static/uZpXq3TBtM5gVgJJeImY", // 临时锁车
in_use_offline: "https://lxnapi.ccttiot.com/bike/img/static/uG13E7BpUFF44wVYC9no", // 骑行中(离线)
in_use: "https://lxnapi.ccttiot.com/bike/img/static/uHQIdWCTmtUztl49wBKU", // 骑行中
reserved_offline: "https://lxnapi.ccttiot.com/bike/img/static/uR3DQEssiK62ovhh88y8", // 预约中(离线)
reserved: "https://lxnapi.ccttiot.com/bike/img/static/u460R1NKWHEpHbt0U4H7", // 预约中
available_offline: "https://lxnapi.ccttiot.com/bike/img/static/uzhMeExOQJbMcZtrfGUV", // 待骑行(离线)
available: "https://lxnapi.ccttiot.com/bike/img/static/uheL17wVZn24BwCwEztT", // 待骑行
storage_offline: "https://lxnapi.ccttiot.com/bike/img/static/uQRng4QNKA38Amk8Wgt5", // 仓库中(离线)
storage: "https://lxnapi.ccttiot.com/bike/img/static/uocjFo8Ar2BJVpzC2G2f", // 仓库中
};
// 设备图标映射
export const DEVICE_STATUS_ICONS = {
[DeviceStatus.STORAGE]: icons.storage, // 仓库中
[DeviceStatus.AVAILABLE]: icons.available, // 待骑行
[DeviceStatus.IN_USE]: icons.in_use, // 骑行中
[DeviceStatus.TEMP_LOCKED]: icons.temp_lock, // 临时锁车
[DeviceStatus.DISPATCHING]: icons.dispatching,// 调度中
[DeviceStatus.DISABLED]: icons.disabled, // 禁用
[DeviceStatus.Q_LOCKED]: icons.temp_lock, // 强制锁车
};
// SN标签样式
export function getSnLabel(AMap, sn, position) {
@ -82,30 +110,80 @@ export function getSnLabel(AMap, sn, position) {
});
}
const icons = {
disabled_offline: 'https://lxnapi.ccttiot.com/bike/img/static/ucBKG3ebYRAToVweJihu', // 禁用(离线)
disabled: "https://lxnapi.ccttiot.com/bike/img/static/uyK7Vg4Lu8xb3oNVuG2l", // 禁用
dispatching_offline: "https://lxnapi.ccttiot.com/bike/img/static/uhZudZM3nEKj0tYKlho2", // 调度中(离线)
dispatching: "https://lxnapi.ccttiot.com/bike/img/static/ujur6TezvPf4buFAqPHo", // 调度中
temp_lock_offline: "https://lxnapi.ccttiot.com/bike/img/static/uRod2zf3t9dAOYafWoWt", // 临时锁车(离线)
temp_lock: "https://lxnapi.ccttiot.com/bike/img/static/uZpXq3TBtM5gVgJJeImY", // 临时锁车
in_use_offline: "https://lxnapi.ccttiot.com/bike/img/static/uG13E7BpUFF44wVYC9no", // 骑行中(离线)
in_use: "https://lxnapi.ccttiot.com/bike/img/static/uHQIdWCTmtUztl49wBKU", // 骑行中
reserved_offline: "https://lxnapi.ccttiot.com/bike/img/static/uR3DQEssiK62ovhh88y8", // 预约中(离线)
reserved: "https://lxnapi.ccttiot.com/bike/img/static/u460R1NKWHEpHbt0U4H7", // 预约中
available_offline: "https://lxnapi.ccttiot.com/bike/img/static/uzhMeExOQJbMcZtrfGUV", // 待骑行(离线)
available: "https://lxnapi.ccttiot.com/bike/img/static/uheL17wVZn24BwCwEztT", // 待骑行
storage_offline: "https://lxnapi.ccttiot.com/bike/img/static/uQRng4QNKA38Amk8Wgt5", // 仓库中(离线)
storage: "https://lxnapi.ccttiot.com/bike/img/static/uocjFo8Ar2BJVpzC2G2f", // 仓库中
}
// 公共方法
export const mapMethods = {
// 开始绘制(不管是那种边界)
startEditArea() {
this.isEditing = true;
// 设备图标映射
export const DEVICE_STATUS_ICONS = {
[DeviceStatus.STORAGE]: icons.storage, // 仓库中
[DeviceStatus.AVAILABLE]: icons.available, // 待骑行
[DeviceStatus.IN_USE]: icons.in_use, // 骑行中
[DeviceStatus.TEMP_LOCKED]: icons.temp_lock, // 临时锁车
[DeviceStatus.DISPATCHING]: icons.dispatching,// 调度中
[DeviceStatus.DISABLED]: icons.disabled, // 禁用
[DeviceStatus.Q_LOCKED]: icons.temp_lock, // 强制锁车
// 关闭当前信息窗口
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
},
// 清理方法优化
clearOverlays(overlayMap) {
if (overlayMap == null || overlayMap.size == 0) {
return;
}
overlayMap.forEach(overlay => {
this.map.remove(overlay);
});
overlayMap.clear();
},
// 获取位置
getPosition(item) {
if (item == null) {
return null;
}
if (item.longitude == null || item.latitude == null) {
return null;
}
return [item.longitude, item.latitude];
},
// 聚焦
focus(polygon, position) {
if (polygon) {
const bounds = polygon.getBounds();
const padding = [150, 300, 150, 300];
this.map.setBounds(bounds, false, padding);
} else if (position) {
this.map.setZoomAndCenter(15, position, false, 300);
}
},
// 计算多边形中心点
calculateCenter(path) {
if (!path || path.length === 0) return null;
let total = path.length;
let X = 0, Y = 0, Z = 0;
path.forEach(point => {
let lng = (point[0] * Math.PI) / 180;
let lat = (point[1] * Math.PI) / 180;
let x = Math.cos(lat) * Math.cos(lng);
let y = Math.cos(lat) * Math.sin(lng);
let z = Math.sin(lat);
X += x;
Y += y;
Z += z;
});
X = X / total;
Y = Y / total;
Z = Z / total;
let Lng = Math.atan2(Y, X);
let Hyp = Math.sqrt(X * X + Y * Y);
let Lat = Math.atan2(Z, Hyp);
return [
(Lng * 180) / Math.PI,
(Lat * 180) / Math.PI
];
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,347 @@
import { isEmpty } from "@/utils/index";
import { DEVICE_STATUS_ICONS, getSnLabel } from "@/views/bst/areaSub/components/AreaMap";
export default {
data() {
return {
isPlaybackVisible: false,
currentLogIndex: 0,
currentSpeed: 0,
isPlaying: false,
playbackSpeed: 1,
playbackTimer: null,
currentPolylines: [], // 修改为数组,存储多段轨迹
currentDeviceMarker: null,
currentDeviceIcon: null, // 新增:存储当前设备图标对象
currentLog: null,
sortedLocationLogs: [],
progressPolylines: [], // 修改为数组,存储多段进度轨迹
deviceSegments: [], // 新增存储按设备ID分段的轨迹数据
};
},
watch: {
// 监听播放状态
isPlaying(newVal) {
if (newVal) {
this.startPlayback();
} else {
this.pausePlayback();
}
}
},
methods: {
// 初始化轨迹播放
initPlayback() {
if (!this.map) {
return;
}
// 清除现有轨迹和设备标记
if (this.currentPolylines.length > 0) {
this.currentPolylines.forEach(polyline => {
this.map.remove(polyline);
});
this.currentPolylines = [];
}
if (this.progressPolylines.length > 0) {
this.progressPolylines.forEach(polyline => {
this.map.remove(polyline);
});
this.progressPolylines = [];
}
if (this.currentDeviceMarker) {
if (this.currentDeviceMarker.snLabel) {
this.map.remove(this.currentDeviceMarker.snLabel);
}
this.map.remove(this.currentDeviceMarker);
this.currentDeviceMarker = null;
}
if (isEmpty(this.locationLogList)) {
this.sortedLocationLogs = [];
this.currentLogIndex = 0;
this.currentLog = null;
return;
} else {
this.currentLogIndex = this.locationLogList.length - 1;
}
// 按时间排序日志并预计算速度
this.sortedLocationLogs = [...this.locationLogList]
.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime())
.map((log, index, array) => {
if (index === 0) {
log.speed = 0;
} else {
const prevLog = array[index - 1];
const prevTime = new Date(prevLog.at).getTime();
const currentTime = new Date(log.at).getTime();
const timeDiff = (currentTime - prevTime) / 1000; // 转换为秒
if (timeDiff === 0) {
log.speed = 0;
} else {
const distance = this.calculateDistance(
prevLog.latitude, prevLog.longitude,
log.latitude, log.longitude
);
log.speed = Number(((distance / timeDiff) * 3.6).toFixed(1)); // 转换为km/h并保留一位小数
}
}
return log;
});
this.updateCurrentLog();
this.drawTrajectory();
},
// 计算两点之间的距离
calculateDistance(lat1, lon1, lat2, lon2) {
if (this.AMap) {
const start = new this.AMap.LngLat(lon1, lat1);
const end = new this.AMap.LngLat(lon2, lat2);
return start.distance(end);
}
return 0;
},
// 更新当前日志
updateCurrentLog() {
if (this.sortedLocationLogs.length === 0) {
return;
}
this.currentLog = this.sortedLocationLogs[this.currentLogIndex];
this.updateDeviceMarker();
},
// 更新设备标记
updateDeviceMarker() {
if (!this.currentLog || !this.map) {
return;
}
const position = [this.currentLog.longitude, this.currentLog.latitude];
if (!this.currentDeviceMarker) {
// 创建图标对象
this.currentDeviceIcon = new this.AMap.Icon({
image: DEVICE_STATUS_ICONS[this.currentLog.status],
size: new this.AMap.Size(32, 36),
imageSize: new this.AMap.Size(32, 36),
anchor: 'bottom-center'
});
// 首次创建设备标记
this.currentDeviceMarker = new this.AMap.Marker({
position: position,
offset: new this.AMap.Pixel(-16, -36),
icon: this.currentDeviceIcon
});
// 保存当前设备ID
this.currentDeviceMarker.deviceId = this.currentLog.deviceId;
// 创建SN标签
const snLabel = getSnLabel(this.AMap, this.currentLog.sn, position);
this.map.add([this.currentDeviceMarker, snLabel]);
// 保存snLabel引用
this.currentDeviceMarker.snLabel = snLabel;
} else {
// 更新位置
this.currentDeviceMarker.setPosition(position);
// 只有当状态发生变化时才更新图标
if (this.currentDeviceIcon.getImage() !== DEVICE_STATUS_ICONS[this.currentLog.status]) {
this.currentDeviceIcon.setImage(DEVICE_STATUS_ICONS[this.currentLog.status]);
this.currentDeviceMarker.setIcon(this.currentDeviceIcon);
}
// 更新设备ID
this.currentDeviceMarker.deviceId = this.currentLog.deviceId;
// 更新SN标签位置和内容
if (this.currentDeviceMarker.snLabel) {
this.currentDeviceMarker.snLabel.setPosition(position);
// 只有当SN发生变化时才更新文本
if (this.currentDeviceMarker.snLabel.getText() !== this.currentLog.sn) {
this.currentDeviceMarker.snLabel.setText(this.currentLog.sn);
}
}
}
// 更新进度轨迹
this.updateProgressTrajectory();
},
// 绘制轨迹
drawTrajectory() {
// 清除现有轨迹
this.currentPolylines.forEach(polyline => {
this.map.remove(polyline);
});
this.progressPolylines.forEach(polyline => {
this.map.remove(polyline);
});
this.currentPolylines = [];
this.progressPolylines = [];
// 按设备ID分段
let currentDeviceId = null;
let currentSegment = [];
this.deviceSegments = [];
this.sortedLocationLogs.forEach(log => {
if (currentDeviceId !== log.deviceId) {
if (currentSegment.length > 0) {
this.deviceSegments.push(currentSegment);
}
currentSegment = [log];
currentDeviceId = log.deviceId;
} else {
currentSegment.push(log);
}
});
// 添加最后一段
if (currentSegment.length > 0) {
this.deviceSegments.push(currentSegment);
}
// 为每个段落创建轨迹线
this.deviceSegments.forEach(segment => {
const path = segment.map(log => [log.longitude, log.latitude]);
// 创建完整轨迹
const polyline = new this.AMap.Polyline({
path: path,
strokeColor: '#409EFF',
strokeWeight: 6,
strokeOpacity: 0.4,
showDir: true
});
// 创建进度轨迹
const progressPolyline = new this.AMap.Polyline({
path: path,
strokeColor: '#67C23A',
strokeWeight: 6,
strokeOpacity: 0.8,
showDir: true
});
this.currentPolylines.push(polyline);
this.progressPolylines.push(progressPolyline);
this.map.add([polyline, progressPolyline]);
});
// 调整地图视野以显示所有轨迹
if (this.currentPolylines.length > 0) {
this.map.setFitView(this.currentPolylines);
}
},
// 更新轨迹进度条
updateProgressTrajectory() {
if (this.currentLogIndex < 0) return;
// 找到当前日志所在的段落
let currentSegmentIndex = -1;
let accumulatedCount = 0;
for (let i = 0; i < this.deviceSegments.length; i++) {
accumulatedCount += this.deviceSegments[i].length;
if (this.currentLogIndex < accumulatedCount) {
currentSegmentIndex = i;
break;
}
}
if (currentSegmentIndex === -1) {
return;
}
// 更新所有进度轨迹
this.progressPolylines.forEach((progressPolyline, index) => {
if (index < currentSegmentIndex) {
// 之前的段落完全显示
const segmentPath = this.deviceSegments[index].map(log => [log.longitude, log.latitude]);
progressPolyline.setPath(segmentPath);
progressPolyline.show();
progressPolyline.setOptions({
strokeOpacity: 0.8
});
} else if (index === currentSegmentIndex) {
// 当前段落显示到当前位置
const segmentStartIndex = index === 0 ? 0 : this.deviceSegments.slice(0, index).reduce((sum, seg) => sum + seg.length, 0);
const currentSegmentPosition = this.currentLogIndex - segmentStartIndex;
const progressPath = this.deviceSegments[index]
.slice(0, currentSegmentPosition + 1)
.map(log => [log.longitude, log.latitude]);
progressPolyline.setPath(progressPath);
progressPolyline.show();
progressPolyline.setOptions({
strokeOpacity: 0.8
});
} else {
// 后面的段落设置为不可见
const segmentPath = this.deviceSegments[index].map(log => [log.longitude, log.latitude]);
progressPolyline.setPath(segmentPath);
progressPolyline.setOptions({
strokeOpacity: 0
});
}
});
},
// 播放控制相关方法
togglePlaybackPanel() {
this.isPlaybackVisible = !this.isPlaybackVisible;
},
handleSliderChange(index) {
let oldIndex = this.currentLogIndex;
this.currentLogIndex = index;
if (oldIndex !== index) {
this.updateCurrentLog();
}
},
togglePlay() {
this.isPlaying = !this.isPlaying;
},
setPlaybackSpeed(speed) {
this.playbackSpeed = speed;
// 如果正在播放,立即应用新的速度
if (this.isPlaying) {
this.pausePlayback();
this.startPlayback();
}
},
startPlayback() {
if (this.playbackTimer) {
clearInterval(this.playbackTimer);
}
this.playbackTimer = setInterval(() => {
if (this.currentLogIndex >= this.sortedLocationLogs.length - 1) {
this.currentLogIndex = 0;
} else {
this.currentLogIndex++;
}
this.updateCurrentLog();
}, 1000 / this.playbackSpeed);
},
pausePlayback() {
if (this.playbackTimer) {
clearInterval(this.playbackTimer);
this.playbackTimer = null;
}
}
}
};

View File

@ -0,0 +1,87 @@
import { debounce } from "@/utils/index";
export default {
data() {
return {
areaPolygons: new Map(),
}
},
watch: {
// 监听区域数据变化
area: {
handler: debounce(function() {
this.renderAll();
}, 300),
deep: true
},
},
methods: {
// 渲染运营区边界
renderAreaBoundary() {
this.clearOverlays(this.areaPolygons);
if (!this.map || !this.area.boundaryStr) {
return;
};
try {
const boundary = JSON.parse(this.area.boundaryStr);
const polygon = new this.AMap.Polygon({
path: boundary,
strokeColor: '#2b8cbe',
strokeWeight: 2,
fillColor: '#ccebc5',
fillOpacity: 0.3,
strokeStyle: 'dashed',
strokeDasharray: [5, 5]
});
this.map.add(polygon);
this.areaPolygons.set(this.area.id, polygon);
} catch (error) {
console.error('渲染运营区边界失败:', error);
}
},
// 开始编辑运营区边界
startAreaBoundaryEdit() {
this.clearEditor();
this.focusArea(this.area);
if (this.area.boundaryStr) {
try {
const boundary = JSON.parse(this.area.boundaryStr);
this.editingPolygon = new this.AMap.Polygon({
path: boundary,
strokeColor: '#2b8cbe',
strokeWeight: 2,
fillColor: '#ccebc5',
fillOpacity: 0.3,
strokeStyle: 'dashed',
strokeDasharray: [5, 5]
});
this.map.add(this.editingPolygon);
this.polygonEditor.setTarget(this.editingPolygon);
} catch (error) {
console.error('加载运营区边界数据失败:', error);
this.$message.error('加载运营区边界数据失败');
}
}
this.polygonEditor.open();
this.isEditingArea = true;
this.startEditArea();
},
// 聚焦运营区
focusArea(area) {
if (area == null) {
return;
}
const position = this.getPosition(area);
const polygon = this.areaPolygons.get(area.id);
this.focus(polygon, position);
}
}
};

View File

@ -0,0 +1,335 @@
import { AreaSubStatus } from "@/utils/enums";
import { debounce } from "@/utils/index";
import { AREA_STYLES, HIGHLIGHT_STYLES, LABEL_STYLES, MARKER_ICONS } from "@/views/bst/areaSub/components/AreaMap";
export default {
data() {
return {
highlightedAreaId: null, // 当前鼠标悬停的区域ID
selectedAreaId: null, // 当前选中的区域ID
editSubArea: null,
currentInfoWindow: null,
subAreaPolygons: new Map(),
subAreaMarkers: new Map(),
subAreaLabels: new Map(),
};
},
watch: {
// 监听子区域列表变化
areaSubList: {
handler: debounce(function() {
this.renderAll();
}, 300),
deep: true
},
// 监听高亮状态变化
highlightedAreaId: {
handler(newVal, oldVal) {
if (oldVal) {
this.updateAreaStyle(oldVal);
}
if (newVal) {
this.updateAreaStyle(newVal);
}
}
},
// 监听选中状态变化
selectedAreaId: {
handler(newVal, oldVal) {
if (oldVal) {
this.updateAreaStyle(oldVal);
}
if (newVal) {
this.updateAreaStyle(newVal);
}
}
}
},
methods: {
initSubArea() {
// 添加地图点击事件,关闭信息窗体和清除选中状态
this.map.on('click', () => {
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
if (!this.isEditing) {
this.selectedAreaId = null;
}
});
this.renderSubAreas();
},
// 渲染子区域
renderSubAreas() {
this.clearHighlight();
this.clearOverlays(this.subAreaPolygons);
this.clearOverlays(this.subAreaMarkers);
this.clearOverlays(this.subAreaLabels);
if (!this.map || !this.areaSubList.length) {
return;
}
this.areaSubList.forEach(subArea => {
if (subArea.status === AreaSubStatus.DISABLED) {
return;
}
try {
// 渲染边界
if (subArea.boundaryStr) {
const boundary = JSON.parse(subArea.boundaryStr);
const polygon = new this.AMap.Polygon({
path: boundary,
...AREA_STYLES[subArea.type],
extData: {
id: subArea.id,
type: subArea.type
}
});
// 添加事件监听
polygon.on('mouseover', () => {
if (!this.isEditing) {
this.highlightedAreaId = subArea.id;
}
});
polygon.on('mouseout', () => {
if (!this.isEditing) {
this.highlightedAreaId = null;
}
});
polygon.on('click', (e) => {
if (this.isDebugMode) {
// 在调试模式下,不阻止事件冒泡
return;
}
if (!this.isEditing) {
// 如果点击的是当前选中的区域,不做任何操作
if (this.selectedAreaId !== subArea.id) {
this.selectedAreaId = subArea.id;
this.showInfoWindow(subArea);
}
}
});
this.map.add(polygon);
this.subAreaPolygons.set(subArea.id, polygon);
}
// 渲染标记点
const marker = new this.AMap.Marker({
position: [subArea.longitude, subArea.latitude],
icon: new this.AMap.Icon({
image: MARKER_ICONS[subArea.type],
size: new this.AMap.Size(25, 36),
imageSize: new this.AMap.Size(25, 36)
}),
offset: new this.AMap.Pixel(-12.5, -36)
});
marker.on('click', (e) => {
if (this.isDebugMode) {
// 在调试模式下,不阻止事件冒泡
return;
}
if (!this.isEditing) {
this.showInfoWindow(subArea);
}
});
this.map.add(marker);
this.subAreaMarkers.set(subArea.id, marker);
// 渲染文字标签
const label = new this.AMap.Text({
text: subArea.name,
position: [subArea.longitude, subArea.latitude],
offset: new this.AMap.Pixel(0, -60),
anchor: 'center',
cursor: 'pointer',
style: {
...LABEL_STYLES[subArea.type],
border: 'none',
fontSize: '12px',
padding: '2px 8px',
borderRadius: '4px',
whiteSpace: 'nowrap',
textAlign: 'center',
position: 'absolute',
transform: 'translateX(-50%)',
minWidth: '50px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}
});
label.on('click', (e) => {
if (this.isDebugMode) {
// 在调试模式下,不阻止事件冒泡
return;
}
if (!this.isEditing) {
this.showInfoWindow(subArea);
}
});
this.map.add(label);
this.subAreaLabels.set(subArea.id, label);
label.setMap(this.showLabels ? this.map : null);
} catch (error) {
console.error(`渲染子区域失败 [${subArea.id}]:`, error);
}
});
},
// 切换标签显示
toggleLabels() {
this.showLabels = !this.showLabels;
this.subAreaLabels.forEach(label => {
label.setMap(this.showLabels ? this.map : null);
});
},
// 开始边界编辑
startBoundaryEdit(subArea, focus = false) {
this.clearEditor();
this.editSubArea = subArea;
if (focus) {
this.focusSubArea(subArea);
}
if (subArea && subArea.boundaryStr) {
try {
const boundary = JSON.parse(subArea.boundaryStr);
this.editingPolygon = new this.AMap.Polygon({
path: boundary,
...AREA_STYLES[subArea.type]
});
this.map.add(this.editingPolygon);
this.polygonEditor.setTarget(this.editingPolygon);
} catch (error) {
console.error('加载边界数据失败:', error);
this.$message.error('加载边界数据失败');
}
}
this.polygonEditor.open();
this.startEditArea();
},
// 显示信息窗体
showInfoWindow(subArea, focus = false) {
if (!this.enableEdit) {
return;
}
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
const position = this.getPosition(subArea);
if (position == null) {
return;
}
// 聚焦效果
if (focus) {
this.focusSubArea(subArea);
} else {
this.map.setCenter(position);
}
// 创建自定义内容使用Vue的事件处理方式
const content = document.createElement('div');
content.className = 'map-info-window';
content.innerHTML = `
<h4>${subArea.name}</h4>
<div class="info-actions">
<a class="info-action" data-action="edit">编辑信息</a>
<a class="info-action" data-action="boundary">修改边界</a>
<a class="info-action" data-action="delete">删除</a>
</div>
`;
// 直接绑定事件
content.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('info-action')) {
const action = target.getAttribute('data-action');
this.handleInfoAction(action, subArea);
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
}
});
this.currentInfoWindow = new this.AMap.InfoWindow({
content: content,
offset: new this.AMap.Pixel(0, -30),
closeWhenClickMap: true
});
// 添加关闭事件监听
this.currentInfoWindow.on('close', () => {
if (!this.isEditing) {
this.selectedAreaId = null;
}
});
this.currentInfoWindow.open(this.map, position);
},
// 聚焦子区域
focusSubArea(subArea) {
if (subArea == null) {
return;
}
let position = this.getPosition(subArea);
const polygon = this.subAreaPolygons.get(subArea.id);
this.focus(polygon, position);
},
// 处理信息窗体操作
handleInfoAction(action, subArea) {
console.log("action", action);
switch (action) {
case 'edit':
this.$emit('edit', subArea);
break;
case 'boundary':
this.startBoundaryEdit(subArea, true);
break;
case 'delete':
this.$emit('delete', subArea);
break;
}
},
// 更新区域样式
updateAreaStyle(areaId) {
const polygon = this.subAreaPolygons.get(areaId);
if (!polygon) return;
const type = polygon.getExtData().type;
let style = { ...AREA_STYLES[type] };
// 根据状态叠加高亮样式
if (areaId === this.selectedAreaId) {
style = { ...style, ...HIGHLIGHT_STYLES.selected };
} else if (areaId === this.highlightedAreaId) {
style = { ...style, ...HIGHLIGHT_STYLES.hover };
}
polygon.setOptions(style);
},
// 清除所有高亮状态
clearHighlight() {
this.highlightedAreaId = null;
this.selectedAreaId = null;
}
}
};

View File

@ -17,22 +17,6 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="queryParams.deviceName"
placeholder="请输入设备名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="型号" prop="modelName">
<el-input
v-model="queryParams.modelName"
placeholder="请输入型号名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="车牌号" prop="vehicleNum">
<el-input
v-model="queryParams.vehicleNum"
@ -41,23 +25,6 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="运营区" prop="areaId">
<area-remote-select
v-model="queryParams.areaId"
clearable
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="在线状态" prop="onlineStatus">
<el-select v-model="queryParams.onlineStatus" placeholder="请选择在线状态" clearable @change="handleQuery">
<el-option
v-for="dict in dict.type.device_online_status"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="车辆状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择车辆状态" clearable @change="handleQuery">
<el-option
@ -68,16 +35,6 @@
/>
</el-select>
</el-form-item>
<el-form-item label="锁状态" prop="lockStatus">
<el-select v-model="queryParams.lockStatus" placeholder="请选择锁状态" clearable @change="handleQuery">
<el-option
v-for="dict in dict.type.device_lock_status"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="运营商" prop="mchName">
<el-input
v-model="queryParams.mchName"
@ -86,10 +43,35 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="物联网状态" prop="iotStatus" label-width="6em">
<el-select v-model="queryParams.iotStatus" placeholder="请选择物联网状态" clearable @change="handleQuery">
<el-form-item label="运营区" prop="areaId">
<area-remote-select
v-model="queryParams.areaId"
clearable
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="车型" prop="modelName">
<el-input
v-model="queryParams.modelName"
placeholder="请输入车型"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="在线状态" prop="onlineStatus">
<el-select v-model="queryParams.onlineStatus" placeholder="请选择在线状态" clearable @change="handleQuery">
<el-option
v-for="dict in dict.type.device_iot_status"
v-for="dict in dict.type.device_online_status"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="锁状态" prop="lockStatus">
<el-select v-model="queryParams.lockStatus" placeholder="请选择锁状态" clearable @change="handleQuery">
<el-option
v-for="dict in dict.type.device_lock_status"
:key="dict.value"
:label="dict.label"
:value="dict.value"

View File

@ -12,6 +12,7 @@
</el-col>
<el-col :span="6">
<el-date-picker
v-if="queryParams.timeRange != null && queryParams.timeRange[0] != null && queryParams.timeRange[1] != null"
v-model="queryParams.timeRange"
value-format="yyyy-MM-dd HH:mm:ss"
type="datetimerange"
@ -24,28 +25,45 @@
style="width: 100%;"
/>
<div class="location-log-list">
<div
@click="handleLogClick(log, index)"
v-for="(log, index) in locationLogList"
:key="index"
class="log-item"
>
<div class="log-time">{{ log.at }}</div>
<div class="log-info">
<span class="info-item">
<i class="el-icon-location"></i>
{{ log.longitude | dv }}, {{ log.latitude | dv }}
</span>
<span class="info-item">
<i class="el-icon-odometer"></i>
{{ log.voltage }}V
</span>
<span class="info-item">
{{ log.sn }}
<dict-tag :options="dict.type.device_status" :value="log.status" size="mini"/>
</span>
</div>
</div>
<el-collapse v-model="activeGroup" accordion>
<el-collapse-item
v-for="(group, groupIndex) in groupedLocationLogs"
:key="groupIndex"
:name="groupIndex"
>
<template slot="title">
<div class="group-header">
<span class="group-time">{{ group.startTime }} - {{ group.endTime }}</span>
<dict-tag :options="dict.type.device_status" :value="group.status" size="mini"/>
<span class="group-count">({{ group.logs.length }})</span>
</div>
</template>
<div class="group-content">
<div
v-for="(log, logIndex) in group.logs"
:key="logIndex"
@click="handleLogClick(log, getLogIndex(groupIndex, logIndex))"
class="log-item"
>
<div class="log-time">
<dict-tag :options="dict.type.device_status" :value="log.status" size="mini"/>
{{ log.at }}
</div>
<div class="log-info">
<span class="info-item">
<i class="el-icon-location"></i>
{{ log.longitude.toFixed(8) }}, {{ log.latitude.toFixed(8) }}
</span>
<span class="info-item">
<i class="el-icon-odometer"></i>
{{ log.voltage | fix2 }}V
</span>
<span class="info-item">{{ log.sn }}</span>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
<el-empty v-if="!locationLogList || locationLogList.length === 0" description="暂无数据" />
</div>
</el-col>
@ -84,6 +102,40 @@ export default {
timeRange: [parseTime(new Date(), '{y}-{m}-{d} 00:00:00'), parseTime(new Date(), '{y}-{m}-{d} 23:59:59')]
},
loading: false,
activeGroup: '', //
}
},
computed: {
groupedLocationLogs() {
if (!this.locationLogList || this.locationLogList.length === 0) {
return [];
}
const groups = [];
let currentGroup = {
status: this.locationLogList[0].status,
logs: [this.locationLogList[0]],
startTime: this.locationLogList[0].at,
endTime: this.locationLogList[0].at
};
for (let i = 1; i < this.locationLogList.length; i++) {
const currentLog = this.locationLogList[i];
if (currentLog.status === currentGroup.status) {
currentGroup.logs.push(currentLog);
currentGroup.endTime = currentLog.at;
} else {
groups.push(currentGroup);
currentGroup = {
status: currentLog.status,
logs: [currentLog],
startTime: currentLog.at,
endTime: currentLog.at
};
}
}
groups.push(currentGroup);
return groups;
}
},
created() {
@ -135,6 +187,22 @@ export default {
this.$refs.map.handleSliderChange(index);
});
}
},
getLogIndex(groupIndex, logIndex) {
let totalIndex = 0;
for (let i = 0; i < groupIndex; i++) {
totalIndex += this.groupedLocationLogs[i].logs.length;
}
return totalIndex + logIndex;
},
getStatusType(status) {
const statusMap = {
0: 'info', // 线
1: 'success', // 线
2: 'warning', //
3: 'danger' //
};
return statusMap[status] || 'info';
}
}
}
@ -148,12 +216,30 @@ export default {
border: 1px solid #EBEEF5;
border-radius: 4px;
.group-header {
font-size: 12px;
padding-left: 8px;
.group-time {
color: #606266;
margin-right: 8px;
}
.group-count {
margin-left: 8px;
color: #909399;
}
}
.log-item {
padding: 8px 12px;
border-bottom: 1px solid #EBEEF5;
cursor: pointer;
transition: all 0.3s;
&:not(:last-child) {
border-bottom: 1px solid #EBEEF5;
}
&:hover {
background-color: #F5F7FA;
}
@ -183,6 +269,30 @@ export default {
}
}
.el-collapse {
border-top: none;
border-bottom: none;
}
.el-collapse-item__header {
padding: 8px 12px;
background-color: #F5F7FA;
border-bottom: 1px solid #EBEEF5;
font-size: 12px;
&:hover {
background-color: #EBEEF5;
}
}
.el-collapse-item__content {
padding: 0;
}
.el-collapse-item__wrap {
border-bottom: none;
}
&::-webkit-scrollbar {
width: 6px;
}

View File

@ -0,0 +1,15 @@
import { calcSecond } from "@/utils/date";
export function getOrderDuration(order) {
if (order == null) {
return 0;
}
if (order.duration != null) {
return order.duration;
}
if (order.startTime != null) {
return calcSecond(order.startTime, new Date());
}
return 0;
}

View File

@ -43,7 +43,7 @@
<el-descriptions-item label="开始时间">{{ detail.startTime | dv}}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ detail.endTime | dv}}</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ detail.maxTime | dv}}</el-descriptions-item>
<el-descriptions-item label="骑行时长">{{ toDescriptionFromSecond(detail.duration).text | dv}}</el-descriptions-item>
<el-descriptions-item label="骑行时长">{{ orderDuration | dv}}</el-descriptions-item>
<el-descriptions-item label="骑行距离">{{ detail.distance / 1000 | fix2 | dv}} 公里</el-descriptions-item>
<el-descriptions-item label="结束原因" v-if="detail.endReason">{{ detail.endReason | dv }}</el-descriptions-item>
<el-descriptions-item label="取消原因" v-if="detail.cancelRemark">{{ detail.cancelRemark | dv }}</el-descriptions-item>
@ -206,6 +206,7 @@ import Operlog from '@/views/monitor/operlog/index.vue'
import CommandLog from '@/views/bst/commandLog/index.vue'
import DeviceLink from '@/components/Business/Device/DeviceLink.vue'
import UserLink from '@/components/Business/User/UserLink.vue'
import {getOrderDuration} from '@/views/bst/order/util'
export default {
name: 'OrderView',
@ -238,6 +239,11 @@ export default {
showVerifyDialog: false,
}
},
computed: {
orderDuration() {
return toDescriptionFromSecond(getOrderDuration(this.detail)).text;
}
},
created() {
this.id = this.$route.params.id
this.getDetail()