Browse Source

【新增】界面:禁飞区管理、制造商管理、监管者管理、登录

master
xiaosi 1 year ago
parent
commit
041f3ba7cb
  1. 14
      .idea/fileTemplates/[项目专用]常规组件.vue
  2. BIN
      src/assets/login_bg.png
  3. 231
      src/components/BasePanel.vue
  4. 92
      src/components/ImageUploader.vue
  5. 29
      src/config/urls.js
  6. 66
      src/core/EventDispatcher.js
  7. 401
      src/core/ShapeCreator.js
  8. 562
      src/core/ShapeEditor.js
  9. 554
      src/core/ShapeRenderer.js
  10. 90
      src/core/mapHelper.js
  11. 10
      src/layout/MainContainer.vue
  12. 28
      src/layout/MapLayer.vue
  13. 167
      src/layout/components/MultifunctionalBar.vue
  14. 51
      src/layout/components/SideMenu.vue
  15. 66
      src/layout/components/TopBar.vue
  16. 84
      src/layout/components/ZoomBar.vue
  17. 40
      src/router/index.js
  18. 4
      src/stores/index.js
  19. 58
      src/stores/modules/authStore.js
  20. 121
      src/stores/modules/manufacturerStore.js
  21. 109
      src/stores/modules/noFlyZoneStore.js
  22. 51
      src/stores/modules/regulatorStore.js
  23. 111
      src/styles/common.less
  24. 29
      src/utils/UserInfo.js
  25. 46
      src/utils/commonRefs.js
  26. 180
      src/utils/helpers.js
  27. 125
      src/views/LoginView/LoginView.vue
  28. 131
      src/views/ManufacturerView/ManufacturerView.vue
  29. 152
      src/views/ManufacturerView/components/ManufacturerDetail.vue
  30. 155
      src/views/ManufacturerView/components/ManufacturerEditor.vue
  31. 261
      src/views/NoFlyZoneEditorView/NoFlyZoneEditorView.vue
  32. 244
      src/views/NoFlyZoneView/NoFlyZoneView.vue
  33. 86
      src/views/RegulatorView/RegulatorView.vue
  34. 90
      src/views/RegulatorView/components/RegulatorEditor.vue
  35. 23
      src/views/common/useGlobalSettings.js

14
.idea/fileTemplates/[项目专用]常规组件.vue

@ -0,0 +1,14 @@
<script setup>
</script>
<template>
<div :class="s.root">
#[[$END$]]#
</div>
</template>
<style lang="less" module="s">
.root {
//
}
</style>

BIN
src/assets/login_bg.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

231
src/components/BasePanel.vue

@ -0,0 +1,231 @@
<script setup>
import { ref, useSlots } from 'vue';
// import { useRoute, useRouter } from 'vue-router';
const emit = defineEmits(['toggle']);
const props = defineProps({
width: {
type: String,
default: '100%',
},
minWidth: {
type: String,
default: 'auto',
},
// fullWidth: {
// type: Boolean,
// default: true,
// },
open: {
type: Boolean,
default: true,
},
scrollbar: {
type: Boolean,
default: false,
},
expandAbility: {
type: Boolean,
default: false,
},
});
const { 'top-bar': topBar, footer, header, 'header-extra': headerExtra } = useSlots();
const isOpen = ref(false);
// const finalWidth = computed(() => (props.fullWidth ? 'calc(100% - 70px)' : props.width));
isOpen.value = props.open;
// onMounted(() => {
// setTimeout(() => {
// isOpen.value = props.open;
// }, 200);
// });
// const route = useRoute();
// const router = useRouter();
// const isActive = (routeName) => {
// const { group } = route.meta;
// return route.name === routeName || group === routeName;
// };
function onToggle() {
isOpen.value = !isOpen.value;
emit('toggle', isOpen.value);
}
// function onNavTo(routeName) {
// isOpen.value = false;
// setTimeout(() => {
// router.push({ name: routeName });
// }, 200);
// }
</script>
<template>
<div :class="[s.root, isOpen ? 'is-open' : '', expandAbility ? 'expand' : '']" :style="{ '--width': width, 'min-width': minWidth }">
<div class="wrapper">
<div class="header" v-if="!!header || !!headerExtra">
<t-space size="small">
<slot name="header" />
</t-space>
<div class="interspace" />
<slot name="header-extra" />
</div>
<div class="top-bar" v-if="!!topBar">
<slot name="top-bar" />
</div>
<div class="body" v-scrollbar:[props.scrollbar].padding>
<slot />
</div>
<div class="footer" v-if="!!footer">
<slot name="footer" />
</div>
</div>
<div class="nav" v-if="expandAbility">
<t-tooltip placement="left" :delay="0" :content="isOpen ? '收起' : '展开'">
<div class="btn" @click="onToggle">
<t-icon name="chevron-right-double" v-if="isOpen" />
<t-icon name="chevron-left-double" v-else />
</div>
</t-tooltip>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
pointer-events: auto;
position: absolute;
right: 0;
top: 0;
bottom: 0;
//z-index: 1;
color: var(--td-text-color-primary);
width: var(--width);
background-color: var(--td-bg-color-page);
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
box-sizing: border-box;
transition: all 0.2s ease;
&:global.expand{
transform: translateX(100%);
}
&:global.is-open {
transform: translateX(0px);
}
:global {
.wrapper {
height: 100%;
//background-color: var(--td-bg-color-container);
background-color: transparent;
//border-radius: var(--td-radius-medium);
//border-radius: var(--td-radius-medium);
//border-left-color: var(--td-gray-color-10);
//border-left: 1px solid var(--td-component-stroke);
//backdrop-filter: blur(20px);
display: flex;
flex-direction: column;
//overflow: hidden;
.header {
display: flex;
justify-content: center;
align-items: center;
min-height: var(--td-comp-size-xxl);
font-size: var(--td-font-size-title-large);
background-color: var(--td-bg-color-container);
//background-color: fade(black, 30%);
border-radius: var(--td-radius-medium);
box-sizing: border-box;
//box-shadow: 0 0 5px 0 rgba(174, 174, 174, 60%);
box-shadow: 0 0 1px 0 var(--td-component-border);
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
.interspace {
flex: 1;
}
.t-space .t-icon {
font-size: var(--td-font-size-headline-medium);
}
}
.top-bar {
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
}
.body {
flex: 1;
//padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
overflow: auto;
//box-shadow: 0 0 5px 0 rgba(174, 174, 174, 60%);
box-shadow: 0 0 1px 0 var(--td-component-border);
background-color: var(--td-bg-color-container);
border-radius: var(--td-radius-medium);
}
.footer {
display: flex;
justify-content: center;
align-items: center;
min-height: var(--td-comp-size-xxl);
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
//background-color: fade(black, 30%);
box-sizing: border-box;
}
}
.nav {
position: absolute;
right: 100%;
//left: 100%;
top: var(--td-comp-paddingTB-m);
.btn {
width: var(--td-comp-size-l);
min-height: var(--td-comp-size-m);
padding: var(--td-comp-paddingTB-m) 0;
background-color: var(--td-bg-color-container);
border-right: 1px solid var(--td-bg-color-container);
border-radius: var(--td-radius-medium) 0 0 var(--td-radius-medium);
backdrop-filter: blur(10px);
transition: background-color ease 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-sizing: border-box;
cursor: pointer;
&:hover {
background-color: var(--td-brand-color-hover);
}
& + .btn {
border-top: 1px solid fade(black, 50%);
}
span {
display: none;
width: 1em;
font-size: var(--td-font-size-link-medium);
line-height: 1.2;
margin-top: 5px;
}
&.is-active {
background-color: var(--td-brand-color-active);
border-top-color: transparent;
span {
display: block;
}
}
}
}
}
}
</style>

92
src/components/ImageUploader.vue

@ -0,0 +1,92 @@
<script setup>
import { ref, watchEffect } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { REQUEST_UPLOAD_FILE } from '@/config/urls';
import auth from '@/utils/auth';
const props = defineProps({
modelValue: {
type: [String, Array],
default: '',
},
subject: {
type: String,
required: true,
},
multiple: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const originFile = ref([]);
watchEffect(() => {
if (props.multiple && props.modelValue) {
originFile.value = [...(props.modelValue || []).map((url) => ({ name: 'image', url }))];
} else if (props.modelValue) {
originFile.value = [{ name: 'image', url: props.modelValue }];
} else {
originFile.value = [];
}
});
const uploadRef = ref();
const handleFail = () => {
MessagePlugin.error('文件上传失败');
};
const handleSuccess = ({ response }) => {
if (props.multiple) {
const [{ data }] = response;
const temp = [...(props.modelValue || [])];
temp.push(data);
emit('update:modelValue', temp);
return;
}
const { data } = response;
emit('update:modelValue', data);
};
const handleRemove = ({ index }) => {
if (props.multiple) {
const temp = [...(props.modelValue || [])];
temp.splice(index, 1);
emit('update:modelValue', temp);
return;
}
emit('update:modelValue', '');
};
</script>
<template>
<t-upload
:class="s.root"
ref="uploadRef"
v-model="originFile"
:action="REQUEST_UPLOAD_FILE(subject)"
theme="image"
accept="image/*"
:headers="{
Authorization: `Bearer ${auth.getToken()}`,
}"
:locale="{
triggerUploadText: {
image: '请选择图片',
},
}"
:multiple="multiple"
@fail="handleFail"
@success="handleSuccess"
@remove="handleRemove"
/>
</template>
<style lang="less" module="s">
.root {
:global {
.t-upload__card-name {
display: none
}
}
}
</style>

29
src/config/urls.js

@ -2,8 +2,35 @@
* 接口地址列表
*/
import { buildURL } from '@/utils/helpers';
const { APP_DEVELOPMENT_BASE_URL, APP_PRODUCTION_BASE_URL, MODE } = import.meta.env; // 获取环境变量
export const { [MODE]: BASE_URL = APP_DEVELOPMENT_BASE_URL } = { development: APP_DEVELOPMENT_BASE_URL, production: APP_PRODUCTION_BASE_URL }; // 声明变量 BASE_URL 并赋值
// 上传文件
export const REQUEST_UPLOAD_FILE = (subject) => buildURL(`${BASE_URL}/fileServ/v1/files/{fileType}`, subject);
// 登录
export const EXAMPLE_API = `${BASE_URL}xxxxxxxx`;
export const LOGIN_WITH_PASSWORD = `${BASE_URL}/userServ/v1/login`;
// 禁飞区管理
export const GET_NO_FLY_ZONE_LIST = `${BASE_URL}/mainServ/v1/noFlyZones`;
export const GET_NO_FLY_ZONE_DETAIL = `${BASE_URL}/mainServ/v1/noFlyZones/{id}`;
export const CREATE_NO_FLY_ZONE = `${BASE_URL}/mainServ/v1/noFlyZones`;
export const UPDATE_NO_FLY_ZONE = `${BASE_URL}/mainServ/v1/noFlyZones/{id}`;
export const DELETE_NO_FLY_ZONE = `${BASE_URL}/mainServ/v1/noFlyZones/{id}`;
export const UPDATE_NO_FLY_ZONE_DISABLE = `${BASE_URL}/mainServ/v1/noFlyZones/{id}/disable`;
// 制造商管理
export const GET_MANUFACTURER_LIST = `${BASE_URL}/mainServ/v1/manufacturers`;
export const CREATE_MANUFACTURER = `${BASE_URL}/mainServ/v1/manufacturers`;
export const UPDATE_MANUFACTURER = `${BASE_URL}/mainServ/v1/manufacturers/{id}`;
export const DELETE_MANUFACTURER = `${BASE_URL}/mainServ/v1/manufacturers/{id}`;
export const UPDATE_MANUFACTURER_DISABLE = `${BASE_URL}/mainServ/v1/manufacturers/{id}/disable`;
// 制造商管理
export const GET_REGULATOR_LIST = `${BASE_URL}/userServ/v1/regulators`;
export const CREATE_REGULATOR = `${BASE_URL}/userServ/v1/regulator`;
// export const UPDATE_REGULATOR = `${BASE_URL}/mainServ/v1/manufacturers/{id}`;
// export const DELETE_REGULATOR = `${BASE_URL}/mainServ/v1/manufacturers/{id}`;
// export const UPDATE_MANUFACTURER_DISABLE = `${BASE_URL}/mainServ/v1/manufacturers/{id}/disable`;

66
src/core/EventDispatcher.js

@ -0,0 +1,66 @@
/**
* 事件调度类
*/
class EventDispatcher {
// 支持的事件名
_supportedEventNames = ['click'];
// 事件池
_eventPool = {};
constructor(supportedEventNames = []) {
this._supportedEventNames = [
...this._supportedEventNames,
...supportedEventNames,
];
}
on(eventName, eventHandler) {
if (!this._supportedEventNames.includes(eventName)) return;
if (eventName in this._eventPool) {
if (!this._eventPool[eventName].includes(eventHandler)) {
this._eventPool[eventName].push(eventHandler);
}
} else {
this._eventPool[eventName] = [eventHandler];
}
}
once(eventName, eventHandler) {
if (!this._supportedEventNames.includes(eventName)) return;
const handler = payload => {
eventHandler(payload);
this.off(eventName, handler);
};
this.on(eventName, handler);
}
off(eventName, eventHandler) {
if (!this._supportedEventNames.includes(eventName)) return;
if (eventName in this._eventPool) {
if (!eventHandler) {
delete this._eventPool[eventName];
return;
}
const index = this._eventPool[eventName].indexOf(eventHandler);
if (index >= 0) {
this._eventPool[eventName].splice(index, 1);
}
}
}
_trigger(eventName, payload = {}) {
if (!this._supportedEventNames.includes(eventName)) return;
if (!(eventName in this._eventPool)) return;
(this._eventPool[eventName] || []).forEach(eventHandler => {
eventHandler(payload);
});
}
}
export default EventDispatcher;

401
src/core/ShapeCreator.js

