This commit is contained in:
etoai 2025-04-17 23:37:44 +08:00
parent 5563ffb05c
commit 1d8fff443b
11 changed files with 574 additions and 24 deletions

View File

@ -73,6 +73,7 @@ INSTALLED_APPS = [
'apps.lyTiktokUnion',
'apps.lyworkflow',
'apps.mymaps',
'apps.map',
]
MIDDLEWARE = [

View File

@ -101,6 +101,7 @@ urlpatterns = [
path('api/lyformbuilder/', include('apps.lyFormBuilder.urls')),
path('api/lytiktokunion/', include('apps.lyTiktokUnion.urls')),
path('api/workflow/', include('apps.lyworkflow.urls')),
path('api/map/', include('apps.map.urls')), # 添加地图模块路由
#文件管理
path('api/fileMedia/', RYFileMediaView.as_view(), name='file_media'),
@ -191,5 +192,7 @@ urlpatterns = [
path('downloadapp/',downloadapp ,name='前端APP下载页'),
path('favicon.ico',RedirectView.as_view(url=r'static/favicon.ico')),
path('', TemplateView.as_view(template_name="index.html"),name='后台管理默认页面'),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.1.8 on 2025-04-17 21:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Place',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='地点名称')),
('lng', models.FloatField(verbose_name='经度')),
('lat', models.FloatField(verbose_name='纬度')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='map.place', verbose_name='父节点')),
],
options={
'verbose_name': '地点标签',
'verbose_name_plural': '地点标签',
},
),
]

View File

View File

@ -0,0 +1,14 @@
from django.db import models
class Place(models.Model):
name = models.CharField(max_length=100, verbose_name="地点名称")
lng = models.FloatField(verbose_name="经度")
lat = models.FloatField(verbose_name="纬度")
parent = models.ForeignKey('self', null=True, blank=True, related_name='children', on_delete=models.CASCADE, verbose_name="父节点")
class Meta:
verbose_name = "地点标签"
verbose_name_plural = verbose_name
def __str__(self):
return self.name

View File

@ -0,0 +1,13 @@
from rest_framework import serializers
from .models import Place
class PlaceSerializer(serializers.ModelSerializer):
children = serializers.SerializerMethodField()
class Meta:
model = Place
fields = ['id', 'name', 'lng', 'lat', 'parent', 'children']
def get_children(self, obj):
queryset = obj.children.all()
return PlaceSerializer(queryset, many=True).data

