Browse Source

1.修改城市选择组件,支持自定义数据
2.增加高德地图点位选择、轨迹播放

XueNing 5 months ago
parent
commit
bfef6136ad

+ 5 - 0
index.html

@@ -6,8 +6,13 @@
     <link rel="icon" href="/logo.png" />
     <!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" /> -->
     <title></title>
+    <!-- TODO:地图组件完成以后删除掉 -->
     <script src="//api.map.baidu.com/api?v=3.0&ak=1V4mSproiau7AxsArSNKBWqR0ZiyMKNh"></script>
     <script type="text/javascript" src="//api.map.baidu.com/library/LuShu/1.2/src/LuShu_min.js"></script>
+
+    <script type="text/javascript">
+      window._AMapSecurityConfig = { securityJsCode: 'b03b80f96a4b9f94ed2cfc750e93ff75' }
+    </script>
   </head>
   <body>
     <div id="app"></div>

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "micro": "plop micro"
   },
   "dependencies": {
+    "@amap/amap-jsapi-loader": "^1.0.1",
     "@element-plus/icons-vue": "^2.3.1",
     "@fskj-admin/core": "^1.2.14",
     "@fskj-admin/micro": "^0.1.0",

+ 8 - 0
pnpm-lock.yaml

@@ -8,6 +8,9 @@ importers:
 
   .:
     dependencies:
+      '@amap/amap-jsapi-loader':
+        specifier: ^1.0.1
+        version: 1.0.1
       '@element-plus/icons-vue':
         specifier: ^2.3.1
         version: 2.3.1(vue@3.4.29(typescript@5.3.3))
@@ -162,6 +165,9 @@ importers:
 
 packages:
 
+  '@amap/amap-jsapi-loader@1.0.1':
+    resolution: {integrity: sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==}
+
   '@ampproject/remapping@2.3.0':
     resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
     engines: {node: '>=6.0.0'}
@@ -4223,6 +4229,8 @@ packages:
 
 snapshots:
 
+  '@amap/amap-jsapi-loader@1.0.1': {}
+
   '@ampproject/remapping@2.3.0':
     dependencies:
       '@jridgewell/gen-mapping': 0.3.5

+ 4 - 0
src/components.d.ts

@@ -7,6 +7,8 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
+    Amap: typeof import('./components/FsMapPicker/components/amap.vue')['default']
+    Baidu: typeof import('./components/FsMapPicker/components/baidu.vue')['default']
     CardItem: typeof import('./components/FsCheckCard/components/CardItem.vue')['default']
     Cropper: typeof import('./components/avatar/cropper.vue')['default']
     ElArea: typeof import('./components/form/ElArea.vue')['default']
@@ -19,6 +21,8 @@ declare module 'vue' {
     FsCitySelect: typeof import('./components/FsCitySelect/index.vue')['default']
     FsDot: typeof import('./components/FsDot/index.vue')['default']
     FsImageUpload: typeof import('./components/FsImageUpload/index.vue')['default']
+    FsMap: typeof import('./components/FsMap/index.vue')['default']
+    FsMapPicker: typeof import('./components/FsMapPicker/index.vue')['default']
     FsPrinter: typeof import('./components/FsPrinter/index.vue')['default']
     FsSplitPanel: typeof import('./components/FsSplitPanel/index.vue')['default']
     FsTableSelect: typeof import('./components/FsTableSelect/index.vue')['default']

+ 32 - 23
src/components/FsCitySelect/index.vue

@@ -18,15 +18,15 @@ const cascaderValue = ref<any>()
 
 const options = ref<any>([])
 
-getAreaList().then((res: any) => {
-  options.value = res
-})
+/* 自定义配置 */
+const setting = {
+  value: 'id'
+}
 
 /* 配置 */
-const cascaderProps = {
-  value: 'id',
-  multiple: props.multiple
-}
+const cascaderProps = computed(() => {
+  return Object.assign(setting, props.props)
+})
 
 /* 级联数据 */
 const cascaderData = computed(() => {
@@ -58,9 +58,27 @@ const cascaderData = computed(() => {
   }
 })
 
+/* 获取数据 */
+const getData = () => {
+  if (props.datasource && typeof props.datasource === 'function') {
+    props.datasource().then((res: any) => {
+      options.value = res
+    })
+  } else {
+    getAreaList().then((res: any) => {
+      options.value = res
+    })
+  }
+}
+getData()
+
 /* 修改modelValue */
 const updateModelValue = (modelValue: any) => {
-  const value = Array.isArray(modelValue) ? modelValue.join(',') : ''
+  let value = ''
+  if (Array.isArray(modelValue)) {
+    // 多选情况利用set去重
+    value = [...new Set([...modelValue])].join(',')
+  }
   emits('update:modelValue', value)
 }
 
@@ -86,20 +104,11 @@ watch(
 </script>
 
 <template>
-  <el-cascader
-    ref="cascaderRef"
-    v-model="cascaderValue"
-    :options="cascaderData"
-    :props="cascaderProps"
-    :size="size"
-    :placeholder="placeholder"
-    :disabled="disabled"
-    :clearable="clearable"
-    :filterable="filterable"
-    :collapse-tags="collapseTags"
-    :max-collapse-tags="maxCollapseTags"
-    :collapse-tags-tooltip="collapseTagsTooltip"
-  />
+  <el-cascader ref="cascaderRef" v-model="cascaderValue" :options="cascaderData" :props="cascaderProps" />
 </template>
 
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+:deep(.el-cascader__dropdown) {
+  min-width: 200px;
+}
+</style>

+ 5 - 22
src/components/FsCitySelect/props.ts

@@ -1,30 +1,13 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
-import type { InputProps } from 'element-plus'
-
+import type { CascaderProps } from 'element-plus'
 export interface citySelectProps {
   // 选中数据
   modelValue: string
-  // 选择类型
+  // 地区类型
   type?: 'province' | 'city' | 'area'
-  // placeholder文字
-  placeholder?: string
-  // 是否禁用
-  disabled?: boolean
-  // 大小
-  size?: InputProps['size']
-  // 清除按钮
-  clearable?: boolean
-  // 是否搜索
-  filterable?: boolean
-  // 是否多选
-  multiple?: boolean
-  // 多选模式下是否折叠Tag
-  collapseTags?: boolean
-  // 显示最大tag数量
-  maxCollapseTags?: number
-  // 用鼠标悬停折叠文字以显示具体所选值
-  collapseTagsTooltip?: boolean
-  teleported?: boolean
+  props?: CascaderProps
+  // 自定义数据源
+  datasource?: () => Promise<Array<any>>
 }
 
 /* 事件 */

+ 12 - 32
src/components/FsDot/index.vue

@@ -1,29 +1,24 @@
 <script setup lang="ts">
 import type { TextProps } from './props'
 
-withDefaults(defineProps<TextProps>(), {
+const props = withDefaults(defineProps<TextProps>(), {
   type: 'primary',
   ripple: true,
   size: '8px'
 })
+
+const bgColor = computed(() => {
+  return props.color || `var(--el-color-${props.type})`
+})
 </script>
 
 <!-- 状态点 -->
 <template>
-  <span
-    :class="[
-      'dot',
-      { 'is-success': 'success' === type },
-      { 'is-warning': 'warning' === type },
-      { 'is-danger': 'danger' === type },
-      { 'is-info': 'info' === type },
-      { 'is-ripple': ripple }
-    ]"
-  >
-    <span class="dot-status" :style="{ width: size, height: size, background: color }">
-      <span class="dot-ripple" :style="{ width: size, height: size, background: color }"></span>
+  <span :class="['dot', { 'is-ripple': ripple }]">
+    <span class="dot-status" :style="{ width: size, height: size }">
+      <span class="dot-ripple" :style="{ width: size, height: size }"></span>
     </span>
-    <span v-if="text" class="dot-text">{{ text }}</span>
+    <span v-if="text" class="dot-text" :style="fontStyle"> {{ text }}</span>
   </span>
 </template>
 
@@ -61,24 +56,9 @@ withDefaults(defineProps<TextProps>(), {
     display: block;
   }
 
-  &.is-success .dot-status,
-  &.is-success .dot-ripple {
-    background: var(--el-color-success);
-  }
-
-  &.is-warning .dot-status,
-  &.is-warning .dot-ripple {
-    background: var(--el-color-warning);
-  }
-
-  &.is-danger .dot-status,
-  &.is-danger .dot-ripple {
-    background: var(--el-color-danger);
-  }
-
-  &.is-info .dot-status,
-  &.is-info .dot-ripple {
-    background: var(--el-color-info);
+  .dot-status,
+  .dot-ripple {
+    background: v-bind('bgColor');
   }
 }
 

+ 4 - 0
src/components/FsDot/props.ts

@@ -1,3 +1,5 @@
+import type { StyleValue } from 'vue'
+
 export interface TextProps {
   //  类型
   type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
@@ -9,4 +11,6 @@ export interface TextProps {
   text?: string
   // 尺寸
   size?: string
+  // 文字样式
+  fontStyle?: StyleValue
 }

+ 3 - 2
src/components/FsImageUpload/index.vue

@@ -78,7 +78,7 @@ const uploadItem = (item: UploadItem) => {
     console.log('请传入action路径或者uploadFunction上传方法')
     return false
   }
-  // 如果配置了上传路径
+  // 如果配置了上传路径,TODO:awint
   if (props.action) {
     const formData = new FormData()
     formData.append(props.fileName, item.file as File)
@@ -132,7 +132,8 @@ const updateModelValue = (items: any) => {
 watch(
   () => props.modelValue,
   () => {
-    if (typeof props.modelValue === 'string' && props.modelValue.startsWith('http')) {
+    // 判断modelValue存不存在,存在就是成功,不存在就没有
+    if (props.modelValue && props.modelValue != undefined) {
       // 传进来的数据转换成内部需要数据
       const formatData = typeof props.modelValue === 'string' ? props.modelValue.split(',').filter(x => x) : []
       const datas: UploadItem[] = formatData.map((x: string) => {

+ 300 - 0
src/components/FsMapPicker/components/amap.vue

@@ -0,0 +1,300 @@
+<script setup lang="ts">
+import AMapLoader from '@amap/amap-jsapi-loader'
+import { CircleCheck, Location, Plus, Search } from '@element-plus/icons-vue'
+import { ElMessage, type AutocompleteInstance } from 'element-plus'
+
+const ICON_CLASS = 'map-main-marker'
+
+const emits = defineEmits(['done'])
+
+const props = defineProps([
+  'modelValue',
+  'height',
+  'center',
+  'mapKey',
+  'markerSrc',
+  'zoom',
+  'selectedZoom',
+  'mapStyle',
+  'poiType',
+  'poiRadius',
+  'poiKeywords',
+  'okText',
+  'required',
+  'message',
+  'suggestionCity'
+])
+
+let mapIns: any = null
+let placeSearchIns: any = null
+let autoCompleteIns: any = null
+// 地图容器
+const mapRef = ref<any>()
+const autocompleteRef = ref<AutocompleteInstance>()
+
+// 是否是poi列表点击移动
+const isItemClickMove = ref<boolean>(false)
+// 图标样式
+const centerIconClass = ref<Array<string>>([ICON_CLASS])
+
+// 当前选中
+const current = ref<any>(null)
+
+// 是否加载中
+const loading = ref<boolean>(false)
+
+const poiData = ref<any[]>([])
+
+// 关键字
+const keywords = ref<string>('')
+// 最后一次搜索关键字
+const lastKeywords = ref<any>(null)
+
+// 关键字搜索数据
+const suggestionData = ref<any[]>([])
+
+onMounted(() => {
+  initMap()
+})
+/* 初始化地图 */
+const initMap = () => {
+  AMapLoader.load({
+    key: props.mapKey,
+    version: '2.0',
+    plugins: ['AMap.PlaceSearch', 'AMap.AutoComplete']
+  }).then((AMap: any) => {
+    // 获取POI检索实例
+    placeSearchIns = new AMap.PlaceSearch({
+      type: props.poiType,
+      pageSize: 20,
+      pageIndex: 1
+    })
+    // 获取输入建议实例
+    autoCompleteIns = new AMap.AutoComplete({
+      city: props.suggestionCity
+    })
+
+    // const selected = getInitSelected()
+    mapIns = new AMap.Map(mapRef.value, {
+      zoom: props.zoom, // 初缩放级别
+      center: props.center, // 初始中心点
+      resizeEnable: true, // 监控地图容器尺寸变化
+      mapStyle: props.mapStyle // 设置地图样式
+    })
+
+    // 地图移动结束事件
+    mapIns.on('moveend', () => {
+      if (isItemClickMove.value) {
+        isItemClickMove.value = false
+        return
+      }
+      bounceIcon()
+      const { lng, lat } = mapIns.getCenter()
+      searchPOI(lng, lat)
+    })
+    // 第一次加载
+    searchPOI(props.center[0], props.center[1])
+  })
+}
+
+/* poi 搜索 */
+const searchPOI = (lng: number, lat: number) => {
+  loading.value = true
+  current.value = null
+  searchNearBy(lng, lat)
+    .then(res => {
+      loading.value = false
+      poiData.value = res
+    })
+    .catch(() => {
+      loading.value = false
+    })
+}
+
+/* 设置地图中心点 */
+const setMapCenter = (lng: number, lat: number, zoom: number) => {
+  if (lng && lat) {
+    if (zoom == null) {
+      mapIns.setCenter([lng, lat])
+    } else {
+      mapIns.setZoomAndCenter(zoom, [lng, lat])
+    }
+  }
+}
+
+/* poi 点击 */
+const handleItemClick = (item: any) => {
+  const { lng, lat } = item
+  setMapCenter(lng, lat, props.selectedZoom)
+  isItemClickMove.value = true
+  current.value = item
+}
+
+/* 让地图图标跳动 */
+const bounceIcon = () => {
+  nextTick(() => {
+    centerIconClass.value = [ICON_CLASS]
+    setTimeout(() => {
+      centerIconClass.value = [ICON_CLASS, 'map-main-marker-bounce']
+    }, 0)
+  })
+}
+
+/* 确认选择 */
+const handleConfirm = () => {
+  // 是否强制选择
+  if (props.required && !current.value) {
+    ElMessage.error(props.message)
+    return
+  }
+  // 如果右侧不选择,则返回当前中心点
+  const { lng, lat } = mapIns.getCenter()
+  emits('done', current.value || { lng, lat })
+}
+
+/* 关键字搜索 */
+const handleSelect = (keyword: string, callback?: any) => {
+  if (!keyword || lastKeywords.value === keyword) {
+    callback && callback(suggestionData.value)
+    return
+  }
+  lastKeywords.value = keyword
+  searchKeywords(keyword)
+    .then(result => {
+      suggestionData.value = result
+      callback && callback(suggestionData.value)
+    })
+    .catch((e: Error) => {
+      console.error(e)
+      callback && callback(suggestionData.value)
+    })
+}
+
+/* 关键字搜索选中 */
+const handleSelectSelect = (item: any) => {
+  autocompleteRef.value && autocompleteRef.value.blur()
+  const { lng, lat } = item
+  setMapCenter(lng, lat, props.selectedZoom)
+  bounceIcon()
+  // searchPOI(lng, lat)
+}
+
+/* 检索附近兴趣点 */
+const searchNearBy = (lng: number, lat: number): Promise<any[]> => {
+  return new Promise((resolve, reject) => {
+    if (!placeSearchIns) {
+      reject(new Error('搜索组件加载失败,请重试'))
+      return
+    }
+    placeSearchIns.searchNearBy(props.poiKeywords, [lng, lat], props.poiRadius, (status: string, result: any) => {
+      if (status === 'complete' && result?.poiList?.pois) {
+        const ps = result.poiList.pois.filter((d: any) => !!d.location)
+        resolve((ps as any[]).map((d: any) => formatPoi(d)))
+        return
+      }
+      if (status === 'no_data') {
+        resolve([])
+        return
+      }
+      const msg = status + ' ' + (result ? JSON.stringify(result) : '')
+      reject(new Error(msg))
+    })
+  })
+}
+
+// 关键字检索
+const searchKeywords = (keyword: string): Promise<any[]> => {
+  return new Promise((resolve, reject) => {
+    if (!autoCompleteIns) {
+      reject(new Error('AutoComplete instance is null'))
+      return
+    }
+    autoCompleteIns.search(keyword, (status: any, result: any) => {
+      if (status === 'error') {
+        const msg = status + ' ' + (result ? JSON.stringify(result) : '')
+        reject(new Error(msg))
+        return
+      }
+      if (!result?.tips) {
+        resolve([])
+        return
+      }
+      const tips = (result.tips as any[]).filter(d => !!d.location)
+      resolve(tips.map(d => formatPoi(d)))
+    })
+  })
+}
+
+/* 格式化返回的点位 */
+const formatPoi = (item: any) => {
+  return {
+    ...item,
+    lng: item.location.lng,
+    lat: item.location.lat
+  }
+}
+
+onBeforeUnmount(() => {
+  /* 销毁地图 */
+  mapIns && mapIns.destroy()
+  mapIns = null
+  placeSearchIns = null
+  autoCompleteIns = null
+  current.value = null
+  loading.value = false
+  poiData.value = []
+})
+</script>
+
+<template>
+  <div class="map-container">
+    <div class="map-main">
+      <div ref="mapRef" :style="{ height }"></div>
+      <el-icon class="map-icon-plus" color="var(--el-color-primary)">
+        <Plus />
+      </el-icon>
+      <img :src="markerSrc" :class="centerIconClass" />
+      <el-autocomplete
+        v-model="keywords"
+        valueKey="name"
+        :clearable="true"
+        :fetch-suggestions="handleSelect"
+        placeholder="请输入关键字进行搜索"
+        @select="handleSelectSelect"
+        class="map-keywrod-input"
+        ref="autocompleteRef"
+      >
+        <template #prefix>
+          <el-icon class="el-input__icon">
+            <Search />
+          </el-icon>
+        </template>
+      </el-autocomplete>
+    </div>
+    <div class="map-poi-side">
+      <div class="map-poi-list" v-loading="loading">
+        <div class="map-poi-item" v-for="item in poiData" :key="item.id" @click="handleItemClick(item)">
+          <div class="map-poi-icon">
+            <el-icon size="18">
+              <Location />
+            </el-icon>
+          </div>
+          <div class="map-poi-desc">
+            <div class="map-poi-title">{{ item.name }}</div>
+            <div class="map-poi-address">{{ item.address }}</div>
+          </div>
+          <div class="map-poi-icon map-poi-icon-checked">
+            <el-icon size="18" color="var(--el-color-primary)" v-if="current === item">
+              <CircleCheck />
+            </el-icon>
+          </div>
+        </div>
+      </div>
+      <div class="map-poi-button">
+        <el-button type="primary" @click="handleConfirm" style="width: 100%">{{ okText }}</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 13 - 0
src/components/FsMapPicker/components/baidu.vue

@@ -0,0 +1,13 @@
+<script setup lang="ts">
+onMounted(() => {
+  initMap()
+})
+/* 初始化地图 */
+const initMap = () => {}
+</script>
+
+<template>
+  <div></div>
+</template>
+
+<style scoped lang="scss"></style>

+ 146 - 0
src/components/FsMapPicker/index.vue

@@ -0,0 +1,146 @@
+<script setup lang="ts">
+import type { MapPickerProps } from './props'
+import { mapPickerEmits } from './props'
+import Amap from './components/amap.vue'
+import BaiDu from './components/baidu.vue'
+
+const emits = defineEmits(mapPickerEmits)
+
+const props = withDefaults(defineProps<MapPickerProps>(), {
+  type: 'amap',
+  height: '460px',
+  markerSrc: 'https://3gimg.qq.com/lightmap/components/locationPicker2/image/marker.png',
+  mapStyle: 'amap://styles/normal',
+  suggestionCity: '太原市',
+  search: true,
+  poi: true,
+  poiRadius: 1000,
+  center: [112.569129, 37.863392],
+  zoom: 11,
+  selectedZoom: 17,
+  message: '请选择位置',
+  okText: '确定'
+})
+
+/* 处理选择完成事件 */
+const handleDone = (data: any) => {
+  emits('done', data)
+  updateModalValue(false)
+}
+
+const updateModalValue = (modelValue: boolean) => {
+  emits('update:modelValue', modelValue)
+}
+</script>
+
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    width="800px"
+    title="位置选择"
+    class="map-picker-dialog"
+    @update:modelValue="updateModalValue"
+  >
+    <template v-if="modelValue">
+      <!-- 高德地图 -->
+      <Amap v-if="type === 'amap'" v-bind="props" :style="{ height }" @done="handleDone" />
+      <!-- 百度地图 -->
+      <BaiDu v-else />
+    </template>
+  </el-dialog>
+</template>
+
+<style lang="scss">
+.map-picker-dialog {
+  padding: 0px;
+  .el-dialog__header {
+    padding: var(--el-dialog-padding-primary);
+  }
+  .el-dialog__body {
+    padding: 0px !important;
+    .map-container {
+      width: 100%;
+      display: flex;
+      .map-main {
+        position: relative;
+        flex: 1;
+      }
+      .map-keywrod-input {
+        position: absolute;
+        left: 8px;
+        top: 8px;
+        width: 200px;
+      }
+      .map-poi-side {
+        width: 260px;
+        height: 100%;
+        display: flex;
+        flex-direction: column;
+        .map-poi-list {
+          flex: 1;
+          overflow-y: auto;
+          box-sizing: border-box;
+          .map-poi-item {
+            display: flex;
+            align-items: center;
+            border-bottom: 1px solid var(--el-border-color);
+            padding: 5px 0px;
+            cursor: pointer;
+            .map-poi-desc {
+              flex: 1;
+            }
+            .map-poi-icon {
+              padding: 0px 5px;
+            }
+            .map-poi-icon-checked {
+              width: 30px;
+              padding-left: 2px;
+            }
+            .map-poi-title {
+              color: #333;
+              font-size: 14px;
+            }
+            .map-poi-address {
+              font-size: 13px;
+            }
+          }
+        }
+        .map-poi-button {
+          height: 40px;
+          padding: 5px;
+          box-sizing: border-box;
+        }
+      }
+      .map-icon-plus,
+      .map-main-marker {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        z-index: 9;
+      }
+      .map-icon-plus {
+        transform: translate(-50%, -50%);
+      }
+      .map-main-marker {
+        width: 26px;
+        margin-top: -35px;
+        margin-left: -13px;
+      }
+      /* 地图图标跳动动画 */
+      .map-main-marker-bounce {
+        animation: mapAnim 0.6s;
+      }
+      @keyframes mapAnim {
+        from,
+        to {
+          transform: translateY(0);
+        }
+
+        50% {
+          transform: translateY(-20px);
+        }
+      }
+    }
+  }
+}
+</style>

+ 42 - 0
src/components/FsMapPicker/props.ts

@@ -0,0 +1,42 @@
+export interface MapPickerProps {
+  // 是否显示
+  modelValue: boolean
+  // 地图类型
+  type?: 'baidu' | 'amap'
+  // 地图key
+  mapKey?: string
+  // 图标样式
+  markerSrc?: string
+  // 地图样式
+  mapStyle?: string
+  // 高度
+  height?: string
+  // 中心点
+  center?: Array<number | string>
+  // 缩放级别
+  zoom?: number
+  // 选中位置级别
+  selectedZoom?: number
+  // 是否强制选择
+  required?: boolean
+  // 强制选择提示文本
+  message?: string
+  // 确定按钮文字
+  okText?: string
+  // POI检索关键字
+  poiKeywords?: string
+  // POI检索半径
+  poiRadius?: number
+  // POI检索兴趣点类别
+  poiType?: string
+  // 输入建议的城市范围
+  suggestionCity?: string
+}
+
+/* 事件 */
+export const mapPickerEmits = {
+  // 选择事件
+  done: (_value: any) => true,
+  // 修改modelValue
+  'update:modelValue': (_value: any) => true
+}

+ 29 - 0
src/components/FsTour/index.vue

@@ -6,7 +6,10 @@ import type { TourStep } from './types'
 import { tourEmits } from './props'
 import type { TourProps } from './props'
 
+const saveCatchKey = '_FS_TOUR'
+
 const props = withDefaults(defineProps<TourProps>(), {
+  once: false,
   mask: true,
   padding: 6
 })
@@ -47,6 +50,7 @@ const start = () => {
   if (!step.value) {
     return
   }
+
   isLast.value = props.modelValue === props.steps.length - 1
   const { mask, popoverProps, target, padding } = step.value
   showMask.value = mask ?? props.mask
@@ -72,12 +76,37 @@ const start = () => {
       left: '50%'
     }
   }
+
+  if (props.once && props.tourKey && findKey()) {
+    return
+  }
   // 显示
   visible.value = true
   tooltipProps.value = getPopperProps(true, !el, popoverProps as any)
   nextTick(() => {
     updatePopper()
   })
+  // 缓存一个key
+  if (props.once && props.tourKey) {
+    const catchTour = localStorage.getItem(saveCatchKey)
+    if (catchTour) {
+      const arr = JSON.parse(catchTour)
+      arr.push(props.tourKey)
+      localStorage.setItem(saveCatchKey, JSON.stringify(arr))
+    } else {
+      localStorage.setItem(saveCatchKey, JSON.stringify([props.tourKey]))
+    }
+  }
+}
+
+/* 查找缓存的key */
+const findKey = () => {
+  const catchKey = localStorage.getItem(saveCatchKey)
+  if (!catchKey) {
+    return false
+  }
+  const arr = JSON.parse(catchKey)
+  return arr.find((item: string) => item === props.tourKey)
 }
 
 /** 关闭引导 */

+ 9 - 5
src/components/FsTour/props.ts

@@ -2,15 +2,19 @@
 import type { TourStep } from './types'
 
 export interface TourProps {
-  /** 当前处于第几步 */
+  // 是否只显示一次
+  once?: boolean
+  // 唯一标识(显示一次时必传)
+  tourKey?: string
+  // 当前处于第几步
   modelValue?: number
-  /** 步骤 */
+  // 步骤
   steps?: TourStep[]
-  /** 是否开启遮罩层 */
+  // 是否开启遮罩层
   mask?: boolean
-  /** 高亮区内间距 */
+  // 高亮区内间距
   padding?: number
-  /** 层级 */
+  // 层级
   zIndex?: number
 }
 

+ 1 - 1
src/router/asyncRouter.ts

@@ -368,7 +368,7 @@ const asyncRouter: RouteRecordRaw[] = [
         name: 'extensionMap',
         component: () => import('@/views/extension/map/index.vue'),
         meta: {
-          title: '地图',
+          title: '地图选择',
           icon: 'MoreFilled'
         }
       }

+ 31 - 0
src/views/extension/citySelect/base.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+import FsCitySelect from '@/components/FsCitySelect/index.vue'
+
+const city = ref('')
+const city2 = ref('')
+const city3 = ref('')
+</script>
+
+<template>
+  <el-card header="基础使用" shadow="never">
+    <el-row :gutter="0">
+      <el-col :span="8">
+        <el-form-item label="选择区:" prop="">
+          <fs-city-select v-model="city" class="w-200px" tag-type="success" clearable></fs-city-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="8">
+        <el-form-item label="选择市:" prop="">
+          <fs-city-select v-model="city2" class="w-200px" type="city" clearable></fs-city-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="8">
+        <el-form-item label="选择省:" prop="">
+          <fs-city-select v-model="city3" class="w-200px" type="province" clearable></fs-city-select>
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

+ 40 - 0
src/views/extension/citySelect/custom.vue

@@ -0,0 +1,40 @@
+<script setup lang="ts">
+import FsCitySelect from '@/components/FsCitySelect/index.vue'
+import { getAreaList } from '@/api/area'
+
+const city = ref('')
+const city2 = ref('')
+
+const getList = async () => {
+  const result: any = await getAreaList()
+  const list = result.find((item: any) => item.id === '140000').children
+  return Promise.resolve(list)
+}
+
+const getList2 = async () => {
+  const result: any = await getAreaList()
+  const list = result
+    .find((item: any) => item.id === '140000')
+    .children.find((item: any) => item.id === '140100').children
+  return Promise.resolve(list)
+}
+</script>
+
+<template>
+  <el-card header="自定义数据" shadow="never">
+    <el-row :gutter="15">
+      <el-col :span="8">
+        <el-form-item label="山西省:" prop="">
+          <fs-city-select v-model="city" class="w-200px" tag-type="success" :datasource="getList"></fs-city-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="8">
+        <el-form-item label="太原市:" prop="">
+          <fs-city-select v-model="city2" class="w-200px" tag-type="success" :datasource="getList2"></fs-city-select>
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

+ 7 - 13
src/views/extension/citySelect/index.vue

@@ -1,19 +1,13 @@
 <script setup lang="ts">
-import FsCitySelect from '@/components/FsCitySelect/index.vue'
-
-const city = ref('')
+import Base from './base.vue'
+import Multiple from './multiple.vue'
+import Custom from './custom.vue'
 </script>
 
 <template>
-  <el-card header="城市选择" shadow="never">
-    <el-form-item label="选择区:" prop="">
-      <fs-city-select v-model="city" class="w-200px"></fs-city-select>
-    </el-form-item>
-  </el-card>
+  <Base />
+  <Multiple class="mt-3" />
+  <Custom class="mt-3" />
 </template>
 
-<style scoped lang="scss">
-:deep(.el-cascader__dropdown) {
-  min-width: 200px;
-}
-</style>
+<style scoped lang="scss"></style>

+ 50 - 0
src/views/extension/citySelect/multiple.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+import FsCitySelect from '@/components/FsCitySelect/index.vue'
+
+const city = ref('')
+const city2 = ref('')
+const city3 = ref('')
+</script>
+
+<template>
+  <el-card header="多选使用" shadow="never">
+    <el-row :gutter="0">
+      <el-col :span="8">
+        <el-form-item label="选择区:" prop="">
+          <fs-city-select
+            v-model="city"
+            class="w-200px"
+            tag-type="success"
+            collapse-tags
+            :props="{ multiple: true }"
+            clearable
+          ></fs-city-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="8">
+        <el-form-item label="选择市:" prop="">
+          <fs-city-select
+            v-model="city2"
+            class="w-200px"
+            type="city"
+            clearable
+            :props="{ multiple: true }"
+          ></fs-city-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="8">
+        <el-form-item label="选择省:" prop="">
+          <fs-city-select
+            v-model="city3"
+            class="w-200px"
+            type="province"
+            clearable
+            :props="{ multiple: true }"
+          ></fs-city-select>
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

+ 1 - 0
src/views/extension/imageUpload/index.vue

@@ -37,6 +37,7 @@ const submit = () => {
     ElMessage.error('请等待图片上传完成')
     return
   }
+  ElMessage.success('上传完成')
 }
 </script>
 

+ 31 - 0
src/views/extension/map/amap.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+import FsMapPicker from '@/components/FsMapPicker/index.vue'
+
+const showPicker = ref(false)
+
+const mapData = ref({ lng: '', lat: '', name: '', address: '' })
+
+const openPicker = () => {
+  showPicker.value = true
+}
+
+/* 选择完毕 */
+const mapPickerDone = (data: any) => {
+  mapData.value = data
+}
+</script>
+
+<template>
+  <div>
+    <div>
+      <p>经度:{{ mapData.lng }}</p>
+      <p>纬度:{{ mapData.lat }}</p>
+      <p>地址:{{ mapData.name }}</p>
+      <p>详细信息:{{ mapData.address }}</p>
+    </div>
+    <el-button type="primary" @click="openPicker">打开地图选择</el-button>
+    <FsMapPicker v-model="showPicker" map-key="80b0e2d75dc7bb2534e9d6e8ed79ab9e" @done="mapPickerDone" />
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 133 - 0
src/views/extension/map/amapTrack.vue

@@ -0,0 +1,133 @@
+<script lang="ts" setup>
+import AMapLoader from '@amap/amap-jsapi-loader'
+
+/* 地图容器 */
+const trackMapRef = ref<HTMLElement | null>(null)
+
+/* 小车轨迹地图的实例 */
+let mapInsTrack: any
+
+/* 小车的 marker */
+let carMarker: any
+
+/* 轨迹路线 */
+const lineData = [
+  [116.478935, 39.997761],
+  [116.478939, 39.997825],
+  [116.478912, 39.998549],
+  [116.478912, 39.998549],
+  [116.478998, 39.998555],
+  [116.478998, 39.998555],
+  [116.479282, 39.99856],
+  [116.479658, 39.998528],
+  [116.480151, 39.998453],
+  [116.480784, 39.998302],
+  [116.480784, 39.998302],
+  [116.481149, 39.998184],
+  [116.481573, 39.997997],
+  [116.481863, 39.997846],
+  [116.482072, 39.997718],
+  [116.482362, 39.997718],
+  [116.483633, 39.998935],
+  [116.48367, 39.998968],
+  [116.484648, 39.999861]
+]
+
+/* 渲染轨迹回放地图 */
+const renderTrackMap = () => {
+  AMapLoader.load({
+    key: '80b0e2d75dc7bb2534e9d6e8ed79ab9e',
+    version: '2.0',
+    plugins: ['AMap.MoveAnimation', 'AMap.Marker', 'AMap.Polyline']
+  })
+    .then(AMap => {
+      // 渲染地图
+      const option = {
+        zoom: 17,
+        center: [116.478935, 39.997761]
+      }
+      mapInsTrack = new AMap.Map(trackMapRef.value, option)
+      // 创建小车 marker
+      carMarker = new AMap.Marker({
+        map: mapInsTrack,
+        position: [116.478935, 39.997761],
+        icon: 'https://a.amap.com/jsapi_demos/static/demo-center-v2/car.png',
+        offset: new AMap.Pixel(-13, -26)
+      })
+      // 绘制轨迹
+      new AMap.Polyline({
+        map: mapInsTrack,
+        path: lineData,
+        showDir: true,
+        strokeColor: '#2288FF', // 线颜色
+        strokeOpacity: 1, // 线透明度
+        strokeWeight: 6 // 线宽
+        //strokeStyle: 'solid'  // 线样式
+      })
+      // 通过的轨迹
+      const passedPolyline = new AMap.Polyline({
+        map: mapInsTrack,
+        showDir: true,
+        strokeColor: '#44BB55', // 线颜色
+        strokeOpacity: 1, // 线透明度
+        strokeWeight: 6 // 线宽
+      })
+      // 小车移动回调
+      carMarker.on('moving', e => {
+        passedPolyline.setPath(e.passedPath)
+      })
+      // 地图自适应
+      mapInsTrack.setFitView()
+    })
+    .catch(e => {
+      console.error(e)
+    })
+}
+
+/* 开始轨迹回放动画 */
+const startTrackAnim = () => {
+  if (carMarker) {
+    carMarker.stopMove()
+    carMarker.moveAlong(lineData, {
+      duration: 200,
+      autoRotation: true
+    })
+  }
+}
+
+/* 暂停轨迹回放动画 */
+const pauseTrackAnim = () => {
+  if (carMarker) {
+    carMarker.pauseMove()
+  }
+}
+
+/* 继续开始轨迹回放动画 */
+const resumeTrackAnim = () => {
+  if (carMarker) {
+    carMarker.resumeMove()
+  }
+}
+
+/* 渲染地图 */
+onMounted(() => {
+  renderTrackMap()
+})
+
+/* 销毁地图 */
+onBeforeUnmount(() => {
+  if (mapInsTrack) {
+    mapInsTrack.destroy()
+    mapInsTrack = null
+  }
+})
+</script>
+
+<template>
+  <div ref="trackMapRef" style="height: 360px; max-width: 800px; margin-bottom: 16px"></div>
+  <div>
+    <el-button type="primary" @click="startTrackAnim"> 开始移动 </el-button>
+    <el-button type="primary" @click="pauseTrackAnim"> 暂停移动 </el-button>
+    <el-button type="primary" @click="resumeTrackAnim"> 继续移动 </el-button>
+  </div>
+</template>

+ 9 - 5
src/views/extension/map/index.vue

@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import Amap from './amap.vue'
+import AmapTrack from './amapTrack.vue'
 let bmap: any = null
 let lushu: any = null
 
@@ -65,10 +67,6 @@ const initMap = () => {
   }
 
   createTrack()
-
-  bmap.addEventListener('click', function (e: any) {
-    console.log(e)
-  })
 }
 
 /* 开始动画 */
@@ -108,7 +106,13 @@ const createTrack = () => {
 </script>
 
 <template>
-  <el-card header="轨迹回放" shadow="never">
+  <el-card header="高度地图" shadow="never">
+    <Amap />
+  </el-card>
+  <el-card header="高德地图轨迹回放" shadow="never" class="mt-3">
+    <AmapTrack />
+  </el-card>
+  <el-card header="轨迹回放" shadow="never" class="mt-3">
     <el-button type="primary" @click="start">开始动画</el-button>
     <el-button type="primary" @click="pause">暂停动画</el-button>
     <div id="map" class="w-full h-300px mt-3"></div>

+ 5 - 1
src/views/extension/tour/index.vue

@@ -99,7 +99,11 @@ const onStart3 = () => {
       <el-button ref="saveRef1" type="primary">保存</el-button>
       <el-button ref="moreRef1">更多</el-button>
     </div>
-    <fs-tour v-model="current" :steps="steps" />
+    <fs-tour v-model="current" :steps="steps" :once="true" tour-key="tourKey1" />
+    <p class="text-xs mt-2 text-neutral">
+      <span>*注:</span>
+      <span>只显示一次,如想多次显示请去除配置(once)</span>
+    </p>
   </el-card>
 
   <el-card header="不带遮罩层" shadow="never" class="mt-3">