@ -0,0 +1,401 @@
/**
* 绘制多边形
*/
import { action, computed, makeObservable, observable, reaction, values } from 'mobx';
import mapbox from 'mapbox-gl';
import * as turf from '@turf/turf';
import EventDispatcher from './EventDispatcher';
// eslint-disable-next-line max-len
const aimCursor = 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsSAAALEgHS3X78AAADcElEQVRYhc1XTUgbURD+sgaDGEjWQ7EUQaFnydVLSW49SaC5SWgCKvRU9SxUoWdNT4UqpCJBChbFkxTqhl68Rs+CopRKwayr1RAxu+V7fa/YzebHWIwDE3b3zXwzb2bevInPcRy0SCkAL6XqEoCPrcDcxQEjl8td82FkZMQPIHbvDsRif2wahoFWHdBatf6/6DYOBAEkTNNc4aY91g25lpCyTVFTKbAsazwQCCSurq782WzWt76+jkKhgNPTU7EeDocRjUYFp9Npp7Oz87pcLq+GQqEPjbAbORC8uLh4b9v2k7m5OV8mkxFG+/v7hbF8Pi+E1PPBwYFwZmJiAlNTU46mad+7u7tfAfhV0wIdqMFBy7Jyh4eHW5FIhCE3UqmUUSgUDMdxBEejUcHqnWuUoSx1qEsMYtWyU7MGuHPLsh4PDg76uDPuMJvNoq+v7xuAMQ+VMa5RRkWDusQgVi07ng4w5wz78PCwj+8EHBoaKgOY7OnpeQNgDzL3ZEl7cm2Ssio9xCAWMZtNQbBUKn2ZmZkRoczn80a5XN50h7FSqXw6Pz//SuazG4M61CUGsYjplQovBxKWZW2Fw2GRT5nfiIdc7+Xl5TSZzx7r1BEYxCImsRs6UCwWVzKZjPCcRXVycjJbp1DrMnWJQSxiEtst73UMRYtlEe3v70MW3F6tImpATwEsDAwMIBKJYG1tDe6W7XfdaoLYZOLxuHpd4I9t2z81TXsN4LiB0V7btt9pmvZIfWCfYPNSG7whu8QIiFttcXHRr76ygtlsyJDVvry8bHd0dBhdXV1v61kvlUrTlUollkwmNdUpGU0yHVE0Ojp6zVu07ZcRI1CVAl3XRQrYVBTdJQXpdFqkwDRNt+xS24uwKgWmaR5z93RgZ2eHxzLZonGhSwyVf2JXCT24RvQQWjHv/PGzszNxDdN7djPpxM1IGPF4XLCM0t+dU5Y61CUGsYjZ9HXMSYbDxMbGhqhQ5m97ezsAYL5YLM7K4hLDiTrr/CbX5imrzjwxiFVrOqrZBzjJhEKhH7u7u46agHicjo6Onqnu6KIFrlGGstShLjHkVORJbR/JWhpKaYx8H0PpP9EA8Nw0zRe6rvfyg+uPiTjnuq5/BrBZdxC9Qf5mhCQRcFXX9VX57v5vEGMLvy21/TK6TQSqiFfqXXHa+/ccwG/SPyXk58r5VgAAAABJRU5ErkJggg==") 16 16';
class ShapeCreator extends EventDispatcher {
_topic = null;
// 地图实例
_map = null;
// 构成形状的点坐标
_coordinates = [];
// 操作控制点
_markers = [];
// 形状是否扭结
_isKinked = false;
// 当前激活的marker索引
_activeMarkerIndex = -1;
// 操控点提示
_markerTooltip = (new mapbox.Popup({ closeButton: false, offset: 6, className: 'shape-editor-popup' })).setHTML('按住可拖拽控制点<br>点击可微调控制点<br>点击第一个控制点可闭合形状');
// 闭合形状提示
_completeTooltip = (new mapbox.Popup({ closeButton: false, offset: 6, className: 'shape-editor-popup' })).setHTML('按住可拖拽控制点<br>点击可微调控制点,或闭合形状');
// 坐标编辑器
_coordinatePopupEditor = new mapbox.Popup({ offset: 6, className: 'shape-editor-popup' });
// 鼠标指针
_cursor = `${aimCursor}, crosshair`;
// 视觉选项
_defaultStyle = {
fillColor: 'rgba(0, 0, 255, 0.2)',
edgeColor: 'rgba(0, 0, 255, 0.5)',
markerFillColor: 'rgba(0, 0, 255, 1)',
markerEdgeColor: 'rgba(255, 255, 255, 1)',
};
_style = {};
get _sourceId() {
return {
FILL: `${this._topic}-shape-creator-fill-source`,
EDGE: `${this._topic}-shape-creator-edge-source`,
};
}
get _layerId() {
return {
FILL: `${this._topic}-shape-creator-fill-layer`,
EDGE: `${this._topic}-shape-creator-edge-layer`,
};
}
// 形状边线
get _shapeEdgeFeature() {
if (this._coordinates.length < 2) {
return turf.feature({});
}
return turf.lineString(this._coordinates);
}
// 形状填充
get _shapeFillFeature() {
if (this._coordinates.length < 3) {
return turf.feature({});
}
return turf.lineToPolygon(turf.lineString(this._coordinates));
}
constructor(topic, mapInstance = null) {
super(['shape_kinked', 'kink_recovered', 'completed']);
this._topic = topic;
if (mapInstance) this.setMap(mapInstance);
this.initStyle();
this._initCoordinateEditor();
makeObservable(this, {
_topic: observable,
_coordinates: observable,
_sourceId: computed,
_layerId: computed,
_shapeEdgeFeature: computed,
_shapeFillFeature: computed,
_appendToCoordinates: action,
_updateCoordinates: action,
clear: action,
});
reaction(() => this._coordinates, () => {
this._render();
this._updateCoordinateEditor();
});
}
// 初始化视觉样式
initStyle(style = {}) {
this._style = {
...this._defaultStyle,
...style,
};
}
setMap(mapInstance) {
if (this._map === mapInstance) return;
if (!(mapInstance instanceof mapbox.Map)) {
throw new Error('必须传入一个mapbox地图实例');
}
this._map = mapInstance;
}
// 开始创建
start() {
this._map.on('mousemove', this._onHoverMap);
this._map.on('click', this._onClickMap);
}
// 结束创建
end() {
this._map.off('mousemove', this._onHoverMap);
this._map.off('click', this._onClickMap);
this._map.getCanvas().style.cursor = '';
}
// 提交结果
commit() {
if (!this._coordinates.length) return;
this._trigger('completed', this._coordinates.map(coordinate => values(coordinate)));
this.clear();
}
// 追加新的坐标点
_appendToCoordinates(coordinate) {
const newCoordinates = [...this._coordinates];
newCoordinates.push(coordinate);
this._coordinates = newCoordinates;
}
// 更新坐标点
_updateCoordinates(index, coordinate) {
const newCoordinates = [...this._coordinates];
newCoordinates[index] = coordinate;
this._coordinates = newCoordinates;
}
_initCoordinateEditor() {
const node = document.createElement('div');
node.className = 'form';
node.innerHTML = `
<div><label>经度<input class="lng" type="text" value=""></label></div>
<div><label>纬度<input class="lat" type="text" value=""></label></div>
<div class="buttons">
<button class="confirm">确定</button>
<button class="close-shape success-bg-color">闭合形状</button>
</div>
`;
this._coordinatePopupEditor.setDOMContent(node);
this._coordinatePopupEditor.on('close', () => {
this._activeMarkerIndex = -1;
});
node.querySelector('.confirm').addEventListener('click', this._onConfirmCoordinate);
node.querySelector('.close-shape').addEventListener('click', this._onCommitShape);
}
_updateCoordinateEditor(toggleButton = false) {
if (this._activeMarkerIndex < 0) return;
const [lng, lat] = this._coordinates[this._activeMarkerIndex];
const node = this._coordinatePopupEditor.getElement();
if (!node) return;
node.querySelector('.lng').value = lng.toFixed(7);
node.querySelector('.lat').value = lat.toFixed(7);
if (toggleButton) {
const btn = node.querySelector('.close-shape');
btn.style.display = this._activeMarkerIndex === 0 ? 'inline-block' : 'none';
}
}
_onConfirmCoordinate = () => {
const node = this._coordinatePopupEditor.getElement();
if (!node || this._activeMarkerIndex < 0) return;
const newLng = node.querySelector('.lng').value - 0;
const newLat = node.querySelector('.lat').value - 0;
const marker = this._markers[this._activeMarkerIndex];
marker.setLngLat([newLng, newLat]);
this._updateCoordinates(this._activeMarkerIndex, [newLng, newLat]);
this._hidePopup();
};
_onCommitShape = () => {
if (this._coordinates.length < 3) return;
if (this._checkKinked()) {
this._trigger('shape_kinked', { action: 'close' });
return;
}
this._hidePopup();
this.commit();
};
_hidePopup() {
this._markers.forEach(marker => marker.setPopup(null));
this._activeMarkerIndex = -1;
}
// 检测形状是否扭结(判断新坐标加入后,或者对原有坐标进行判断)
_checkKinked(newCoordinate) {
if (this._coordinates.length < 3) return false;
if (newCoordinate) {
const lineFeature = turf.lineString([...this._coordinates, newCoordinate]);
const kinks = turf.kinks(lineFeature);
return !!kinks.features.length;
}
const kinks = turf.kinks(this._shapeFillFeature);
return !!kinks.features.length;
}
// 生成操作点
_genMarkerNode() {
const node = document.createElement('div');
node.style.cursor = 'pointer';
node.style.width = '12px';
node.style.height = '12px';
node.style.borderRadius = '50%';
node.style.background = this._style.markerFillColor;
node.style.boxSizing = 'border-box';
node.style.border = `2px solid ${this._style.markerEdgeColor}`;
node.addEventListener('click', this._onClickMarker, false);
node.addEventListener('mousemove', this._onMouseMoveMarker);
node.addEventListener('mouseleave', this._onMouseLeaveMarker);
return node;
}
_onClickMarker = e => {
e.stopPropagation();
const { markerIndex } = e.target.dataset;
this._markers.forEach(marker => marker.setPopup(null));
if (markerIndex >= 0) {
this._activeMarkerIndex = markerIndex - 0;
const marker = this._markers[markerIndex];
marker.setPopup(this._coordinatePopupEditor);
if (!marker.getPopup().isOpen()) {
marker.togglePopup();
}
this._updateCoordinateEditor(true);
}
};
_onMouseMoveMarker = e => {
const { markerIndex } = e.target.dataset;
if (markerIndex === undefined) return;
const marker = this._markers[markerIndex];
if (this._activeMarkerIndex >= 0) return;
marker.setPopup(!(markerIndex - 0) ? this._completeTooltip : this._markerTooltip);
marker.togglePopup();
};
_onMouseLeaveMarker = e => {
const { markerIndex } = e.target.dataset;
if (markerIndex === undefined) return;
const marker = this._markers[markerIndex];
const popup = marker.getPopup();
if (!popup || popup === this._coordinatePopupEditor) return;
if (popup.isOpen()) {
marker.togglePopup();
}
};
_onDragMarker = e => {
const marker = e.target;
const index = this._markers.indexOf(marker);
const { lng: newLng, lat: newLat } = marker.getLngLat();
this._updateCoordinates(index, [newLng, newLat]);
if (this._checkKinked()) {
this._isKinked = true;
this._trigger('shape_kinked', { action: 'drag' });
} else {
// 从扭结复原了
if (this._isKinked) {
this._trigger('kink_recovered');
}
this._isKinked = false;
}
};
_onClickMap = e => {
const { lng, lat } = e.lngLat;
// 判断新坐标是否会造成扭结(即:新的边线是否会跟其他边线交叉)
if (this._checkKinked([lng, lat])) {
this._trigger('shape_kinked', { action: 'click' });
return;
}
const element = this._genMarkerNode();
const marker = new mapbox.Marker({
element,
draggable: true,
}).setLngLat(e.lngLat).addTo(this._map);
this._markers.push(marker);
element.dataset.markerIndex = `${this._markers.length - 1}`;
this._appendToCoordinates([lng, lat]);
marker.on('drag', this._onDragMarker);
};
_onHoverMap = () => {
this._map.getCanvas().style.cursor = this._cursor;
};
_render() {
if (!this._map) return;
const { length: count } = this._coordinates;
if (count < 2) return;
this._renderEdge();
if (count < 3) return;
this._renderFill();
}
_renderEdge() {
const source = this._map.getSource(this._sourceId.EDGE);
if (!source) {
this._map.addSource(this._sourceId.EDGE, {
type: 'geojson',
data: this._shapeEdgeFeature,
});
this._map.addLayer({
id: this._layerId.EDGE,
type: 'line',
source: this._sourceId.EDGE,
paint: {
'line-color': this._style.edgeColor,
'line-width': 2,
},
});
} else {
source.setData(this._shapeEdgeFeature);
}
}
_renderFill() {
const source = this._map.getSource(this._sourceId.FILL);
if (!source) {
this._map.addSource(this._sourceId.FILL, {
type: 'geojson',
data: this._shapeFillFeature,
});
this._map.addLayer({
id: this._layerId.FILL,
type: 'fill',
source: this._sourceId.FILL,
paint: {
'fill-color': this._style.fillColor,
},
});
} else {
source.setData(this._shapeFillFeature);
}
}
_clearMarkers() {
this._markers.forEach(m => m.remove());
this._markers = [];
}
clear(end = false) {
if (!this._map) return;
if (this._map.getSource(this._sourceId.FILL)) {
this._map.removeLayer(this._layerId.FILL);
this._map.removeSource(this._sourceId.FILL);
}
if (this._map.getSource(this._sourceId.EDGE)) {
this._map.removeLayer(this._layerId.EDGE);
this._map.removeSource(this._sourceId.EDGE);
}
this._clearMarkers();
this._coordinates = [];
if (end) this.end();
}
destroy() {
this.clear();
this.end();
this._map = null;
this.initStyle();
}
}
export default ShapeCreator;

562
src/core/ShapeEditor.js