15
backend/apps/map/urls.py Normal file
View File

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""
@Remark: 地点标签模块的路由文件
"""
from django.urls import path, re_path
from rest_framework import routers
from .views import PlaceViewSet
map_url = routers.SimpleRouter()
map_url.register(r'places', PlaceViewSet)
urlpatterns = []
urlpatterns += map_url.urls

29
backend/apps/map/views.py Normal file
View File

@ -0,0 +1,29 @@
from rest_framework import viewsets
from .models import Place
from .serializers import PlaceSerializer
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
class PlaceViewSet(viewsets.ModelViewSet):
"""
地点标签管理接口
"""
queryset = Place.objects.filter(parent=None) # 只获取顶级节点
serializer_class = PlaceSerializer
@swagger_auto_schema(
operation_summary='获取地点标签树',
operation_description='返回所有地点标签的树形结构数据',
responses={200: PlaceSerializer(many=True)}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(
operation_summary='创建地点标签',
operation_description='创建新的地点标签',
request_body=PlaceSerializer,
responses={201: PlaceSerializer()}
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)

View File

@ -380,7 +380,7 @@
transform: translateX(-0.5%);
}
.lyadmin-main-content{
padding: 10px !important;
padding: 1px !important;
}
.lyfadein-leave-from {
transform: translateX(0);

View File

@ -0,0 +1,298 @@
<template>
<div>
<div class="place-tag-tree">
<el-tree
:data="treeData"
:props="defaultProps"
node-key="id"
default-expand-all
highlight-current
@node-click="handleNodeClick"
/>
</div>
<div class="add-place-panel">
<el-input v-model="newPlaceName" placeholder="新地点名称" size="small" />
<el-button
type="primary"
size="small"
@click="startAddPlace"
:type="isAdding ? 'success' : 'primary'"
>
{{ isAdding ? '点击地图选点' : '添加地点' }}
</el-button>
<el-button
v-if="tempMarker"
type="primary"
size="small"
@click="savePlace"
>
保存
</el-button>
<!-- 添加刷新按钮 -->
<el-button
type="info"
size="small"
@click="refreshMap"
>
刷新地图
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const treeData = ref([])
const newPlaceName = ref('')
const isAdding = ref(false)
const tempMarker = ref(null)
const tempLngLat = ref(null)
const defaultProps = {
children: 'children',
label: 'name'
}
//
const props = defineProps({
map: {
type: Object,
required: true
}
})
const showPlacesOnMap = () => {
//
props.map.clearOverLays()
//
treeData.value.forEach(place => {
const lnglat = new T.LngLat(place.lng, place.lat)
const marker = new T.Marker(lnglat)
props.map.addOverLay(marker)
const label = new T.Label({
text: place.name,
position: lnglat,
offset: new T.Point(-37, -45),
style: {
color: "#333",
fontSize: "14px",
height: "28px",
lineHeight: "28px",
padding: "0 12px",
minWidth: "100px",
maxWidth: "200px", //
textAlign: "center",
border: "none",
borderRadius: "999px", // 使
MozBorderRadius: "999px", //
WebkitBorderRadius: "999px",
backgroundColor: "rgba(255, 255, 255, 0.85)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
boxShadow: "0 2px 6px rgba(0, 0, 0, 0.1)",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
textOverflow: "ellipsis" //
}
})
props.map.addOverLay(label)
//
marker.addEventListener('click', () => {
const infoWin = new T.InfoWindow(place.name)
marker.openInfoWindow(infoWin)
})
})
}
const fetchPlaces = async () => {
try {
const res = await axios.get('/api/map/places/', {
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
console.log('获取地点响应:', res.data) // 便
if (res.data && res.data.data) {
//
const placesData = Array.isArray(res.data.data) ? res.data.data :
(res.data.data.data || [])
treeData.value = placesData
showPlacesOnMap()
} else {
throw new Error(res.data?.msg || '数据格式错误')
}
} catch (e) {
console.error('获取地点标签详细错误:', e)
if (e.response) {
ElMessage.error(`获取地点标签失败: ${e.response.data?.msg || e.response.statusText}`)
} else if (e.request) {
ElMessage.error('网络请求失败,请检查网络连接')
} else {
ElMessage.error(e.message || '获取地点标签失败')
}
treeData.value = []
}
}
//
const startAddPlace = () => {
if (!newPlaceName.value) {
ElMessage.warning('请先输入地点名称')
return
}
isAdding.value = true
//
props.map.addEventListener('click', handleMapClick)
}
const handleMapClick = (e) => {
if (!isAdding.value) return
//
if (tempMarker.value) {
props.map.removeOverLay(tempMarker.value)
}
//
tempLngLat.value = e.lnglat
tempMarker.value = new T.Marker(e.lnglat)
props.map.addOverLay(tempMarker.value)
}
const savePlace = async () => {
if (!tempLngLat.value || !newPlaceName.value) return
try {
const existingPlace = treeData.value.find(item => item.name === newPlaceName.value)
// URL
const method = existingPlace ? 'put' : 'post'
const url = existingPlace ? `/api/map/places/${existingPlace.id}/` : '/api/map/places/'
const res = await axios({
method,
url,
data: {
name: newPlaceName.value,
lng: tempLngLat.value.lng,
lat: tempLngLat.value.lat
},
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
timeout: 10000
})
if (res.data && res.data.id) {
if (existingPlace) {
//
const index = treeData.value.findIndex(item => item.id === existingPlace.id)
treeData.value[index] = res.data
ElMessage.success('地点已更新')
} else {
//
treeData.value.push(res.data)
ElMessage.success('添加成功')
}
showPlacesOnMap()
newPlaceName.value = ''
//
props.map.removeEventListener('click', handleMapClick)
if (tempMarker.value) {
props.map.removeOverLay(tempMarker.value)
}
isAdding.value = false
tempMarker.value = null
tempLngLat.value = null
} else {
throw new Error('保存失败:返回数据格式错误')
}
} catch (e) {
console.error('添加失败:', e)
if (e.response) {
if (e.response.status === 500) {
ElMessage.error('服务器内部错误,请稍后再试')
} else {
ElMessage.error(`添加失败: ${e.response.data?.msg || e.response.statusText}`)
}
} else if (e.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请检查网络连接')
} else {
ElMessage.error(e.message || '未知错误')
}
}
}
const handleNodeClick = (node) => {
ElMessage.info(`选中:${node.name}`)
}
onMounted(fetchPlaces)
//
const refreshMap = async () => {
try {
await fetchPlaces()
ElMessage.success('地图已刷新')
} catch (e) {
ElMessage.error('刷新失败')
}
}
//
defineExpose({
refreshMap
})
</script>
<style scoped>
.place-tag-tree {
width: 300px;
padding: 10px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: absolute;
right: 10px;
top: 10px;
}
.add-place-panel {
position: absolute;
right: 10px;
bottom: 20px;
background: #fff;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
display: flex;
gap: 8px;
z-index: 1000;
}
/* 优化输入框和按钮的样式 */
:deep(.el-input__wrapper) {
width: 150px;
}
.el-button {
white-space: nowrap;
}
</style>

View File

@ -25,26 +25,27 @@
<el-dropdown-menu>
<el-dropdown-item @click="startMeasure">开始测距</el-dropdown-item>
<el-dropdown-item @click="clearMeasure">清除测距</el-dropdown-item>
<el-dropdown-item @click="refreshMap" divided>刷新地图</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div id="map" style="width: 100%; height: 600px;"></div>
<div id="map"></div>
</div>
<!-- 添加 v-if 条件 -->
<PlaceTagTree class="place-tag-tree-panel" :map="map" v-if="map" />
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Close } from '@element-plus/icons-vue' //
import { ArrowDown } from '@element-plus/icons-vue' //
import { Close, ArrowDown } from '@element-plus/icons-vue'
import PlaceTagTree from './PlaceTagTree.vue'
import axios from 'axios' // axios
export default {
components: {
Close,
ArrowDown //
},
components: { Close, ArrowDown, PlaceTagTree },
setup() {
const searchText = ref('白云路')
const map = ref(null)
@ -93,8 +94,6 @@ export default {
const initMap = () => {
map.value = new T.Map('map')
map.value.centerAndZoom(new T.LngLat(108.320004, 22.82402), 12)
// map.value.addControl(new T.Control.Zoom())
// map.value.addControl(new T.Control.Scale())
map.value.addControl(new T.Control.MapType())
}
@ -106,28 +105,144 @@ export default {
}
})
// setupfetchPlaces
const fetchPlaces = async () => {
try {
const res = await axios.get('/api/map/places/', {
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
if (res.data && res.data.data) {
const placesData = Array.isArray(res.data.data) ? res.data.data :
(res.data.data.data || [])
//
showPlacesOnMap(placesData)
ElMessage.success('地点数据已刷新')
} else {
throw new Error(res.data?.msg || '数据格式错误')
}
} catch (e) {
console.error('获取地点标签详细错误:', e)
ElMessage.error('获取地点标签失败')
}
}
// clearSearch
const clearSearch = () => {
try {
console.log('开始清除搜索')
poiList.value = []
map.value.clearOverLays()
console.log('地图标记已清除')
// fetchPlaces
fetchPlaces()
} catch (e) {
console.error('清除搜索时出错:', e)
ElMessage.error('清除搜索失败')
}
}
const isMeasuring = ref(false)
const lineTool = ref(null)
const startMeasure = () => {
if (lineTool.value) {
lineTool.value.clear()
}
try {
lineTool.value = new T.PolylineTool(map.value, {
showLabel: true,
color: "#FF0000", //
color: "#FF0000",
weight: 3,
opacity: 1
});
lineTool.value.open();
opacity: 1,
enableDrawingTool: true, //
enableEditing: false //
})
//
lineTool.value.addEventListener("draw", function(e) {
//
console.log("测距完成", e)
})
lineTool.value.open()
ElMessage.success('测距工具已开启')
} catch (e) {
console.error('测距工具初始化失败:', e)
ElMessage.error('测距工具启动失败')
}
}
const clearMeasure = () => {
try {
if (lineTool.value) {
lineTool.value.clear(); // 线
lineTool.value.clear() // 线
lineTool.value = null //
ElMessage.success('测距已清除')
}
} catch (e) {
console.error('清除测距失败:', e)
ElMessage.error('清除测距失败')
}
}
//
const refreshMap = () => {
if (map.value) {
//
const placeTagTree = document.querySelector('.place-tag-tree-panel').__vueParentComponent.ctx
placeTagTree.refreshMap()
}
}
const showPlacesOnMap = (places) => {
//
map.value.clearOverLays()
//
places.forEach(place => {
const lnglat = new T.LngLat(place.lng, place.lat)
const marker = new T.Marker(lnglat)
map.value.addOverLay(marker)
const label = new T.Label({
text: place.name,
position: lnglat,
offset: new T.Point(-37, -45),
style: {
color: "#333",
fontSize: "14px",
height: "28px",
lineHeight: "28px",
padding: "0 12px",
minWidth: "100px",
maxWidth: "200px",
textAlign: "center",
border: "none",
borderRadius: "999px",
backgroundColor: "rgba(255, 255, 255, 0.85)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
boxShadow: "0 2px 6px rgba(0, 0, 0, 0.1)",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden"
}
})
map.value.addOverLay(label)
marker.addEventListener('click', () => {
const infoWin = new T.InfoWindow(place.name)
marker.openInfoWindow(infoWin)
})
})
}
return {
@ -136,8 +251,11 @@ export default {
poiList,
handlePoiClick,
clearSearch,
startMeasure, //
clearMeasure //
startMeasure,
clearMeasure,
refreshMap,
map,
showPlacesOnMap // 使
}
}
}
@ -146,8 +264,28 @@ export default {
<style scoped>
.map-container {
position: relative;
width: 100%; /* 改为100% */
height: 100%; /* 改为100% */
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-content {
width: 100%;
height: 600px;
height: 100%;
position: relative;
overflow: hidden;
flex: 1;
}
#map {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
overflow: hidden;
}
.poi-list {
@ -247,4 +385,14 @@ export default {
.search-box {
width: 400px; /* 调整宽度 */
}
/* 覆盖 el-main 的 padding */
:deep(.el-main) {
padding: 0 !important;
}
/* 覆盖 lyadmin-main-content 的 padding */
:deep(.lyadmin-main-content) {
padding: 0 !important;
}
</style>