@ -0,0 +1,562 @@
/**
* 多边形编辑
*/
import { action, computed, makeObservable, observable, reaction, values } from 'mobx';
import * as turf from '@turf/turf';
import mapbox from 'mapbox-gl';
import EventDispatcher from './EventDispatcher';
class ShapeEditor extends EventDispatcher {
_topic = null;
// 地图实例
_map = null;
_dataSource = {};
// 构成形状的点坐标
_coordinates = [];
// 操作控制点
_markers = [];
// 形状是否扭结
_isKinked = false;
// 当前激活的marker索引
_activeMarkerIndex = -1;
// 操控点提示
_markerTooltip = (new mapbox.Popup({ closeButton: false, offset: 6, className: 'shape-editor-popup' })).setHTML('按住可拖拽控制点<br>点击可微调、删除控制点');
// 扩展点提示
_extPointTooltip = (new mapbox.Popup({ closeButton: false, offset: 4, className: 'shape-editor-popup' })).setText('点击可添加控制点');
// 坐标编辑器
_coordinatePopupEditor = new mapbox.Popup({ offset: 6, className: 'shape-editor-popup' });
// 在地图上点击以外区域时,是否自动提交本次编辑
_autoCommit = false;
// 视觉选项
_defaultStyle = {
edgeColor: 'rgba(0, 0, 255, 0.8)',
markerFillColor: 'rgba(0, 0, 255, 1)',
markerEdgeColor: 'rgba(255, 255, 255, 1)',
};
_style = {};
get _sourceId() {
return {
FILL: `${this._topic}-shape-editor-fill-source`,
EDGE: `${this._topic}-shape-editor-edge-source`,
VERTEX: `${this._topic}-shape-editor-vertex-source`,
EXT_POINT: `${this._topic}-shape-editor-ext-point-source`,
};
}
get _layerId() {
return {
FILL: `${this._topic}-shape-editor-fill-layer`,
EDGE: `${this._topic}-shape-editor-edge-layer`,
VERTEX: `${this._topic}-shape-editor-vertex-layer`,
EXT_POINT: `${this._topic}-shape-editor-ext-point-layer`,
};
}
// 形状填充
get _shapeFillFeature() {
return turf.lineToPolygon(turf.lineString(this._coordinates));
}
// 形状集合包围盒
get _shapeBoundingBox() {
return turf.bbox(this._shapeFillFeature);
}
// 形状边界线
get _shapeEdgeFeature() {
return turf.polygonToLine(this._shapeFillFeature);
}
// 形状顶点
get _shapeVertexFeature() {
return turf.multiPoint(this._coordinates);
}
// 形状扩展点
get _shapeExtPointFeatureCollection() {
const [first] = this._coordinates;
if (!first) return turf.featureCollection([]);
const points = this._coordinates.concat([first]).map(coordinate => turf.point(coordinate));
const midPoints = [];
for (let i = 1; i < points.length; i += 1) {
const midPoint = turf.midpoint(points[i - 1], points[i]);
midPoint.id = i;
midPoints.push(midPoint);
}
return turf.featureCollection(midPoints);
}
constructor(topic, autoCommit = false, mapInstance = null) {
super(['shape_kinked', 'kink_recovered', 'completed']);
this._topic = topic;
this._autoCommit = autoCommit;
if (mapInstance) this.setMap(mapInstance);
this.initStyle();
this._initCoordinateEditor();
makeObservable(this, {
_topic: observable,
_coordinates: observable,
_sourceId: computed,
_layerId: computed,
_shapeFillFeature: computed,
_shapeEdgeFeature: computed,
_shapeVertexFeature: computed,
_shapeExtPointFeatureCollection: computed,
loadDataSource: action,
_initCoordinates: action,
_updateCoordinates: action,
_insertIntoCoordinates: action,
_deleteCoordinates: action,
clear: action,
});
reaction(() => this._coordinates, () => {
this._render();
this._updateCoordinateEditor();
});
}
// 初始化视觉演示(需在loadTracks之前配置)
initStyle(style = {}) {
this._style = {
...this._defaultStyle,
...style,
};
}
setMap(mapInstance) {
if (this._map === mapInstance) return;
if (!(mapInstance instanceof mapbox.Map)) {
throw new Error('必须传入一个mapbox地图实例');
}
this._map = mapInstance;
}
loadDataSource({ points, ...others } = {}) {
if (!this._map) {
throw new Error('请先设置地图实例');
}
let newPoints = points || [];
newPoints = Array.isArray(newPoints[0]) ? newPoints : newPoints.map(({ lng, lat }) => [lng, lat]);
this._dataSource = { points: newPoints, ...others };
this._initCoordinates();
}
// 提交编辑
commit() {
if (!this._coordinates.length) return;
const result = {
...this._dataSource,
points: this._coordinates.map(coordinate => values(coordinate)),
};
this._trigger('completed', result);
this.clear();
}
// 初始化操控点坐标集
_initCoordinates() {
const { points } = this._dataSource;
this._coordinates = points;
this._showMarkers();
}
// 更新操控点坐标
_updateCoordinates(index, coordinate) {
const newCoordinates = [...this._coordinates];
newCoordinates[index] = coordinate;
this._coordinates = newCoordinates;
}
// 插入新的操控点坐标
_insertIntoCoordinates(index, coordinate) {
const newCoordinates = [...this._coordinates];
newCoordinates.splice(index, 0, coordinate);
this._coordinates = newCoordinates;
this._showMarkers();
}
// 删除操控点坐标
_deleteCoordinates(index) {
const newCoordinates = [...this._coordinates];
newCoordinates.splice(index - 0, 1);
this._coordinates = newCoordinates;
this._hidePopup();
this._showMarkers();
}
_initCoordinateEditor() {
const node = document.createElement('div');
node.className = 'form';
node.innerHTML = `
<div><label>经度<input class="lng" type="text" value=""></label></div>
<div><label>纬度<input class="lat" type="text" value=""></label></div>
<div class="buttons">
<button class="confirm">确定</button>
<button class="delete danger-bg-color">删除此点</button>
</div>
`;
this._coordinatePopupEditor.setDOMContent(node);
this._coordinatePopupEditor.on('close', () => {
this._activeMarkerIndex = -1;
});
node.querySelector('.confirm').addEventListener('click', this._onConfirmCoordinate);
node.querySelector('.delete').addEventListener('click', this._onDeleteMarker);
}
_updateCoordinateEditor(toggleButton = false) {
if (this._activeMarkerIndex < 0 || !this._coordinates.length) return;
const [lng, lat] = this._coordinates[this._activeMarkerIndex];
const node = this._coordinatePopupEditor.getElement();
if (!node) return;
node.querySelector('.lng').value = lng.toFixed(7);
node.querySelector('.lat').value = lat.toFixed(7);
if (toggleButton) {
const btn = node.querySelector('.delete');
btn.style.display = this._coordinates.length > 3 ? 'inline-block' : 'none';
}
}
_onConfirmCoordinate = () => {
const node = this._coordinatePopupEditor.getElement();
if (!node || this._activeMarkerIndex < 0) return;
const newLng = node.querySelector('.lng').value - 0;
const newLat = node.querySelector('.lat').value - 0;
const marker = this._markers[this._activeMarkerIndex];
marker.setLngLat([newLng, newLat]);
this._updateCoordinates(this._activeMarkerIndex, [newLng, newLat]);
this._hidePopup();
};
_onDeleteMarker = () => {
if (this._coordinates.length <= 3) return;
if (this._activeMarkerIndex < 0) return;
this._deleteCoordinates(this._activeMarkerIndex);
};
_hidePopup() {
this._markers.forEach(marker => marker.setPopup(null));
this._activeMarkerIndex = -1;
}
_render() {
if (!this._map) return;
if (this._coordinates.length < 3) return;
this._renderFill();
this._renderEdge();
this._renderVertex();
this._renderExtPoint();
this._map.off('click', this._onClickMap);
this._map.on('click', this._onClickMap);
}
// 渲染填充层,仅用于鼠标点击
_renderFill() {
const source = this._map.getSource(this._sourceId.FILL);
if (!source) {
this._map.addSource(this._sourceId.FILL, {
type: 'geojson',
data: this._shapeFillFeature,
});
this._map.addLayer({
id: this._layerId.FILL,
type: 'fill',
source: this._sourceId.FILL,
paint: {
'fill-color': 'rgba(0, 0, 0, 0.1)',
},
});
} else {
source.setData(this._shapeFillFeature);
}
}
_renderEdge() {
const source = this._map.getSource(this._sourceId.EDGE);
if (!source) {
this._map.addSource(this._sourceId.EDGE, {
type: 'geojson',
data: this._shapeEdgeFeature,
});
this._map.addLayer({
id: this._layerId.EDGE,
type: 'line',
source: this._sourceId.EDGE,
paint: {
'line-color': this._style.edgeColor,
'line-width': 2,
'line-dasharray': [1, 2],
},
layout: {
'line-cap': 'round',
},
});
} else {
source.setData(this._shapeEdgeFeature);
}
}
// 渲染顶点,仅用于鼠标点击
_renderVertex() {
const source = this._map.getSource(this._sourceId.VERTEX);
if (!source) {
this._map.addSource(this._sourceId.VERTEX, {
type: 'geojson',
data: this._shapeVertexFeature,
});
this._map.addLayer({
id: this._layerId.VERTEX,
type: 'circle',
source: this._sourceId.VERTEX,
paint: {
'circle-color': this._style.edgeColor,
'circle-radius': 4,
},
});
} else {
source.setData(this._shapeVertexFeature);
}
}
// 渲染扩展点
_renderExtPoint() {
const source = this._map.getSource(this._sourceId.EXT_POINT);
if (!source) {
this._map.addSource(this._sourceId.EXT_POINT, {
type: 'geojson',
data: this._shapeExtPointFeatureCollection,
});
this._map.addLayer({
id: this._layerId.EXT_POINT,
type: 'circle',
source: this._sourceId.EXT_POINT,
paint: {
'circle-color': this._style.edgeColor,
'circle-radius': 4,
},
});
this._map.on('click', this._layerId.EXT_POINT, this._onClickExtPoint);
this._map.on('mousemove', this._layerId.EXT_POINT, this._onMouserMoveExtPoint);
this._map.on('mouseleave', this._layerId.EXT_POINT, this._onMouserLeaveExtPoint);
} else {
source.setData(this._shapeExtPointFeatureCollection);
}
}
_onClickMap = e => {
if (!this._autoCommit) return;
const features = this._map.queryRenderedFeatures(e.point, {
layers: [
this._layerId.FILL,
this._layerId.EDGE,
this._layerId.VERTEX,
this._layerId.EXT_POINT,
],
});
// length>=0表示点击了编辑区域,否则点击编辑区域以外(即:结束编辑)
if (features.length) return;
if (this._checkKinked()) {
this._trigger('shape_kinked', { action: 'close' });
return;
}
this.commit();
};
// 点击扩展点(即:插入新的操控点)
_onClickExtPoint = e => {
const [feature] = e.features;
this._insertIntoCoordinates(feature.id, turf.getCoord(feature));
};
_onMouserMoveExtPoint = e => {
this._map.getCanvas().style.cursor = 'pointer';
const [feature] = e.features;
this._extPointTooltip.setLngLat(turf.getCoord(feature)).addTo(this._map);
};
_onMouserLeaveExtPoint = () => {
this._map.getCanvas().style.cursor = '';
this._extPointTooltip.remove();
};
_onMouseMoveMarker = e => {
const { markerIndex } = e.target.dataset;
if (markerIndex === undefined) return;
const marker = this._markers[markerIndex];
if (this._activeMarkerIndex >= 0) return;
marker.setPopup(this._markerTooltip);
if (!marker.getPopup().isOpen()) {
marker.togglePopup();
}
};
_onMouseLeaveMarker = e => {
const { markerIndex } = e.target.dataset;
if (markerIndex === undefined) return;
const marker = this._markers[markerIndex];
const popup = marker.getPopup();
if (!popup || popup === this._coordinatePopupEditor) return;
if (popup.isOpen()) {
marker.togglePopup();
}
};
_onClickMarker = e => {
e.stopPropagation();
const { markerIndex } = e.target.dataset;
this._markers.forEach(marker => marker.setPopup(null));
if (markerIndex >= 0) {
this._activeMarkerIndex = markerIndex - 0;
const marker = this._markers[markerIndex];
marker.setPopup(this._coordinatePopupEditor);
if (!marker.getPopup().isOpen()) {
marker.togglePopup();
}
this._updateCoordinateEditor(true);
}
};
_onDragMarker = e => {
const marker = e.target;
const index = this._markers.indexOf(marker);
const { lng: newLng, lat: newLat } = marker.getLngLat();
this._updateCoordinates(index, [newLng, newLat]);
if (this._checkKinked()) {
this._isKinked = true;
this._trigger('shape_kinked', { action: 'drag' });
} else {
// 从扭结复原了
if (this._isKinked) {
this._trigger('kink_recovered');
}
this._isKinked = false;
}
};
// 检测形状是否扭结(判断新坐标加入后,或者对原有坐标进行判断)
_checkKinked(newCoordinate) {
if (this._coordinates.length < 3) return false;
if (newCoordinate) {
const lineFeature = turf.lineString([...this._coordinates, newCoordinate]);
const kinks = turf.kinks(lineFeature);
return !!kinks.features.length;
}
const kinks = turf.kinks(this._shapeFillFeature);
return !!kinks.features.length;
}
// 生成操作点
_genMarkerNode() {
const node = document.createElement('div');
node.style.cursor = 'pointer';
node.style.width = '12px';
node.style.height = '12px';
node.style.borderRadius = '50%';
node.style.background = this._style.markerFillColor;
node.style.boxSizing = 'border-box';
node.style.border = `2px solid ${this._style.markerEdgeColor}`;
node.addEventListener('mousemove', this._onMouseMoveMarker);
node.addEventListener('mouseleave', this._onMouseLeaveMarker);
node.addEventListener('click', this._onClickMarker, false);
return node;
}
// 显示操控点
_showMarkers() {
this._clearMarkers();
this._coordinates.forEach(coordinate => {
const element = this._genMarkerNode();
const marker = new mapbox.Marker({
element,
draggable: true,
}).setLngLat(coordinate).addTo(this._map);
this._markers.push(marker);
element.dataset.markerIndex = `${this._markers.length - 1}`;
marker.on('drag', this._onDragMarker);
});
}
// 缩放到包围盒
fitView({ top = 0, bottom = 0, left = 0, right = 0 } = {}, cut = 120) {
if (!this._coordinates.length) return;
this._map.setPadding({ top: 0, bottom: 0, left: 0, right: 0 });
this._map.fitBounds(this._shapeBoundingBox, {
duration: 2000,
padding: {
top: top + cut,
bottom: bottom + cut,
left: left + cut,
right: right + cut,
},
});
}
_clearMarkers() {
this._markers.forEach(m => m.remove());
this._markers = [];
}
clear() {
if (!this._map) return;
this._map.off('click', this._onClickMap);
if (this._map.getSource(this._sourceId.FILL)) {
this._map.removeLayer(this._layerId.FILL);
this._map.removeSource(this._sourceId.FILL);
}
if (this._map.getSource(this._sourceId.EDGE)) {
this._map.removeLayer(this._layerId.EDGE);
this._map.removeSource(this._sourceId.EDGE);
}
if (this._map.getSource(this._sourceId.VERTEX)) {
this._map.removeLayer(this._layerId.VERTEX);
this._map.removeSource(this._sourceId.VERTEX);
}
if (this._map.getSource(this._sourceId.EXT_POINT)) {
this._map.off('click', this._layerId.EXT_POINT, this._onClickExtPoint);
this._map.off('mousemove', this._layerId.EXT_POINT, this._onMouserMoveExtPoint);
this._map.off('mouseleave', this._layerId.EXT_POINT, this._onMouserLeaveExtPoint);
this._map.removeLayer(this._layerId.EXT_POINT);
this._map.removeSource(this._sourceId.EXT_POINT);
}
this._clearMarkers();
this._coordinates = [];
this._activeMarkerIndex = -1;
this._isKinked = false;
}
// 销毁
destroy() {
this.clear();
this._map = null;
this._dataSource = {};
this.initStyle();
}
}
export default ShapeEditor;

554
src/core/ShapeRenderer.js

@ -0,0 +1,554 @@
/**
* 任意多边形在地图上显示
*/
import { action, computed, makeObservable, observable, reaction } from 'mobx';
import mapbox from 'mapbox-gl';
import * as turf from '@turf/turf';
import EventDispatcher from './EventDispatcher';
class ShapeRenderer extends EventDispatcher {
_topic = null;
// 地图实例
_map = null;
_dataSource = [];
// 是否完成初始渲染
isRendered = false;
// 鼠标经过的形状id
_hoveredShapeId = null;
// 显示配置项
_defaultOptions = {
// 显示填充
showFill: true,
// 显示描边
showStroke: true,
// 显示标签
showLabel: true,
// 显示标签(仅鼠标经过时)
showLabelOnOver: false,
};
_options = {};
// 视觉选项
_defaultStyle = {
fillColor: 'rgba(255, 255, 0, 0.1)',
fillHoverColor: 'rgba(255, 255, 0, 0.3)',
strokeColor: 'rgba(255, 255, 0, 0.5)',
strokeHoverColor: 'rgba(255, 255, 0, 1)',
labelColor: 'rgba(0, 0, 0, 1)',
labelStrokeColor: 'rgba(255, 255, 255, 1)',
labelMinZoom: 3,
};
_style = {};
get _sourceId() {
return {
FILL: `${this._topic}-shape-fill-source`,
STROKE: `${this._topic}-shape-stroke-source`,
LABEL: `${this._topic}-shape-label-source`,
};
}
get _layerId() {
return {
FILL: `${this._topic}-shape-fill-layer`,
STROKE: `${this._topic}-shape-stroke-layer`,
LABEL: `${this._topic}-shape-label-layer`,
};
}
// 形状填充色表达式
get _shapeFillColorExpression() {
return [
'case',
[
'any',
['boolean', ['feature-state', 'hover'], false],
['boolean', ['feature-state', 'highlight'], false],
],
this._options.showFill ? this._style.fillHoverColor : 'rgba(0, 0, 0, 0)',
this._options.showFill ? this._style.fillColor : 'rgba(0, 0, 0, 0)',
];
}
// 形状描边色表达式
get _shapeStrokeColorExpression() {
return [
'case',
[
'any',
['boolean', ['feature-state', 'hover'], false],
['boolean', ['feature-state', 'highlight'], false],
],
this._options.showStroke ? this._style.strokeHoverColor : 'rgba(0, 0, 0, 0)',
this._options.showStroke ? this._style.strokeColor : 'rgba(0, 0, 0, 0)',
];
}
// 形状标签透明度表达式
get _shapeLabelOpacityExpression() {
if (this._options.showLabelOnOver) {
return [
'case',
[
'any',
['boolean', ['feature-state', 'hover'], false],
['boolean', ['feature-state', 'highlight'], false],
],
1,
0,
];
}
return [
'interpolate', ['linear'],
['zoom'],
this._style.labelMinZoom - 0.01, 0,
this._style.labelMinZoom, 1,
];
}
// 形状填充
get _shapeFillFeatures() {
return this._dataSource.map(({ id, points, ...others }) => {
const newPoints = Array.isArray(points[0]) ? points : points.map(({ lng, lat }) => [lng, lat]);
const lineFeature = turf.lineString(newPoints, { id, ...others });
const polygonFeature = turf.lineToPolygon(lineFeature);
polygonFeature.id = id;
return polygonFeature;
});
}
// 形状填充集合
get _shapeFillFeatureCollection() {
return turf.featureCollection(this._shapeFillFeatures);
}
// 形状描边
get _shapeStrokeFeatures() {
return this._shapeFillFeatures.map(polygonFeature => {
const lineFeature = turf.polygonToLine(polygonFeature);
const { id } = lineFeature.properties;
lineFeature.id = id;
return lineFeature;
});
}
// 形状描边集合
get _shapeStrokeFeatureCollection() {
return turf.featureCollection(this._shapeStrokeFeatures);
}
// 形状中心点
get _shapeCenterFeatures() {
return this._shapeFillFeatures.map(polygonFeature => {
const feature = turf.centerOfMass(polygonFeature);
feature.id = polygonFeature.id;
feature.properties = polygonFeature.properties;
return feature;
});
}
// 形状中心点集合
get _shapeCenterFeatureCollection() {
return turf.featureCollection(this._shapeCenterFeatures);
}
// 形状集合包围盒
get _shapeBoundingBox() {
return turf.bbox(this._shapeFillFeatureCollection);
}
constructor(topic, mapInstance = null) {
super(['rendered', 'mouse_move', 'mouse_leave']);
this._topic = topic;
if (mapInstance) this.setMap(mapInstance);
this.updateOptions();
this.updateStyle();
makeObservable(this, {
_topic: observable,
_dataSource: observable,
_options: observable,
_style: observable,
_sourceId: computed,
_layerId: computed,
_shapeFillColorExpression: computed,
_shapeStrokeColorExpression: computed,
_shapeLabelOpacityExpression: computed,
_shapeFillFeatures: computed,
_shapeFillFeatureCollection: computed,
_shapeStrokeFeatures: computed,
_shapeStrokeFeatureCollection: computed,
_shapeCenterFeatures: computed,
_shapeCenterFeatureCollection: computed,
_shapeBoundingBox: computed,
loadDataSource: action,
updateOptions: action,
updateStyle: action,
destroy: action,
});
reaction(() => this._dataSource, () => {
this._render();
});
}
// 更新配置项
updateOptions(options = {}) {
if (Object.keys(options).length) {
this._options = {
...this._options,
...options,
};
this._refreshVisibility();
} else {
this._options = {
...this._defaultOptions,
};
}
}
// 更新视觉样式
updateStyle(style = {}) {
if (Object.keys(style).length) {
this._style = {
...this._style,
...style,
};
this._repaintStyle();
} else {
this._style = {
...this._defaultStyle,
};
}
}
setMap(mapInstance) {
if (this._map === mapInstance) return;
if (!(mapInstance instanceof mapbox.Map)) {
throw new Error('必须传入一个mapbox地图实例');
}
this._map = mapInstance;
}
// 载入数据(list->item必须包含id、name、points数组,points->item如果是对象则必须包含lng、lat属性,如果是数组则必须是[lng, lat])
loadDataSource(list) {
if (!this._map) {
throw new Error('请先设置地图实例');
}
this._dataSource = (list || []).map(item => {
const { id, points } = item;
return (id >= 0 && Array.isArray(points) && points.length >= 3) ? item : null;
}).filter(Boolean);
}
// 是否包含某个多边形
hasShape(shapeId) {
return this._dataSource.findIndex(({ id }) => shapeId === id) >= 0;
}
// 获取某个形状数据
getShape(shapeId) {
return this._dataSource.find(({ id }) => shapeId === id);
}
// 获取所有形状的中心点
getAllCenters() {
return this._shapeCenterFeatures.map(feature => turf.getCoord(feature));
}
_render() {
if (!this._map) return;
if (this._options.showFill) this._renderShapeFill();
if (this._options.showStroke) this._renderShapeStroke();
if (this._options.showLabel) this._renderShapeLabel();
this.isRendered = true;
this._trigger('rendered');
}
// 渲染形状填充
_renderShapeFill() {
const source = this._map.getSource(this._sourceId.FILL);
if (!source) {
this._map.addSource(this._sourceId.FILL, {
type: 'geojson',
data: this._shapeFillFeatureCollection,
});
this._map.addLayer({
id: this._layerId.FILL,
type: 'fill',
source: this._sourceId.FILL,
paint: {
'fill-color': this._shapeFillColorExpression,
'fill-opacity': [
'case',
['boolean', ['feature-state', 'visible'], true],
1,
0,
],
},
});
this._map.on('click', this._layerId.FILL, this._onClick);
this._map.on('mousemove', this._layerId.FILL, this._onMouseMove);
this._map.on('mouseleave', this._layerId.FILL, this._onMouseLeave);
} else {
source.setData(this._shapeFillFeatureCollection);
}
}
// 渲染形状描边
_renderShapeStroke() {
const source = this._map.getSource(this._sourceId.STROKE);
if (!source) {
this._map.addSource(this._sourceId.STROKE, {
type: 'geojson',
data: this._shapeStrokeFeatureCollection,
});
this._map.addLayer({
id: this._layerId.STROKE,
type: 'line',
source: this._sourceId.STROKE,
paint: {
'line-color': this._shapeStrokeColorExpression,
'line-width': 2,
'line-opacity': [
'case',
['boolean', ['feature-state', 'visible'], true],
1,
0,
],
},
layout: {
'line-cap': 'round',
},
});
} else {
source.setData(this._shapeStrokeFeatureCollection);
}
}
_renderShapeLabel() {
const source = this._map.getSource(this._sourceId.LABEL);
if (!source) {
this._map.addSource(this._sourceId.LABEL, {
type: 'geojson',
data: this._shapeCenterFeatureCollection,
});
this._map.addLayer({
id: this._layerId.LABEL,
type: 'symbol',
source: this._sourceId.LABEL,
layout: {
'text-field': '{name}',
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': this._style.labelColor,
'text-halo-color': this._style.labelStrokeColor,
'text-halo-width': 1,
'text-opacity': this._shapeLabelOpacityExpression,
},
});
} else {
source.setData(this._shapeCenterFeatureCollection);
}
}
_onClick = e => {
if (!e.features.length) return;
const { lngLat, point } = e;
const [{ properties: detail }] = e.features;
this._trigger('click', { lngLat, point, detail });
};
_onMouseMove = e => {
this._map.getCanvas().style.cursor = 'pointer';
if (e.features.length > 0) {
if (this._hoveredShapeId !== null) {
const id = this._hoveredShapeId;
if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id }, { hover: false });
if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id }, { hover: false });
if (this._map.getSource(this._sourceId.LABEL)) this._map.setFeatureState({ source: this._sourceId.LABEL, id }, { hover: false });
}
const [{ id }] = e.features;
if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id }, { hover: true });
if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id }, { hover: true });
if (this._map.getSource(this._sourceId.LABEL)) this._map.setFeatureState({ source: this._sourceId.LABEL, id }, { hover: true });
this._hoveredShapeId = id;
}
const { lngLat, point } = e;
const [{ properties: detail }] = e.features;
this._trigger('mouse_move', { lngLat, point, detail });
};
_onMouseLeave = e => {
this._map.getCanvas().style.cursor = '';
if (this._hoveredShapeId !== null) {
if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: this._hoveredShapeId }, { hover: false });
if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: this._hoveredShapeId }, { hover: false });
if (this._map.getSource(this._sourceId.LABEL)) this._map.setFeatureState({ source: this._sourceId.LABEL, id: this._hoveredShapeId }, { hover: false });
}
const { lngLat, point } = e;
let detail = {};
if (this._hoveredShapeId !== null) {
const { id, points, ...others } = this._dataSource.find(item => item.id === this._hoveredShapeId) || {};
detail = { id, ...others };
}
this._trigger('mouse_leave', { lngLat, point, detail });
this._hoveredShapeId = null;
};
// 隐藏某个形状
hideShape(shapeId) {
if (!this._map || !this.hasShape(shapeId)) return;
if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: shapeId }, { visible: false });
if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: shapeId }, { visible: false });
}
// 显示某个形状
showShape(shapeId) {
if (!this._map || !this.hasShape(shapeId)) return;
if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: shapeId }, { visible: true });
if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: shapeId }, { visible: true });
}
// 高亮某个形状(或取消高亮)
highlightShape(shapeId, stateValue = true) {
if (!this._map || !this.hasShape(shapeId)) return;
if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: shapeId }, { highlight: stateValue });
if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: shapeId }, { highlight: stateValue });
}
// 高亮一组形状(或取消高亮)
highlightShapes(keyName, keyValue, stateValue = true) {
if (!this._map) return;
turf.featureEach(this._shapeFillFeatureCollection, currentFeature => {
const { id } = currentFeature;
if (currentFeature.properties[keyName] === keyValue) this.highlightShape(id, stateValue);
});
}
// 刷新可见性
_refreshVisibility() {
if (!this._map) return;
if (this._map.getLayer(this._layerId.FILL)) {
this._renderShapeFill();
this._map.setLayoutProperty(this._layerId.FILL, 'visibility', this._options.showFill ? 'visible' : 'none');
}
if (this._map.getLayer(this._layerId.STROKE)) {
this._renderShapeStroke();
this._map.setLayoutProperty(this._layerId.STROKE, 'visibility', this._options.showStroke ? 'visible' : 'none');
}
if (this._map.getLayer(this._layerId.LABEL)) {
this._renderShapeLabel();
this._map.setLayoutProperty(this._layerId.LABEL, 'visibility', this._options.showLabel ? 'visible' : 'none');
}
}
// 重绘样式
_repaintStyle() {
if (!this._map) return;
if (this._map.getLayer(this._layerId.FILL)) {
this._map.setPaintProperty(this._layerId.FILL, 'fill-color', this._shapeFillColorExpression);
}
if (this._map.getLayer(this._layerId.STROKE)) {
this._map.setPaintProperty(this._layerId.STROKE, 'line-color', this._shapeStrokeColorExpression);
}
if (this._map.getLayer(this._layerId.LABEL)) {
this._map.setPaintProperty(this._layerId.LABEL, 'text-color', this._style.labelColor);
this._map.setPaintProperty(this._layerId.LABEL, 'text-halo-color', this._style.labelStrokeColor);
this._map.setPaintProperty(this._layerId.LABEL, 'text-opacity', this._shapeLabelOpacityExpression);
}
}
// 缩放到某个形状的包围盒
fitShape(shapeId, { top = 0, bottom = 0, left = 0, right = 0 } = {}, cut = 120) {
if (!this._map || !this.hasShape(shapeId)) return;
const { points } = this._dataSource.find(({ id }) => id === shapeId) || {};
if (!points) return;
const newPoints = Array.isArray(points[0]) ? points : points.map(({ lng, lat }) => [lng, lat]);
const pointFeature = turf.multiPoint(newPoints);
this._map.setPadding({ top: 0, bottom: 0, left: 0, right: 0 });
this._map.fitBounds(turf.bbox(pointFeature), {
duration: 2000,
padding: {
top: top + cut,
bottom: bottom + cut,
left: left + cut,
right: right + cut,
},
});
}
// 缩放到所有形状的包围盒
fitView({ top = 0, bottom = 0, left = 0, right = 0 } = {}, cut = 120) {
if (!this._dataSource.length) return;
this._map.setPadding({ top: 0, bottom: 0, left: 0, right: 0 });
this._map.fitBounds(this._shapeBoundingBox, {
duration: 2000,
padding: {
top: top + cut,
bottom: bottom + cut,
left: left + cut,
right: right + cut,
},
});
}
// 对其到所有形状的中心
alignCenter(centerOffset = 0) {
if (!this._dataSource.length) return;
const feature = turf.bboxPolygon(this._shapeBoundingBox);
const center = turf.center(feature);
this._map.panTo(turf.getCoord(center), {
offset: [centerOffset, 0],
});
}
clear() {
if (!this._map) return;
this.isRendered = false;
if (this._map.getSource(this._sourceId.FILL)) {
this._map.off('click', this._layerId.FILL, this._onClick);
this._map.off('mousemove', this._layerId.FILL, this._onMouseMove);
this._map.off('mouseleave', this._layerId.FILL, this._onMouseLeave);
this._map.removeLayer(this._layerId.FILL);
this._map.removeSource(this._sourceId.FILL);
}
if (this._map.getSource(this._sourceId.STROKE)) {
this._map.removeLayer(this._layerId.STROKE);
this._map.removeSource(this._sourceId.STROKE);
}
if (this._map.getSource(this._sourceId.LABEL)) {
this._map.removeLayer(this._layerId.LABEL);
this._map.removeSource(this._sourceId.LABEL);
}
}
destroy() {
this.clear();
this._map = null;
this._dataSource = [];
this.updateStyle();
this.updateOptions();
}
}
export default ShapeRenderer;

90
src/core/mapHelper.js

@ -0,0 +1,90 @@
/**
* 地图助手
*/
import mapbox from 'mapbox-gl';
import * as turf from '@turf/turf';
class MapHelper {
_map = null;
setMap(mapInstance) {
if (this._map === mapInstance) return;
if (!(mapInstance instanceof mapbox.Map)) {
throw new Error('必须传入一个mapbox地图实例');
}
this._map = mapInstance;
window.map = mapInstance;
}
// 改变高清图层状态
changeHdLayer(toStatus, map = this._map) {
if (!map.getSource('hd-tiles')) {
map.addSource('hd-tiles', {
type: 'raster',
tiles: ['http://jiagu-map.oss-cn-shanghai.aliyuncs.com/tile/{z}/{x}/{y}.webp'],
tileSize: 256,
minzoom: 12,
maxzoom: 24,
});
map.addLayer({
id: 'hd-tiles-layer',
type: 'raster',
source: 'hd-tiles',
layout: {
visibility: 'none',
},
}, 'keyLayer');
}
map.setLayoutProperty('hd-tiles-layer', 'visibility', toStatus ? 'visible' : 'none');
}
// 获取一些点的包围盒(像素值)
getBoundingBoxProject = (points = []) => {
if (!this._map) return null;
const pointsFeature = turf.multiPoint(points);
const [westLng, southLat, eastLng, northLat] = turf.bbox(pointsFeature);
// project返回的是dom坐标系┏
const { x: x1, y: y1 } = this._map.project([westLng, southLat]);
const { x: x2, y: y2 } = this._map.project([eastLng, northLat]);
// 将dom坐标转换为webgl坐标┗
const { height } = this._map.getCanvas().getBoundingClientRect();
return [x1, height - y1, x2, height - y2];
};
// 获取包围盒区域图像数据
getImageData(bbox, padding = 0, size = 0) {
const [x1, y1, x2, y2] = bbox;
const glWidth = Math.abs(x2 - x1).toFixed(0) - 0 + padding * 2;
const glHeight = Math.abs(y2 - y1).toFixed(0) - 0 + padding * 2;
const diff = glWidth - glHeight; // 宽高差
const offset = Math.ceil(Math.abs(diff / 2)); // 起点偏移量
const originSize = diff >= 0 ? glWidth : glHeight;
const x = diff >= 0 ? (x1.toFixed(0) - padding) : (x1.toFixed(0) - offset - padding);
const y = diff < 0 ? (y1.toFixed(0) - padding) : (y1.toFixed(0) - offset - padding);
const gl = this._map.getCanvas().getContext('webgl');
const pixels = new Uint8Array(originSize * originSize * 4);
gl.readPixels(x, y, originSize, originSize, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const imageData = new ImageData(new Uint8ClampedArray(pixels), originSize, originSize);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = originSize;
tempCanvas.height = originSize;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.putImageData(imageData, 0, 0);
const canvas = document.createElement('canvas');
canvas.width = size || originSize;
canvas.height = size || originSize;
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, -1, 0, canvas.height);
ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/png');
});
}
}
export default new MapHelper();

10
src/layout/MainContainer.vue

@ -2,13 +2,14 @@
import { RouterView } from 'vue-router';
import MapLayer from '@/layout/MapLayer.vue';
import SideMenu from '@/layout/components/SideMenu.vue';
import TopBar from '@/layout/components/TopBar.vue';
</script>
<template>
<MapLayer />
<t-layout :class="s.root">
<t-header>
<h1>云端无人机管理系统管理员</h1>
<TopBar />
</t-header>
<t-layout>
<t-aside>
@ -27,16 +28,21 @@
position: relative;
height: 100%;
pointer-events: none;
color: var(--td-text-color-primary);
:global {
.t-layout__header {
pointer-events: auto;
box-shadow: 0 0 2px 0 var(--td-component-border);
z-index: 2;
}
.t-layout__sider {
width: fit-content;
background-color: transparent;
pointer-events: auto;
box-shadow: 0 0 1px 0 var(--td-component-border);
z-index: 1;
}
.t-layout--with-sider {
@ -44,6 +50,8 @@
}
.t-layout__content {
position: relative;
//color: var(--td-text-color-primary);
//pointer-events: none;
}
}

28
src/layout/MapLayer.vue

@ -2,6 +2,11 @@
import { onMounted } from 'vue';
import mapbox from 'mapbox-gl';
import config, { mapStyles } from '@/config/map';
import commonRefs from '@/utils/commonRefs';
import mapHelper from '@/core/mapHelper';
import 'mapbox-gl/dist/mapbox-gl.css';
import MultifunctionalBar from '@/layout/components/MultifunctionalBar.vue';
import ZoomBar from '@/layout/components/ZoomBar.vue';
let map;
@ -105,6 +110,8 @@
});
map.on('style.load', () => {
commonRefs.setRef('map', map);
mapHelper.setMap(map);
});
map.on('load', () => {
@ -121,17 +128,34 @@
</script>
<template>
<div :class="s.root" id="map" />
<div :class="s.root" id="map">
<div class="tool-bar">
<ZoomBar class="tool" />
<MultifunctionalBar class="tool" />
</div>
</div>
</template>
<style lang="less" module="s">
.root {
position: fixed;
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 100%;
background-color: #2f4f4f;
:global {
.tool-bar {
position: absolute;
top: 50%;
right: var(--td-comp-margin-m);
transform: translateY(-50%);
display: grid;
justify-content: center;
gap: var(--td-comp-margin-l);
}
}
}
</style>

167
src/layout/components/MultifunctionalBar.vue

@ -0,0 +1,167 @@
<script setup>
import { onMounted, ref, watchEffect } from 'vue';
import commonRefs from '@/utils/commonRefs';
import { useGlobalSettings } from '@/views/common/useGlobalSettings';
// import { useGlobalFarm } from '@/views/common/useGlobalFarm';
// import { useGlobalFields } from '@/views/common/useGlobalFields';
import eventBus from '@/utils/eventBus';
const isReady = ref(false);
const showMapHd = ref(true);
const showFieldFill = ref(true);
const showFieldName = ref(true);
watchEffect(() => {
const settings = useGlobalSettings().valueOf();
showMapHd.value = settings.showMapHd;
showFieldFill.value = settings.showFieldFill;
showFieldName.value = settings.showFieldName;
});
function init() {
eventBus.emit('show-map-hd-layer', showMapHd.value);
}
function onToggleMapHd() {
const result = !showMapHd.value;
eventBus.emit('show-map-hd-layer', result);
useGlobalSettings().set('showMapHd', result);
}
function onToggleFill(val) {
// useGlobalFields().toggleFill(val);
useGlobalSettings().set('showFieldFill', val);
}
function onToggleName(val) {
// useGlobalFields().toggleName(val);
useGlobalSettings().set('showFieldName', val);
}
onMounted(async () => {
await commonRefs.getRef('map');
isReady.value = true;
init();
});
</script>
<template>
<div :class="s.root" v-if="isReady">
<t-tooltip content="视角回归" placement="left">
<div class="cell">
<font-icon name="icon-positioning" />
</div>
</t-tooltip>
<t-popup placement="left-bottom" show-arrow :overlay-class-name="s.popup_free">
<div class="cell">
<font-icon name="icon-layer" />
</div>
<template #content>
<div class="top">
<t-space size="4px" direction="vertical" align="center" :class="[showMapHd ? 'active' : '']">
<div class="icon" @click="onToggleMapHd">
<img src="@/assets/map-icon-hd.png" alt="">
</div>
<span>高清图层</span>
</t-space>
</div>
</template>
</t-popup>
<t-popup placement="left-bottom" show-arrow :overlay-class-name="s.popup_single_col">
<div class="cell">
<font-icon name="icon-config" />
</div>
<template #content>
<t-space size="small" align="center">
<t-switch :label="['显示', '隐藏']" :value="showFieldFill" @change="onToggleFill" />
<span>地块形状</span>
</t-space>
<t-space size="small" align="center">
<t-switch :label="['显示', '隐藏']" :value="showFieldName" @change="onToggleName" />
<span>地块名称</span>
</t-space>
</template>
</t-popup>
</div>
</template>
<style lang="less" module="s">
.popup_single_col {
:global {
.t-popup__content {
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
display: grid;
grid-template-columns: 1fr;
gap: var(--td-comp-margin-m);
}
}
}
.popup_free {
:global {
.t-popup__content {
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
}
.top {
display: grid;
grid-template-columns: repeat(5fr);
gap: var(--td-comp-margin-m);
.t-space {
opacity: 0.6;
transition: opacity .2s ease;
.icon {
border: 2px solid transparent;
border-radius: 100%;
padding: 1px;
transition: border .2s ease;
cursor: pointer;
img {
vertical-align: top;
transition: opacity .2s ease;
}
}
&.active {
opacity: 1;
.icon {
border-color: var(--td-brand-color);
}
}
}
}
}
}
.root {
background-color: fade(black, 35%);
backdrop-filter: blur(6px);
border-radius: 100px;
border: 1px solid white;
padding: 10px;
color: white;
pointer-events: auto;
:global {
.cell {
padding: 10px 0;
font-size: 12px;
text-align: center;
border-top: 1px solid fade(white, 50%);
cursor: pointer;
&:first-child {
border-top: none;
}
.t-icon {
font-size: 18px;
}
}
}
}
</style>

51
src/layout/components/SideMenu.vue

@ -1,17 +1,39 @@
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
const collapsed2 = ref(false);
const changeCollapsed2 = () => {
collapsed2.value = !collapsed2.value;
const collapsed = ref(false);
const changeCollapsed = () => {
collapsed.value = !collapsed.value;
};
const expanded2 = ref(['2']);
const route = useRoute();
const router = useRouter();
const currentViewName = computed({
get() {
return route?.meta?.group || route.name || 'ExampleView';
},
set(nv) {
router.push({
name: nv,
});
},
});
</script>
<template>
<t-menu v-model:expanded="expanded2" theme="dark" :class="s.root" default-value="2-1" :collapsed="collapsed2">
<t-menu v-model="currentViewName" :class="s.root" :collapsed="collapsed">
<template #logo>
<t-button class="t-demo-collapse-btn" variant="text" shape="square" @click="changeCollapsed">
<template #icon><t-icon :name="collapsed ? 'indent-right' : 'indent-left'" size="large" /></template>
</t-button>
</template>
<t-menu-item value="ExampleView">
<template #icon>
<font-icon name="icon-plant-height" />
</template>
案例模板
</t-menu-item>
<t-menu-item value="item1">
<template #icon>
<font-icon name="icon-plant-height" />
@ -24,19 +46,25 @@
</template>
设备管理
</t-menu-item>
<t-menu-item value="root">
<t-menu-item value="ManufacturerView">
<template #icon>
<t-icon name="root-list" />
</template>
制造商管理
</t-menu-item>
<t-menu-item value="RegulatorView">
<template #icon>
<t-icon name="root-list" />
</template>
监管者管理
</t-menu-item>
<t-menu-item value="1">
<template #icon>
<t-icon name="server" />
</template>
架次管理
</t-menu-item>
<t-menu-item value="2">
<t-menu-item value="NoFlyZoneView">
<template #icon>
<t-icon name="server" />
</template>
@ -48,11 +76,6 @@
</template>
数据分析
</t-menu-item>
<template #operations>
<t-button class="t-demo-collapse-btn" variant="text" shape="square" @click="changeCollapsed2">
<template #icon><t-icon name="view-list" /></template>
</t-button>
</template>
</t-menu>
</template>

66
src/layout/components/TopBar.vue

@ -0,0 +1,66 @@
<script setup>
import { useRouter } from 'vue-router';
import { DialogPlugin } from 'tdesign-vue-next';
const router = useRouter();
function LogOut() {
const dialog = DialogPlugin({
header: '操作确认',
body: '确认退出吗?',
onConfirm: () => {
localStorage.clear();
router.push({
name: 'LoginView',
});
dialog.hide();
},
});
}
</script>
<template>
<div :class="s.root">
<t-space align="center">
<t-image
src="https://tdesign.gtimg.com/demo/demo-image-1.png"
:style="{ width: '38px', height: '38px' }"
shape="circle"
fit="cover"
/>
<div class="title">云端无人机管理系统管理员</div>
</t-space>
<t-space align="center">
<t-button theme="primary" shape="round" variant="base">监察者模式</t-button>
<t-dropdown>
<t-avatar>
<template #icon>
<t-icon name="user" />
</template>
</t-avatar>
<t-dropdown-menu>
<t-dropdown-item @click="LogOut">
退出登录
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
</t-space>
</div>
</template>
<style lang="less" module="s">
.root {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--td-comp-paddingTB-s) var(--td-comp-paddingLR-xl);
:global {
.title {
white-space: nowrap;
font-size: var(--td-font-size-headline-medium);
}
}
}
</style>

84
src/layout/components/ZoomBar.vue

@ -0,0 +1,84 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import commonRefs from '@/utils/commonRefs';
const level = ref(0);
async function updateLevel() {
const map = await commonRefs.getRef('map');
level.value = map.level;
}
onMounted(async () => {
const map = await commonRefs.getRef('map');
level.value = map.level;
map.on('postUpdate', updateLevel);
});
onUnmounted(async () => {
const map = await commonRefs.getRef('map');
map.off('postUpdate', updateLevel);
});
async function onZoomIn() {
const map = await commonRefs.getRef('map');
map.zoomIn();
}
async function onZoomOut() {
const map = await commonRefs.getRef('map');
map.zoomOut();
}
</script>
<template>
<div :class="s.root" v-if="level">
<t-tooltip content="放大地图" placement="left">
<div class="cell" @click="onZoomIn">
<font-icon name="icon-zoom-in" />
</div>
</t-tooltip>
<t-tooltip content="当前级别" placement="left">
<div class="cell text">{{ level }}</div>
</t-tooltip>
<t-tooltip content="缩小地图" placement="left">
<div class="cell" @click="onZoomOut">
<font-icon name="icon-zoom-out" />
</div>
</t-tooltip>
</div>
</template>
<style lang="less" module="s">
.root {
background-color: fade(black, 35%);
backdrop-filter: blur(6px);
border-radius: 100px;
border: 1px solid white;
padding: 10px;
color: white;
pointer-events: auto;
:global {
.cell {
padding: 10px 0;
font-size: 12px;
text-align: center;
border-top: 1px solid fade(white, 50%);
cursor: pointer;
&:first-child {
border-top: none;
}
&.text {
cursor: default;
}
.t-icon {
font-size: 18px;
}
}
}
}
</style>

40
src/router/index.js

@ -1,18 +1,32 @@
import { createRouter, createWebHistory } from 'vue-router';
// import auth from '@/utils/auth';
import auth from '@/utils/auth';
const ExampleView = () => import('@/views/ExampleView/ExampleView.vue');
const LoginView = () => import('@/views/LoginView/LoginView.vue');
const NoFlyZoneView = () => import('@/views/NoFlyZoneView/NoFlyZoneView.vue');
const NoFlyZoneEditorView = () => import('@/views/NoFlyZoneEditorView/NoFlyZoneEditorView.vue');
const ManufacturerView = () => import('@/views/ManufacturerView/ManufacturerView.vue');
const RegulatorView = () => import('@/views/RegulatorView/RegulatorView.vue');
const routes = [
{
path: '/',
redirect: () => '/login',
redirect: () => '/example',
},
{ path: '/example', name: 'ExampleView', component: ExampleView },
{ path: '/login', name: 'LoginView', component: LoginView },
{ path: '/no-fly-zone', name: 'NoFlyZoneView', component: NoFlyZoneView },
{ path: '/no-fly-zone/create', name: 'NoFlyZoneCreateView', component: NoFlyZoneEditorView, meta: { group: 'NoFlyZoneView' } },
{ path: '/no-fly-zone/:id/edit', name: 'NoFlyZoneEditorView', component: NoFlyZoneEditorView, meta: { group: 'NoFlyZoneView' } },
{ path: '/manufacturers', name: 'ManufacturerView', component: ManufacturerView },
{ path: '/regulators', name: 'RegulatorView', component: RegulatorView },
];
const router = createRouter({
@ -21,16 +35,16 @@ const router = createRouter({
});
// 前置路由钩子
// router.beforeEach((to, from, next) => {
// // 必须先登录
// if (!['LoginView'].includes(to.name) && !auth.checkToken()) {
// return next({
// name: 'LoginView',
// replace: true,
// });
// }
//
// return next();
// });
router.beforeEach((to, from, next) => {
// 必须先登录
if (!['LoginView'].includes(to.name) && !auth.checkToken()) {
return next({
name: 'LoginView',
replace: true,
});
}
return next();
});
export default router;

4
src/stores/index.js

@ -7,3 +7,7 @@ const store = createPinia();
export default store;
export * from './modules/exampleStore';
export * from './modules/noFlyZoneStore';
export * from './modules/authStore';
export * from './modules/manufacturerStore';
export * from './modules/regulatorStore';

58
src/stores/modules/authStore.js

@ -0,0 +1,58 @@
/**
* authStore
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '@/utils/http';
import * as helpers from '@/utils/helpers';
import * as urls from '@/config/urls';
import auth from '@/utils/auth';
import UserInfo from '@/utils/UserInfo';
export const useAuthStore = defineStore('auth', () => {
// state
const userInfo = ref(UserInfo.get());
// const isPlatform = computed(() => {
// const { roles } = userInfo;
// return (roles || []).includes('platform');
// });
// const isFarmer = computed(() => {
// const { roles } = userInfo;
// return (roles || []).includes('farmer');
// });
// action
function loginWithPassword(formData = {}) {
const requestDate = helpers.pick(formData, [
'phone',
'password',
]);
return http.getInstance(false).post(urls.LOGIN_WITH_PASSWORD, requestDate).then(({ data }) => {
// 获取信息
const { data: { accessToken } = {}, extra = {} } = data || {};
// 更新信息
const newUserInfo = {
...userInfo.value,
...extra,
};
userInfo.value = { ...newUserInfo };
// 保存信息
UserInfo.save(newUserInfo);
if (accessToken) {
auth.saveToken(accessToken);
}
return data;
});
}
return {
userInfo,
// isPlatform,
// isFarmer,
loginWithPassword,
};
});
export default {};

121
src/stores/modules/manufacturerStore.js

@ -0,0 +1,121 @@
/**
* 制造商
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '@/utils/http';
import * as urls from '@/config/urls';
import * as helpers from '@/utils/helpers';
export const useManufacturerStore = defineStore('manufacturer', () => {
const manufacturerList = ref([]);
const manufacturerExtra = ref({ total: null });
const manufacturerQueries = ref({ page: 1, pageSize: 10, all: undefined, search: undefined });
function getManufacturerList(otherQueries = {}) {
return http.getInstance().get(urls.GET_MANUFACTURER_LIST, {
params: { ...manufacturerQueries.value, ...otherQueries },
}).then(({ data }) => {
const { data: list, extra } = data;
manufacturerList.value = list || [];
manufacturerExtra.value = { ...manufacturerExtra.value, ...(extra || {}) };
manufacturerQueries.value = { ...manufacturerQueries.value, ...otherQueries };
return data;
});
}
function createManufacturer(formData = {}, { refreshList = false } = {}) {
const reqData = helpers.pick(formData, [
'manufacturerName',
'manufacturerLogo',
'contactPhone',
'contactName',
// 'province',
// 'city',
// 'county',
// 'enterprise_name',
'legal',
'creditCode',
'detailAddress',
'email',
'businessLicense',
'companyLogo',
// 'idcardNum',
// 'idcardFrontPic',
// 'idcardReversePic',
// 'ruleProvince',
// 'ruleCity',
// 'ruleCounty',
]);
return http.getInstance().post(urls.CREATE_MANUFACTURER, reqData).then(({ data }) => {
if (refreshList) getManufacturerList();
return data;
});
}
function updateManufacturer(formData = {}, { refreshList = false } = {}) {
const reqData = helpers.pick(formData, [
'id',
'manufacturerName',
'manufacturerLogo',
'contactPhone',
'contactName',
// 'province',
// 'city',
// 'county',
// 'enterprise_name',
'legal',
'creditCode',
'detailAddress',
'email',
'businessLicense',
'companyLogo',
// 'idcardNum',
// 'idcardFrontPic',
// 'idcardReversePic',
// 'ruleProvince',
// 'ruleCity',
// 'ruleCounty',
]);
const url = helpers.buildURL(urls.UPDATE_MANUFACTURER, reqData.id);
return http.getInstance().put(url, reqData).then(({ data }) => {
if (refreshList) getManufacturerList();
return data;
});
}
function deleteManufacturer(manufacturerId = '', { refreshList = false } = {}) {
const url = helpers.buildURL(urls.DELETE_MANUFACTURER, manufacturerId);
return http.getInstance().delete(url).then(({ data }) => {
if (refreshList) getManufacturerList();
return data;
});
}
function updateManufacturerDisable(formData = {}, { refreshList = false } = {}) {
const reqData = helpers.pick(formData, [
'id',
]);
const url = helpers.buildURL(urls.UPDATE_MANUFACTURER_DISABLE, reqData.id);
return http.getInstance().put(url).then(({ data }) => {
if (refreshList) getManufacturerList();
return data;
});
}
return {
manufacturerList,
manufacturerQueries,
manufacturerExtra,
getManufacturerList,
createManufacturer,
updateManufacturer,
deleteManufacturer,
updateManufacturerDisable,
};
});
export default null;

109
src/stores/modules/noFlyZoneStore.js

@ -0,0 +1,109 @@
/**
* 禁飞区
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '@/utils/http';
import * as urls from '@/config/urls';
import * as helpers from '@/utils/helpers';
export const useNoFlyZoneStore = defineStore('noFlyZone', () => {
const noFlyZoneList = ref([]);
const noFlyZoneExtra = ref({ total: null });
const noFlyZoneQueries = ref({ page: 1, pageSize: 10, all: undefined, type: undefined, search: undefined });
const noFlyZoneDetail = ref({});
function getNoFlyZoneList(otherQueries = {}) {
return http.getInstance().get(urls.GET_NO_FLY_ZONE_LIST, {
params: { ...noFlyZoneQueries.value, ...otherQueries },
}).then(({ data }) => {
const { data: list, extra } = data;
noFlyZoneList.value = list || [];
noFlyZoneExtra.value = { ...noFlyZoneExtra.value, ...(extra || {}) };
noFlyZoneQueries.value = { ...noFlyZoneQueries.value, ...otherQueries };
return data;
});
}
function getNoFlyZoneDetail(noFlyZoneId = '') {
const url = helpers.buildURL(urls.GET_NO_FLY_ZONE_DETAIL, noFlyZoneId);
return http.getInstance().get(url).then(({ data }) => {
const { data: detail } = data || {};
noFlyZoneDetail.value = detail;
return data;
});
}
function createNoFlyZone(formData = {}, { refreshList = false } = {}) {
const reqData = helpers.pick(formData, [
'detailAddress',
'effectTimeStart',
'effectTimeEnd',
'province',
// 'city',
// 'county',
'orbit',
]);
return http.getInstance().post(urls.CREATE_NO_FLY_ZONE, reqData).then(({ data }) => {
if (refreshList) getNoFlyZoneList();
return data;
});
}
function updateNoFlyZone(formData = {}, { refreshList = false } = {}) {
const reqData = helpers.pick(formData, [
'id',
'detailAddress',
'effectTimeStart',
'effectTimeEnd',
'province',
// 'city',
// 'county',
'orbit',
'isEnable',
]);
const url = helpers.buildURL(urls.UPDATE_NO_FLY_ZONE, reqData.id);
return http.getInstance().put(url, reqData).then(({ data }) => {
if (refreshList) getNoFlyZoneList();
return data;
});
}
function deleteNoFlyZone(noFlyZoneId = '', { refreshList = false } = {}) {
const url = helpers.buildURL(urls.DELETE_NO_FLY_ZONE, noFlyZoneId);
return http.getInstance().delete(url).then(({ data }) => {
if (refreshList) getNoFlyZoneList();
return data;
});
}
function updateNoFlyZoneState(formData = {}, { refreshList = false } = {}) {
const reqData = helpers.pick(formData, [
'id',
]);
const url = helpers.buildURL(urls.UPDATE_NO_FLY_ZONE_DISABLE, reqData.id);
return http.getInstance().put(url).then(({ data }) => {
if (refreshList) getNoFlyZoneList();
return data;
});
}
return {
noFlyZoneList,
noFlyZoneQueries,
noFlyZoneExtra,
noFlyZoneDetail,
getNoFlyZoneList,
createNoFlyZone,
updateNoFlyZone,
deleteNoFlyZone,
updateNoFlyZoneState,
getNoFlyZoneDetail,
};
});
export default null;

51
src/stores/modules/regulatorStore.js

@ -0,0 +1,51 @@
/**
* 监管者
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import http from '@/utils/http';
import * as urls from '@/config/urls';
import * as helpers from '@/utils/helpers';
export const useRegulatorStore = defineStore('regulator', () => {
const regulatorList = ref([]);
const regulatorExtra = ref({ total: null });
const regulatorQueries = ref({ page: 1, pageSize: 10, all: undefined, search: undefined });
function getRegulatorList(otherQueries = {}) {
return http.getInstance().get(urls.GET_REGULATOR_LIST, {
params: { ...regulatorQueries.value, ...otherQueries },
}).then(({ data }) => {
const { data: list, extra } = data;
regulatorList.value = list || [];
regulatorExtra.value = { ...regulatorExtra.value, ...(extra || {}) };
regulatorQueries.value = { ...regulatorQueries.value, ...otherQueries };
return data;
});
}
function createRegulator(formData = {}, { refreshList = false } = {}) {
const reqData = helpers.pick(formData, [
'phone',
'password',
]);
return http.getInstance().post(urls.CREATE_REGULATOR, reqData).then(({ data }) => {
if (refreshList) getRegulatorList();
return data;
});
}
return {
regulatorList,
regulatorQueries,
regulatorExtra,
getRegulatorList,
createRegulator,
// updateRegulator,
// deleteRegulator,
// updateRegulatorDisable,
};
});
export default null;

111
src/styles/common.less

@ -3,14 +3,125 @@
*/
@import "variables";
:root {
color-scheme: dark;
}
body,
html,
#app {
margin: 0;
padding: 0;
height: 100%;
color: white;
}
.t-layout {
background: transparent !important;
}
.mapboxgl-popup.shape-editor-popup,
.mapboxgl-popup.line-editor-popup {
.mapboxgl-popup-content {
background-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
border-bottom-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
border-top-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
border-right-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
border-left-color: fade(black, 60%);
}
.form {
padding: 10px 10px 0 6px;
& > div + div {
margin-top: 8px;
}
.buttons {
padding-left: 3em;
}
input {
border: 1px solid #2b6c5a;
background-color: fade(black, 30%);
padding: 2px 4px;
&:focus {
outline: 1px solid #00ffcc;
}
}
button {
background-color: #00ffcc;
border: none;
border-radius: 2px;
color: black;
cursor: pointer;
& + button {
margin-left: 5px;
}
}
}
}
.mapboxgl-popup.perception-popup {
.mapboxgl-popup-content {
padding: 0;
background-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
border-bottom-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip {
border-bottom-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
border-top-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip {
border-top-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
border-right-color: fade(black, 60%);
}
&.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
border-left-color: fade(black, 60%);
}
}
.mapboxgl-ctrl-bottom-left,
.mapboxgl-ctrl-bottom-right {
opacity: 0.2;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
.success-bg-color {
background-color: #52c41a !important;
}
.danger-bg-color {
background-color: #f5222d !important;
}

29
src/utils/UserInfo.js

@ -0,0 +1,29 @@
/**
* 用户信息
*/
class UserInfo {
static save(userInfo) {
localStorage.setItem('userInfo', JSON.stringify(userInfo));
}
static get() {
const userInfo = localStorage.getItem('userInfo');
if (!userInfo) {
return {};
}
try {
const result = JSON.parse(userInfo);
return typeof result === 'object' ? result : {};
} catch (e) {
//
}
return {};
}
static remove() {
localStorage.removeItem('userInfo');
}
}
export default UserInfo;

46
src/utils/commonRefs.js

@ -0,0 +1,46 @@
/*
* 公共引用
* 异步获取引用对象
*/
const refs = Symbol('refs');
const pendingList = Symbol('pendingList');
class CommonRefs {
[refs] = {};
[pendingList] = {};
setRef(key, ref) {
if (key in this[refs]) return;
this[refs][key] = ref;
const { [key]: resolves } = this[pendingList];
(resolves || []).forEach((resolve, index) => {
if (!resolve) return;
resolve(ref);
resolves[index] = undefined; // 只作废,不删除
});
}
getRef(key) {
return new Promise((resolve) => {
const { [key]: ref } = this[refs];
if (ref) {
resolve(ref);
} else if (key in this[pendingList]) {
this[pendingList][key].push(resolve);
} else {
this[pendingList][key] = [resolve];
}
});
}
removeRef(key) {
if (key in this[refs]) {
delete this[refs][key];
}
}
}
export default new CommonRefs();

180
src/utils/helpers.js

@ -0,0 +1,180 @@
/**
* 辅助函数
*/
import moment from 'moment';
import * as turf from '@turf/turf';
/**
* 等待异步结果
* @param checker 检测函数返回值为truthy时表示得到想要的结果否则反之
* @param timeout 指定时间内没有得到想要的结果则超时
*/
export function until(checker = () => true, timeout = 2000) {
return new Promise((resolve) => {
let pollingTimer = null;
const timeoutTimer = setTimeout(() => {
clearInterval(pollingTimer);
resolve(false);
}, timeout);
pollingTimer = setInterval(() => {
const result = checker();
if (result) {
clearTimeout(timeoutTimer);
clearInterval(pollingTimer);
resolve(result);
}
}, 10);
});
}
/**
* 根据restful的uri构建实际url
* @param uri uri模版
* @param argv 插入到uri中的参数列表按顺序插入
* @returns url
*/
export function buildURL(uri, ...argv) {
return uri.replace(/{\w+}|:[a-zA-Z]+/g, () => {
const res = argv.shift();
if (res === undefined) {
throw new Error('URI 参数不足');
}
return res;
});
}
/**
* 选取原object指定属性构成新的object
* @param originObject
* @param pickKeys
*/
export function pick(originObject, pickKeys = []) {
const newObject = {};
pickKeys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(originObject, key)) {
newObject[key] = originObject[key];
}
});
return newObject;
}
/**
* 更新target对象的键值使用source对象里的数据
* @param target
* @param source
*/
export function update(target, source) {
const result = { ...target };
Object.keys(target).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(source, key)) {
result[key] = source[key];
}
});
return result;
}
/**
* 将对象的部分key重命名
* @param originObject 原始对象也可以是相同结构的对象构成的数组
* @param keysMapping 新keys映射{ name: 'username', sex: 'gender' }表示把原始对象中的name重命名为username把sex重命名为gender原始对象其他key不变
* @returns {{}|*} 新对象
*/
export function renameKeys(originObject, keysMapping = {}) {
if (!Array.isArray(originObject)) {
const kvList = Object.keys(originObject).map((key) => {
const newKey = keysMapping[key] || key;
return { [newKey]: originObject[key] };
});
return Object.assign({}, ...kvList);
}
return originObject.map((obj) => renameKeys(obj, keysMapping));
}
/**
* 随机数
*/
export function rand(min, max, fraction = 0) {
const res = (Math.random() * ((max - min) + 1)) + min;
return res.toFixed(fraction) - 0;
}
/**
* 将input当着数字处理若不是数字则返回代替字符
* @param input
* @param substitution
* @returns {string|number}
*/
export function numeric(input, substitution = '-') {
const type = typeof input;
if (type !== 'number' && type !== 'string') return substitution;
const output = Number(input);
return !Number.isNaN(output) ? output : substitution;
}
// 保留小数位(是整数则不保留)
export const toFixed = (num, digits = 2) => (Number(num || 0).toFixed(digits)) - 0;
/**
* 格式化时间戳
* @param timestamp
* @param format
* @returns {string}
*/
export function formatTime(timestamp, format = 'YYYY-MM-DD HH:mm:ss') {
return (timestamp || timestamp === 0) && moment(timestamp).isValid() ? moment(timestamp).format(format) : '-';
}
/**
* 将总秒数格式化为x时y分z秒形式
* @param sec
* @param precision
* @param isChinese
* @returns {string}
*/
export function popularTime(sec, precision = 0, isChinese = false) {
const { HOUR, MINUTE, SECOND } = isChinese ? { HOUR: '时', MINUTE: '分', SECOND: '秒' } : { HOUR: 'h', MINUTE: 'm', SECOND: 's' };
let remainingSec = sec;
const hours = Math.floor(remainingSec / 3600);
remainingSec -= hours * 3600;
const minutes = Math.floor(remainingSec / 60);
remainingSec -= minutes * 60;
return `${hours ? `${hours}${HOUR}` : ''}${minutes ? `${minutes}${MINUTE}` : ''}${remainingSec ? `${remainingSec.toFixed(precision)}${SECOND}` : ''}`;
}
/**
* 将秒转化为小时
* @param sec
* @returns {number}
*/
export function toHour(sec) {
return (sec / 3600).toFixed(2) - 0;
}
/**
* 对数组中的每个元素进行偏移计算返回计算后的数组
* @param originArray 原数组有数字构成
* @param offsetArray 偏移量数组与原数组元素一一对应
* @returns {*[]} 计算后的新数组
*/
export function arrOffset(originArray = [], offsetArray = []) {
return originArray.map((item, index) => +item + (offsetArray[index] || 0));
}
/**
* 计算经纬度构成的区域面积
* @param coordinates 经纬度点列表
* @param unit 单位默认
* @returns {number} 面积值平方米
*/
export function computeArea(coordinates, unit = 'mu') {
const lineFeature = turf.lineString(coordinates);
const polygonFeature = turf.lineToPolygon(lineFeature);
const { [unit]: rate } = { mu: 0.0015 };
return turf.area(polygonFeature) * rate;
}

125
src/views/LoginView/LoginView.vue

@ -1,13 +1,136 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores';
import { MessagePlugin } from 'tdesign-vue-next';
const router = useRouter(); // this.$router
const { loginWithPassword } = useAuthStore();
const { APP_TITLE } = import.meta.env; //
const title = ref(APP_TITLE); //
const formRules = { // form
phone: [{ required: true, message: '请输入账户' }],
password: [{ required: true, message: '请输入密码' }],
};
const form = ref(); // this.$ref
const formData = ref({ //
phone: '18188888888',
password: 'asdf1234',
});
const onSubmit = ({ validateResult }) => { //
if (validateResult !== true) return; //
//
loginWithPassword(formData.value).then(() => {
router.replace('/');
MessagePlugin.success('登录成功');
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
};
</script>
<template>
<div :class="s.root">LoginView</div>
<div :class="s.root">
<!-- 登录卡片 -->
<div class="login-card">
<!-- 标题 -->
<span class="title">{{ title }}</span>
<!-- 表单 -->
<t-form ref="form" :data="formData" :rules="formRules" :label-width="0" @submit="onSubmit">
<t-form-item name="phone">
<t-input v-model="formData.phone" clearable placeholder="请输入账户名" />
</t-form-item>
<t-form-item name="password">
<t-input v-model="formData.password" type="password" clearable placeholder="请输入密码" />
</t-form-item>
<t-form-item>
<t-button theme="primary" type="submit" block>
登录
</t-button>
</t-form-item>
</t-form>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
height: inherit;
background: url("../../assets/login_bg.png") no-repeat scroll center bottom transparent;
background-size: cover;
//
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
:global {
.login-card {
position: relative;
width: 70vh;
min-width: 380px;
padding: 8vh 10vh;
border-radius: var(--td-radius-extraLarge);
background-color: fade(black, 40%);
backdrop-filter: blur(5px);
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
.title {
position: absolute;
top: 0;
transform: translateY(calc(-150% - var(--td-comp-margin-m)));
color: white;
//font-size: var(--td-font-size-display-large);
font-size: 8vh;
white-space: nowrap;
letter-spacing: 1vw;
text-shadow: 0 0 8px black;
}
.t-form {
min-width: 280px;
width: 20vw;
.t-input {
color: white;
background-color: transparent;
&:focus {
box-shadow: unset;
}
.t-input__inner {
color: white;
&::placeholder {
color: fade(white, 70%);
}
}
.t-input__suffix > .t-icon {
color: fade(white, 40%);
&:hover {
color: fade(white, 90%);
}
}
}
.t-input--focused {
box-shadow: unset;
}
}
}
}
}
</style>

131
src/views/ManufacturerView/ManufacturerView.vue

@ -0,0 +1,131 @@
<script setup>
import BasePanel from '@/components/BasePanel.vue';
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useManufacturerStore } from '@/stores';
import { MessagePlugin } from 'tdesign-vue-next';
import ManufacturerEditor from '@/views/ManufacturerView/components/ManufacturerEditor.vue';
import ManufacturerDetail from '@/views/ManufacturerView/components/ManufacturerDetail.vue';
import eventBus from '@/utils/eventBus';
const manufacturerStore = useManufacturerStore();
const { manufacturerList, manufacturerQueries, manufacturerExtra } = storeToRefs(manufacturerStore);
const { getManufacturerList, updateManufacturerDisable, deleteManufacturer } = manufacturerStore;
function loadList(queries = {}) {
getManufacturerList(queries).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
}
loadList();
function onPageChange({ current, pageSize }) {
loadList({ page: current, pageSize });
}
const search = ref();
function onSearchList() {
loadList({ search: search.value });
}
const columns = [
{ colKey: 'manufacturerName', title: '企业/组织名称', ellipsis: true },
{ colKey: 'contactName', title: '联系人名称', ellipsis: true },
{ colKey: 'contactPhone', title: '联系人电话(账号)', ellipsis: true },
{ colKey: 'disabled', title: '状态', ellipsis: true },
{ colKey: 'operation', title: '操作', width: '350px' },
];
function onShowEditor(row = {}) {
eventBus.emit('show-manufacturer-editor', row);
}
function onShowDetail(row = {}) {
eventBus.emit('show-manufacturer-detail', row);
}
const pagination = computed(() => ({
current: manufacturerQueries.value.page,
pageSize: manufacturerQueries.value.pageSize,
total: manufacturerExtra.value.total,
}));
function onChangeState(row = {}) {
updateManufacturerDisable(row, { refreshList: true }).then(() => {
MessagePlugin.success('更改成功');
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
}
function onDeleteManufacturer(row = {}) {
deleteManufacturer(row.id, { refreshList: true }).then(() => {
MessagePlugin.success('成功删除');
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
}
</script>
<template>
<BasePanel>
<template #header>制造商管理</template>
<template #header-extra>
<t-space>
<t-button @click="onShowEditor">新增制造商</t-button>
</t-space>
</template>
<template #top-bar>
<t-space align="center">
<div>关键字查询</div>
<t-input v-model="search" clearable />
<t-button @click="onSearchList">查询</t-button>
</t-space>
</template>
<t-table
row-key="id"
:data="manufacturerList"
:columns="columns"
cell-empty-content="-"
:pagination="pagination"
@page-change="onPageChange"
>
<template #disabled="{ row }">
<t-tag theme="success" v-if="!row.disabled">正常</t-tag>
<t-tag theme="danger" v-else>禁用</t-tag>
</template>
<template #operation="{ row }">
<t-space align="center">
<t-popconfirm :content="`确认${!row.disabled ? '禁用' : '启用'}吗?`" @confirm="onChangeState(row)">
<t-button :theme="!row.disabled ? 'danger' : 'success'">{{ !row.disabled ? '禁用' : '启用' }}</t-button>
</t-popconfirm>
<t-popconfirm :content="`确认删除吗?`" @confirm="onDeleteManufacturer(row)">
<t-button theme="danger">删除</t-button>
</t-popconfirm>
<t-button @click="onShowEditor(row)">编辑</t-button>
<t-button @click="onShowDetail(row)">详情</t-button>
</t-space>
</template>
</t-table>
</BasePanel>
<ManufacturerEditor />
<ManufacturerDetail />
</template>
<style lang="less" module="s">
.root {
//
}
.dropdown {
:global {
.t-dropdown__submenu ul {
margin: 0;
}
}
}
</style>

152
src/views/ManufacturerView/components/ManufacturerDetail.vue

@ -0,0 +1,152 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import eventBus from '@/utils/eventBus';
const visible = ref(false);
/* function onCancel() {
visible.value = false;
info.value = {};
// form.value.reset();
} */
const info = ref({});
onMounted(() => {
eventBus.on('show-manufacturer-detail', (row = {}) => {
info.value = { ...row };
visible.value = true;
});
});
onUnmounted(() => {
eventBus.off('show-manufacturer-detail');
});
</script>
<template>
<t-dialog
:class="s.root"
v-model:visible="visible"
:footer="null"
top="70px"
>
<template #header>
<div style="flex: 1; text-align: center;">制造商详情</div>
</template>
<div class="grid-container">
<div class="grid-item label">企业/组织名称</div>
<div class="grid-item">{{ info.manufacturerName || '-' }}</div>
<div class="grid-item label">法人代表</div>
<div class="grid-item">{{ info.legal || '-' }}</div>
<div class="grid-item label">统一社会信用代码</div>
<div class="grid-item">{{ info.creditCode || '-' }}</div>
<div class="grid-item label">联系人姓名</div>
<div class="grid-item">{{ info.contactName || '-' }}</div>
<div class="grid-item label">联系人电话</div>
<div class="grid-item">{{ info.contactPhone || '-' }}</div>
<div class="grid-item label">联系地址</div>
<div class="grid-item">{{ info.detailAddress || '-' }}</div>
<div class="grid-item label">E-mail</div>
<div class="grid-item">{{ info.email || '-' }}</div>
<div class="grid-item label">营业执照扫描件</div>
<div class="grid-item">
<t-image-viewer
:images="[info.businessLicense]"
>
<template #trigger="{ open: onPreview }">
<div class="image-box">
<t-image
shape="round"
fit="cover"
:style="{ 'min-width': '160px', height: '120px' }"
:src="info.businessLicense"
error=""
/>
<div class="image-hover" @click="onPreview">
<span><t-icon name="browse" size="1.4em" /> 预览</span>
</div>
</div>
</template>
</t-image-viewer>
</div>
<div class="grid-item label">公司LOGO</div>
<div class="grid-item">
<t-image-viewer
:images="[info.companyLogo]"
>
<template #trigger="{ open: onPreview }">
<div class="image-box">
<t-image
shape="round"
fit="cover"
:style="{ 'min-width': '160px', height: '120px' }"
:src="info.companyLogo"
error=""
/>
<div class="image-hover" @click="onPreview">
<span><t-icon name="browse" size="1.4em" /> 预览</span>
</div>
</div>
</template>
</t-image-viewer>
</div>
</div>
</t-dialog>
</template>
<style lang="less" module="s">
.root {
//
:global {
.grid-container {
display: grid;
grid-template-columns: 160px auto;
grid-gap: 10px;
.label {
text-align: right;
}
}
.grid-item {
color: var(--td-text-color-primary);
padding: 10px;
.image-box {
//width: 40px;
//height: 60px;
display: inline-flex;
position: relative;
justify-content: center;
align-items: center;
border-radius: var(--td-radius-small);
overflow: hidden;
.image-hover {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
left: 0;
top: 0;
opacity: 0;
background-color: rgba(0, 0, 0, 0.6);
color: var(--td-text-color-anti);
line-height: 22px;
transition: 0.2s;
z-index: 1;
}
&:hover .image-hover {
opacity: 1;
cursor: pointer;
}
}
}
}
}
</style>

155
src/views/ManufacturerView/components/ManufacturerEditor.vue

@ -0,0 +1,155 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { useManufacturerStore } from '@/stores';
import eventBus from '@/utils/eventBus';
import ImageUploader from '@/components/ImageUploader.vue';
import { update } from '@/utils/helpers';
const visible = ref(false);
const manufacturerStore = useManufacturerStore();
const { createManufacturer, updateManufacturer } = manufacturerStore;
const form = ref();
const formData = ref({
id: undefined,
manufacturerName: undefined,
manufacturerLogo: undefined,
contactPhone: undefined,
contactName: undefined,
// province: undefined,
// city: undefined,
// county: undefined,
// enterprise_name: undefined,
legal: undefined,
creditCode: undefined,
detailAddress: undefined,
email: undefined,
businessLicense: undefined,
companyLogo: undefined,
// idcardNum: undefined,
// idcardFrontPic: undefined,
// idcardReversePic: undefined,
// ruleProvince: undefined,
// ruleCity: undefined,
// ruleCounty: undefined,
});
const FORM_RULES = {
manufacturerName: [{ required: true, message: '请输入企业/组织名称' }],
contactPhone: [{ required: true, message: '请输入联系人电话' }],
contactName: [{ required: true, message: '请输入联系人名称' }],
};
function onCancel() {
visible.value = false;
form.value.reset();
}
function onSubmit({ validateResult }) {
if (validateResult === true) {
if (formData.value.id) {
updateManufacturer(formData.value, { refreshList: true }).then(() => {
MessagePlugin.success('更新成功');
onCancel();
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
return;
}
createManufacturer(formData.value, { refreshList: true }).then(() => {
MessagePlugin.success('创建成功');
onCancel();
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
}
}
onMounted(() => {
eventBus.on('show-manufacturer-editor', (row = {}) => {
if (row.id) {
formData.value = update(formData.value, row);
}
visible.value = true;
});
});
onUnmounted(() => {
eventBus.off('show-manufacturer-editor');
});
</script>
<template>
<t-dialog
:class="s.root"
v-model:visible="visible"
:close-btn="false"
:footer="null"
@closed="onCancel"
top="70px"
>
<template #header>
<div style="flex: 1; text-align: center;">{{ formData.id ? '更新' : '创建' }}制造商</div>
</template>
<t-form
ref="form"
:data="formData"
:rules="FORM_RULES"
label-width="150px"
colon
@submit="onSubmit"
>
<t-form-item name="id" v-show="false" />
<t-form-item label="企业/组织名称" name="manufacturerName">
<t-input v-model="formData.manufacturerName" />
</t-form-item>
<t-form-item label="法人代表" name="legal">
<t-input v-model="formData.legal" />
</t-form-item>
<t-form-item label="统一社会信用代码" name="creditCode">
<t-input v-model="formData.creditCode" />
</t-form-item>
<t-form-item label="联系人姓名" name="contactName">
<t-input v-model="formData.contactName" />
</t-form-item>
<t-form-item label="联系人电话" name="contactPhone" help="创建后的登录账号">
<t-input v-model="formData.contactPhone" />
</t-form-item>
<t-form-item label="联系地址" name="detailAddress">
<t-input v-model="formData.detailAddress" />
</t-form-item>
<t-form-item label="E-mail" name="email">
<t-input v-model="formData.email" />
</t-form-item>
<t-form-item label="营业执照扫描件" name="businessLicense">
<ImageUploader subject="device_pic" v-model="formData.businessLicense" />
</t-form-item>
<t-form-item label="公司LOGO" name="companyLogo">
<ImageUploader subject="device_pic" v-model="formData.companyLogo" />
</t-form-item>
<t-form-item>
<t-space>
<t-button theme="primary" type="submit">提交</t-button>
<t-button theme="default" @click="onCancel">取消</t-button>
</t-space>
</t-form-item>
</t-form>
</t-dialog>
</template>
<style lang="less" module="s">
.root {
//
}
</style>

261
src/views/NoFlyZoneEditorView/NoFlyZoneEditorView.vue

@ -0,0 +1,261 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import ShapeCreator from '@/core/ShapeCreator';
import ShapeEditor from '@/core/ShapeEditor';
import ShapeRenderer from '@/core/ShapeRenderer';
import commonRefs from '@/utils/commonRefs';
import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next';
import { useRoute, useRouter } from 'vue-router';
import { useNoFlyZoneStore } from '@/stores';
import { update } from '@/utils/helpers';
let Tips;
const noFlyZoneCreator = new ShapeCreator('noFlyZone-form');
const noFlyZoneEditor = new ShapeEditor('noFlyZone-form', true);
const noFlyZoneRenderer = new ShapeRenderer('noFlyZone-form');
const route = useRoute();
const router = useRouter();
const { createNoFlyZone, getNoFlyZoneDetail, updateNoFlyZone } = useNoFlyZoneStore();
const formData = ref({
id: undefined,
detailAddress: undefined,
effectTimeStart: undefined,
effectTimeEnd: undefined,
province: undefined,
// city: undefined,
// county: undefined,
orbit: undefined,
});
const FORM_RULES = {
detailAddress: [{ required: true, message: '请输入禁飞区名称' }],
effectTimeStart: [{ required: true, message: '请选择有效期范围' }],
province: [{ required: true, message: '请输入禁飞区位置' }],
orbit: [{ required: true, type: Array, message: '请绘制禁飞区' }],
};
const effectiveTime = computed({
get() {
if (formData.value.effectTimeStart && formData.value.effectTimeEnd) {
return [formData.value.effectTimeStart * 1000, formData.value.effectTimeEnd * 1000];
}
return [];
},
set(nv) {
const [startDay, endDay] = nv || [];
formData.value.effectTimeStart = (startDay / 1000) || undefined;
formData.value.effectTimeEnd = (endDay / 1000) || undefined;
},
});
const onSubmit = ({ validateResult, firstError }) => {
if (validateResult === true) {
(formData.value.orbit || []).push({ ...formData.value.orbit[0] });
const data = { ...formData.value, orbit: JSON.stringify(formData.value.orbit) };
if (formData.value.id) {
updateNoFlyZone(data).then(() => {
MessagePlugin.success('更新成功');
router.back();
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
return;
}
createNoFlyZone(data).then(() => {
MessagePlugin.success('添加成功');
router.back();
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
} else {
MessagePlugin.warning(firstError);
}
};
function showOperationTips(action) {
let content = '';
switch (action) {
case 1:
content = '在地图上,沿着禁飞区边缘逐个打点,最后闭合形状,即可围成一个禁飞区';
break;
case 2:
content = h => [
h('div', '请根据操作点上的提示进行编辑禁飞区'),
h('div', '点击禁飞区以外区域,即可完成禁飞区编辑'),
];
break;
default:
break;
}
if (!content) return;
Tips = NotifyPlugin.info({
title: '操作提示',
content,
offset: [0, 54],
duration: 0,
});
}
function closeOperationTips() {
if (Tips) NotifyPlugin.close(Tips);
}
const isEdit = computed(() => !!route?.params?.id);
async function init() {
//
const map = await commonRefs.getRef('map');
noFlyZoneCreator.setMap(map);
noFlyZoneEditor.setMap(map);
noFlyZoneRenderer.setMap(map);
noFlyZoneRenderer.updateStyle({
fillColor: 'rgba(255, 0, 0, 0.1)',
fillHoverColor: 'rgba(255, 0, 0, 0.3)',
strokeColor: 'rgba(255, 0, 0, 0.5)',
strokeHoverColor: 'rgba(255, 0, 0, 1)',
});
noFlyZoneCreator.on('shape_kinked', ({ action }) => {
if (action === 'drag') {
MessagePlugin.error('禁飞区形状扭结了');
return;
}
if (action === 'click') {
MessagePlugin.error('在这个位置打点会造成禁飞区形状扭结');
return;
}
if (action === 'close') {
MessagePlugin.error('禁飞区形状扭结了,无法闭合');
}
});
noFlyZoneCreator.on('completed', (coordinates) => {
noFlyZoneCreator.end();
noFlyZoneEditor.loadDataSource({ id: 0, points: coordinates });
closeOperationTips();
showOperationTips(2);
});
noFlyZoneEditor.on('shape_kinked', ({ action }) => {
if (action === 'drag') {
MessagePlugin.error('禁飞区形状扭结了');
return;
}
if (action === 'close') {
MessagePlugin.error('禁飞区形状扭结了,无法结束编辑');
}
});
noFlyZoneEditor.on('completed', ({ points }) => {
formData.value.orbit = (points || []).map(([lng, lat]) => ({ lng, lat }));
const detail = {
id: formData.value.id || 0,
points,
};
noFlyZoneRenderer.loadDataSource([detail]);
closeOperationTips();
});
noFlyZoneRenderer.on('click', () => {
noFlyZoneRenderer.clear();
noFlyZoneEditor.loadDataSource({
id: formData.value.id || 0,
points: formData.value.orbit,
});
showOperationTips(2);
});
if (isEdit.value) {
noFlyZoneRenderer.loadDataSource([{ id: formData.value.id, points: formData.value.orbit }]);
noFlyZoneRenderer.fitShape(formData.value.id);
noFlyZoneRenderer.clear();
noFlyZoneEditor.loadDataSource({ id: formData.value.id, points: formData.value.orbit });
} else {
noFlyZoneCreator.start();
showOperationTips(1);
}
}
onMounted(() => {
if (isEdit.value) {
getNoFlyZoneDetail(route.params.id).then(({ data }) => {
formData.value = update(formData.value, data);
formData.value.orbit = JSON.parse(formData.value.orbit);
if (formData.value.orbit.length) {
(formData.value.orbit || []).splice(formData.value.orbit.length - 1, 1);
}
init();
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
return;
}
init();
});
onUnmounted(() => {
noFlyZoneCreator.clear(true);
noFlyZoneEditor.clear();
noFlyZoneRenderer.clear();
closeOperationTips();
});
</script>
<template>
<div :class="s.root">
<div class="container">
<t-form
ref="form"
:data="formData"
:rules="FORM_RULES"
label-width="calc(2em + 60px)"
layout="inline"
scroll-to-first-error="smooth"
@submit="onSubmit"
>
<t-form-item name="id" v-show="false" />
<t-form-item name="orbit" v-show="false" />
<t-form-item label="禁飞区名称" name="detailAddress">
<t-input v-model="formData.detailAddress" />
</t-form-item>
<t-form-item label="禁飞区位置" name="province">
<t-input v-model="formData.province" placeholder="XX省 XX市 XX(区/县)" />
</t-form-item>
<t-form-item label="生效日期" name="effectTimeStart">
<t-date-range-picker value-type="time-stamp" v-model="effectiveTime" clearable />
</t-form-item>
<t-form-item label-width="0" style="min-width: unset;">
<t-space size="small">
<t-button theme="primary" type="submit">提交</t-button>
<t-button theme="default" @click="router.back()">返回列表</t-button>
</t-space>
</t-form-item>
</t-form>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
position: absolute;
top: 0;
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
:global {
.container {
pointer-events: auto;
background-color: var(--td-bg-color-container);
border-radius: var(--td-radius-medium);
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-l);
}
}
}
</style>

244
src/views/NoFlyZoneView/NoFlyZoneView.vue

@ -0,0 +1,244 @@
<script setup>
import BasePanel from '@/components/BasePanel.vue';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { formatTime } from '@/utils/helpers';
import { storeToRefs } from 'pinia';
import { useNoFlyZoneStore } from '@/stores';
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next';
import ShapeRenderer from '@/core/ShapeRenderer';
import commonRefs from '@/utils/commonRefs';
import gcoord from 'gcoord';
const noFlyZoneRenderer = new ShapeRenderer('noFlyZone-show');
async function init() {
const map = await commonRefs.getRef('map');
noFlyZoneRenderer.setMap(map);
noFlyZoneRenderer.updateStyle({
fillColor: 'rgba(255, 0, 0, 0.1)',
fillHoverColor: 'rgba(255, 0, 0, 0.3)',
strokeColor: 'rgba(255, 0, 0, 0.5)',
strokeHoverColor: 'rgba(255, 0, 0, 1)',
});
}
onMounted(() => {
init();
});
const noFlyZoneStore = useNoFlyZoneStore();
const { noFlyZoneList, noFlyZoneQueries, noFlyZoneExtra } = storeToRefs(noFlyZoneStore);
const { getNoFlyZoneList, deleteNoFlyZone, updateNoFlyZoneState } = noFlyZoneStore;
function onDeleteNoFlyZone(row = {}) {
const dialog = DialogPlugin({
header: '操作确认',
body: '确认删除此禁飞区吗?',
onConfirm: () => {
deleteNoFlyZone(row.id, { refreshList: true }).then(() => {
MessagePlugin.success('删除成功');
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
}).finally(() => {
dialog.hide();
});
},
});
}
function onUpdateNoFlyZoneState(row = {}) {
const dialog = DialogPlugin({
header: '操作确认',
body: `确认${!row.disabled ? '禁用' : '启用'}此禁飞区吗?`,
onConfirm: () => {
updateNoFlyZoneState(row, { refreshList: true }).then(() => {
MessagePlugin.success('更改成功');
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
}).finally(() => {
dialog.hide();
});
},
});
}
function loadList(queries = {}) {
getNoFlyZoneList(queries).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
}
loadList();
function onPageChange({ current, pageSize }) {
loadList({ page: current, pageSize });
}
function onFilterChange({ type = undefined } = {}) {
loadList({ type });
}
const search = ref();
function onSearchList() {
loadList({ search: search.value });
}
const columns = [
{ colKey: 'detailAddress', title: '禁飞区名称', ellipsis: true, width: '300px' },
{
colKey: 'province',
title: '禁飞区位置',
ellipsis: true,
cell: (_, { row }) => {
const { province, city, county } = row;
if (county) {
return `${province || ''}${city || ''}${county || ''}`;
}
if (province) {
return province;
}
return '-';
},
},
{
colKey: 'type',
title: '禁飞区类型',
ellipsis: true,
width: '150px',
cell: (_, { row }) => (row.type === 1 ? '标准禁飞区' : '民航禁飞区'),
filter: {
type: 'single',
list: [
{ label: '标准禁飞区', value: 1 },
{ label: '民航禁飞区', value: 2 },
],
showConfirmAndReset: true,
},
},
{
colKey: 'time',
title: '有效时间',
ellipsis: true,
cell: (_, { row }) => `${formatTime(row.effectTimeStart * 1000, 'YYYY-MM-DD')}/${formatTime(row.effectTimeEnd * 1000, 'YYYY-MM-DD')}`,
},
{ colKey: 'effectStatus', title: '是否有效', ellipsis: true },
{ colKey: 'disabled', title: '当前状态', ellipsis: true },
{ colKey: 'operation', title: '操作', fixed: 'right', width: '100px' },
];
const expandAbility = ref(false);
const width = computed(() => (expandAbility.value ? '50vh' : '100%'));
const router = useRouter();
function onNavTo(routeName, param = {}) {
router.push({
name: routeName,
...(param.id ? { params: { id: param.id } } : {}),
});
}
const pagination = computed(() => ({
current: noFlyZoneQueries.value.page,
pageSize: noFlyZoneQueries.value.pageSize,
total: noFlyZoneExtra.value.total,
showPageSize: !expandAbility.value,
size: expandAbility.value ? 'small' : 'medium',
}));
function onShowNoFlyZone(row = {}) {
if (!expandAbility.value) {
expandAbility.value = true;
}
const { orbit: pointStr, id } = row;
const orbit = JSON.parse(pointStr);
const points = orbit.map(item => gcoord.transform([item.lng, item.lat], gcoord.WGS84, gcoord.GCJ02));
const detail = {
id,
points,
};
noFlyZoneRenderer.loadDataSource([detail]);
noFlyZoneRenderer.fitShape(id, { right: 80 }, 150);
}
onUnmounted(() => {
noFlyZoneRenderer.clear();
});
</script>
<template>
<BasePanel :width="width" :expand-ability="expandAbility">
<template #header>禁飞区管理</template>
<template #header-extra>
<t-space>
<t-button @click="onNavTo('NoFlyZoneCreateView')">新增禁飞区</t-button>
<t-button v-if="expandAbility" @click="expandAbility = false">展开列表</t-button>
</t-space>
</template>
<template #top-bar>
<t-space align="center">
<div>关键字查询</div>
<t-input v-model="search" clearable />
<t-button @click="onSearchList">查询</t-button>
</t-space>
</template>
<t-table
row-key="id"
:data="noFlyZoneList"
:columns="columns"
cell-empty-content="-"
:pagination="pagination"
@filter-change="onFilterChange"
@page-change="onPageChange"
>
<template #effectStatus="{ row }">
<t-tag theme="primary" v-if="row.effectStatus === 1">有效</t-tag>
<t-tag theme="warning" v-else>无效</t-tag>
</template>
<template #disabled="{ row }">
<t-tag theme="success" v-if="!row.disabled">启用</t-tag>
<t-tag theme="danger" v-else>禁用</t-tag>
</template>
<template #operation="{ row }">
<t-dropdown>
<t-button shape="square" variant="text">
<template #icon>
<t-icon name="view-list" />
</template>
</t-button>
<t-dropdown-menu>
<template v-if="row.type === 1">
<t-dropdown-item @click="onUpdateNoFlyZoneState(row)">{{ !row.disabled ? '禁用' : '启用' }}</t-dropdown-item>
<t-dropdown-item @click="onDeleteNoFlyZone(row)">删除</t-dropdown-item>
<t-dropdown-item @click="onNavTo('NoFlyZoneEditorView', row)">编辑</t-dropdown-item>
<t-dropdown-item @click="onShowNoFlyZone(row)">查看</t-dropdown-item>
</template>
<template v-else>
<t-dropdown-item @click="onShowNoFlyZone(row)">查看</t-dropdown-item>
</template>
</t-dropdown-menu>
</t-dropdown>
</template>
</t-table>
</BasePanel>
</template>
<style lang="less" module="s">
.root {
//
}
.dropdown {
:global {
.t-dropdown__submenu ul {
margin: 0;
}
}
}
</style>

86
src/views/RegulatorView/RegulatorView.vue

@ -0,0 +1,86 @@
<script setup>
import BasePanel from '@/components/BasePanel.vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useRegulatorStore } from '@/stores';
import { MessagePlugin } from 'tdesign-vue-next';
import RegulatorEditor from '@/views/RegulatorView/components/RegulatorEditor.vue';
import eventBus from '@/utils/eventBus';
const regulatorStore = useRegulatorStore();
const { regulatorList, regulatorQueries, regulatorExtra } = storeToRefs(regulatorStore);
const { getRegulatorList } = regulatorStore;
function loadList(queries = {}) {
getRegulatorList(queries).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
}
loadList();
function onPageChange({ current, pageSize }) {
loadList({ page: current, pageSize });
}
const columns = [
{ colKey: 'serial-number', title: '序列号', ellipsis: true },
// { colKey: 'contactName', title: '', ellipsis: true },
{ colKey: 'phone', title: '手机号(账号)', ellipsis: true },
];
function onShowEditor(row = {}) {
eventBus.emit('show-regulator-editor', row);
}
const pagination = computed(() => ({
current: regulatorQueries.value.page,
pageSize: regulatorQueries.value.pageSize,
total: regulatorExtra.value.total,
}));
</script>
<template>
<BasePanel>
<template #header>监管者管理</template>
<template #header-extra>
<t-space>
<t-button @click="onShowEditor">新增监管者</t-button>
</t-space>
</template>
<template #top-bar>
<t-space align="center" v-if="false">
<div>关键字查询</div>
<t-input clearable />
<t-button>查询</t-button>
</t-space>
</template>
<t-table
row-key="id"
:data="regulatorList"
:columns="columns"
cell-empty-content="-"
:pagination="pagination"
@page-change="onPageChange"
/>
</BasePanel>
<RegulatorEditor />
</template>
<style lang="less" module="s">
.root {
//
}
.dropdown {
:global {
.t-dropdown__submenu ul {
margin: 0;
}
}
}
</style>

90
src/views/RegulatorView/components/RegulatorEditor.vue

@ -0,0 +1,90 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { useRegulatorStore } from '@/stores';
import eventBus from '@/utils/eventBus';
const visible = ref(false);
const regulatorStore = useRegulatorStore();
const { createRegulator } = regulatorStore;
const form = ref();
const formData = ref({
phone: undefined,
password: undefined,
});
const FORM_RULES = {
phone: [{ required: true, message: '请输入手机号' }],
password: [{ required: true, message: '请输入密码' }],
};
function onCancel() {
visible.value = false;
form.value.reset();
}
function onSubmit({ validateResult }) {
if (validateResult === true) {
createRegulator(formData.value, { refreshList: true }).then(() => {
MessagePlugin.success('创建成功');
onCancel();
}).catch(({ message }) => {
if (message) MessagePlugin.error(message);
});
}
}
onMounted(() => {
eventBus.on('show-regulator-editor', () => {
visible.value = true;
});
});
onUnmounted(() => {
eventBus.off('show-regulator-editor');
});
</script>
<template>
<t-dialog
:class="s.root"
v-model:visible="visible"
:close-btn="false"
:footer="null"
@closed="onCancel"
>
<template #header>
<div style="flex: 1; text-align: center;">创建监管者</div>
</template>
<t-form
ref="form"
:data="formData"
:rules="FORM_RULES"
label-width="150px"
colon
@submit="onSubmit"
>
<t-form-item label="手机号" name="phone" help="创建后的登录账号">
<t-input v-model="formData.phone" />
</t-form-item>
<t-form-item label="密码" name="password">
<t-input type="password" v-model="formData.password" />
</t-form-item>
<t-form-item>
<t-space>
<t-button theme="primary" type="submit">提交</t-button>
<t-button theme="default" @click="onCancel">取消</t-button>
</t-space>
</t-form-item>
</t-form>
</t-dialog>
</template>
<style lang="less" module="s">
.root {
//
}
</style>

23
src/views/common/useGlobalSettings.js

@ -0,0 +1,23 @@
/**
* 本地持久化配置项
*/
import { toValue } from 'vue';
import { createGlobalState, useStorage } from '@vueuse/core';
export const useGlobalSettings = createGlobalState(() => {
const settings = useStorage('settings', {
showMapHd: true,
showFieldFill: true,
showFieldName: true,
});
const valueOf = () => toValue(settings);
const set = (key, val) => {
if (key in settings.value) settings.value[key] = val;
};
return { set, valueOf };
});
export default null;
Loading…
Cancel
Save