17 Commits 14ce6cd7c9 ... 17de638448

Author SHA1 Message Date
  XueNing 17de638448 合并组件 6 months ago
  XueNing 5f6bf0b94d 去除地图引入文件、npm 包 6 months ago
  XueNing bfef6136ad 1.修改城市选择组件,支持自定义数据 6 months ago
  XueNing 0486962eea 增加城市选择组件 6 months ago
  XueNing 81cf67cc4f 修改上传图片是否存在自定义方法判断 6 months ago
  XueNing a9b5c42f31 1.增加文本组件 6 months ago
  XueNing 431dba17ea 新增文本状态显示组件 6 months ago
  XueNing 7840a5a364 修改fs-table-select 问题 6 months ago
  XueNing b9244f09b4 新增卡片、打印 6 months ago
  XueNing 9229709bc1 新增分割面板、excel导入导出、可选卡片 6 months ago
  XueNing 55b820f6df 新增图片上传组件 6 months ago
  XueNing e50e113f7e 完善演示页面 6 months ago
  XueNing ed6ffb7afb 增加表格下拉自定义搜索 6 months ago
  XueNing efad55c87b 增加tag类型、增加最多显示多少个tag 6 months ago
  XueNing 5ebff362cc 设置分页回显数据显示 6 months ago
  XueNing 56573a4156 1.增加下拉表格多选 6 months ago
  XueNing f9c46f0c17 新增加fs-table-select 组件 6 months ago
62 changed files with 4916 additions and 704 deletions
  1. 3 0
      .eslintrc.cjs
  2. 3 2
      package.json
  3. 262 638
      pnpm-lock.yaml
  4. BIN
      public/car.png
  5. BIN
      public/test.pdf
  6. 14 0
      src/components.d.ts
  7. 83 0
      src/components/FsCheckCard/components/CardItem.vue
  8. 111 0
      src/components/FsCheckCard/index.vue
  9. 26 0
      src/components/FsCheckCard/props.ts
  10. 14 0
      src/components/FsCheckCard/types/index.ts
  11. 114 0
      src/components/FsCitySelect/index.vue
  12. 19 0
      src/components/FsCitySelect/props.ts
  13. 300 0
      src/components/FsMapPicker/components/amap.vue
  14. 13 0
      src/components/FsMapPicker/components/baidu.vue
  15. 146 0
      src/components/FsMapPicker/index.vue
  16. 42 0
      src/components/FsMapPicker/props.ts
  17. 177 0
      src/components/FsPrinter/index.vue
  18. 38 0
      src/components/FsPrinter/props.ts
  19. 48 0
      src/components/FsPrinter/types/index.ts
  20. 219 0
      src/components/FsPrinter/util.ts
  21. 415 0
      src/components/FsTableSelect/index.vue
  22. 58 0
      src/components/FsTableSelect/props.ts
  23. 24 0
      src/components/FsTableSelect/types/index.ts
  24. 304 0
      src/components/FsTour/index.vue
  25. 26 0
      src/components/FsTour/props.ts
  26. 24 0
      src/components/FsTour/types/index.ts
  27. 50 0
      src/components/FsTour/util.ts
  28. 1 32
      src/components/core/GlobalAside.vue
  29. 1 0
      src/components/core/GlobalHeader.vue
  30. 54 0
      src/components/core/GlobalLogo.vue
  31. 5 1
      src/components/core/GlobalSetting.vue
  32. 1 0
      src/config/defaultSetting.ts
  33. 33 0
      src/config/uploadConfig.ts
  34. 190 0
      src/hooks/useExcel.ts
  35. 26 21
      src/layouts/BasicLayout.vue
  36. 4 7
      src/main.ts
  37. 97 1
      src/router/asyncRouter.ts
  38. 6 0
      src/shims-vue.d.ts
  39. 3 1
      src/stores/theme.ts
  40. 3 0
      src/utils/constants.ts
  41. 2 1
      src/utils/request.ts
  42. 194 0
      src/views/extension/checkCard/index.vue
  43. 31 0
      src/views/extension/citySelect/base.vue
  44. 40 0
      src/views/extension/citySelect/custom.vue
  45. 13 0
      src/views/extension/citySelect/index.vue
  46. 50 0
      src/views/extension/citySelect/multiple.vue
  47. 156 0
      src/views/extension/excel/export.vue
  48. 82 0
      src/views/extension/excel/import.vue
  49. 17 0
      src/views/extension/excel/index.vue
  50. 85 0
      src/views/extension/imageUpload/index.vue
  51. 31 0
      src/views/extension/map/amap.vue
  52. 133 0
      src/views/extension/map/amapTrack.vue
  53. 122 0
      src/views/extension/map/index.vue
  54. 179 0
      src/views/extension/printer/index.vue
  55. 149 0
      src/views/extension/printer/printContract.vue
  56. 149 0
      src/views/extension/splitePanel/index.vue
  57. 10 0
      src/views/extension/stausText/index.vue
  58. 93 0
      src/views/extension/tableSelect/base.vue
  59. 86 0
      src/views/extension/tableSelect/disabled.vue
  60. 19 0
      src/views/extension/tableSelect/index.vue
  61. 173 0
      src/views/extension/tableSelect/search.vue
  62. 145 0
      src/views/extension/tour/index.vue

+ 3 - 0
.eslintrc.cjs

@@ -17,5 +17,8 @@ module.exports = {
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/ban-types': 'off',
     'vue/multi-word-component-names': 'off'
+  },
+  globals: {
+    BMapGL: true
   }
 }

+ 3 - 2
package.json

@@ -15,7 +15,7 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
-    "@fskj-admin/core": "^1.2.14",
+    "@fskj-admin/core": "^1.3.1",
     "@fskj-admin/micro": "^0.1.0",
     "@icon-park/vue-next": "^1.4.2",
     "@sentry/vue": "^8.9.2",
@@ -24,7 +24,8 @@
     "@wangeditor/editor-for-vue": "^5.1.12",
     "axios": "^1.7.2",
     "dayjs": "^1.11.11",
-    "element-plus": "^2.7.5",
+    "element-plus": "^2.7.6",
+    "exceljs": "^4.4.0",
     "nprogress": "^0.2.0",
     "pinia": "^2.1.7",
     "splitpanes": "^3.1.5",

File diff suppressed because it is too large
+ 262 - 638
pnpm-lock.yaml


BIN
public/car.png


BIN
public/test.pdf


+ 14 - 0
src/components.d.ts

@@ -7,15 +7,29 @@ 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']
     ElDict: typeof import('./components/form/ElDict.vue')['default']
     ElEditor: typeof import('./components/form/ElEditor.vue')['default']
     ElEmployees: typeof import('./components/form/ElEmployees.vue')['default']
     Exception: typeof import('./components/Exception.vue')['default']
+    FsCheckCard: typeof import('./components/FsCheckCard/index.vue')['default']
+    FsCity: (typeof import('./components/FsCity/index.vue'))['default']
+    FsCitySelect: typeof import('./components/FsCitySelect/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']
+    FsTableSelect: typeof import('./components/FsTableSelect/index.vue')['default']
+    FsText: (typeof import('./components/FsText/index.vue'))['default']
+    FsTour: typeof import('./components/FsTour/index.vue')['default']
     GlobalAside: typeof import('./components/core/GlobalAside.vue')['default']
     GlobalFooter: typeof import('./components/core/GlobalFooter.vue')['default']
     GlobalHeader: typeof import('./components/core/GlobalHeader.vue')['default']
+    GlobalLogo: typeof import('./components/core/GlobalLogo.vue')['default']
     GlobalMenu: typeof import('./components/core/GlobalMenu.vue')['default']
     GlobalNews: typeof import('./components/core/GlobalNews.vue')['default']
     GlobalSetting: typeof import('./components/core/GlobalSetting.vue')['default']

+ 83 - 0
src/components/FsCheckCard/components/CardItem.vue

@@ -0,0 +1,83 @@
+<script setup lang="ts">
+import type { CheckCardItem } from '../types'
+
+defineProps({
+  // 数据
+  item: Object as PropType<CheckCardItem>,
+  // 是否选中
+  checked: Boolean,
+  // 是否禁用
+  disabled: Boolean,
+  // 是否显示边框
+  bordered: Boolean,
+  // 是否需要选中箭头
+  arrow: Boolean
+})
+</script>
+
+<template>
+  <div
+    :class="['check-card-item', { 'is-bordered': bordered }, { 'is-checked': checked }, { 'is-disabled': disabled }]"
+  >
+    <slot :item="item" :checked="checked" :disabled="disabled"></slot>
+    <div v-if="arrow" class="check-card-arrow"></div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.check-card-item {
+  height: 100%;
+  margin-right: 5px;
+  margin-bottom: 5px;
+  font-size: 13px;
+  padding: 0px 6px;
+  cursor: pointer;
+  border-radius: var(--el-border-radius-base);
+  position: relative;
+  border-width: 1px;
+  border-style: solid;
+  border-color: transparent;
+
+  &.is-bordered {
+    border-color: var(--el-border-color);
+  }
+  &.is-checked {
+    &.is-bordered {
+      border-color: var(--el-color-primary);
+    }
+    .check-card-arrow {
+      position: absolute;
+      top: 3px;
+      right: 3px;
+      width: 0;
+      height: 0;
+      border: 4px solid transparent;
+      border-top-color: var(--el-color-primary);
+      border-right-color: var(--el-color-primary);
+      border-top-right-radius: var(--el-border-radius-base);
+      box-sizing: border-box;
+    }
+  }
+
+  &.is-disabled {
+    background: var(--el-disabled-bg-color);
+    opacity: 0.75;
+    cursor: not-allowed;
+
+    &.is-bordered.is-checked {
+      border-color: var(--el-border-color-light);
+    }
+
+    &.is-checked > .check-card-arrow {
+      border-top-color: var(--el-text-color-disabled);
+      border-right-color: var(--el-text-color-disabled);
+    }
+  }
+
+  &:hover:not(.is-disabled) {
+    &.is-bordered {
+      border-color: var(--el-color-primary);
+    }
+  }
+}
+</style>

+ 111 - 0
src/components/FsCheckCard/index.vue

@@ -0,0 +1,111 @@
+<script setup lang="ts">
+import type { CheckCardProps } from './props'
+import { checkCardEmits } from './props'
+import type { CheckCardItem } from './types'
+import CardItem from './components/CardItem.vue'
+
+const props = withDefaults(defineProps<CheckCardProps>(), {
+  modelValue: '',
+  multiple: false,
+  disabled: false,
+  bordered: true,
+  arrow: true
+})
+const emits = defineEmits(checkCardEmits)
+
+const onItemClick = (item: CheckCardItem) => {
+  // 是否禁用
+  if (props.disabled || item.disabled) {
+    return
+  }
+
+  // 是否多选
+  if (props.multiple) {
+    item.checked = !item.checked
+    updateModelValue()
+  } else {
+    // 单选
+    const select = props.items?.find(x => x.checked)
+    select && (select.checked = false)
+    item.checked = !item.checked
+    updateModelValue()
+  }
+}
+
+/* 修改modelValue */
+const updateModelValue = () => {
+  const value =
+    props.items
+      ?.filter(x => x.checked)
+      .map(x => x.value)
+      .join(',') || ''
+  emits('update:modelValue', value)
+}
+watch(
+  () => props.modelValue,
+  () => {
+    if (props.modelValue) {
+      const value = props.modelValue.split(',').filter(x => x)
+      props.items?.forEach(item => {
+        item.checked = value.includes(item.value)
+      })
+      updateModelValue()
+    }
+  },
+  {
+    immediate: true
+  }
+)
+</script>
+
+<template>
+  <el-row v-if="row" v-bind="row === true ? {} : row">
+    <el-col v-for="(item, index) in items" :key="index + '-' + item.value" v-bind="item.col || {}">
+      <CardItem
+        :item="item"
+        :checked="item.checked"
+        :disabled="disabled || item.disabled"
+        :bordered="bordered || item.bordered"
+        :arrow="arrow"
+        @click="onItemClick(item)"
+      >
+        <template #default="slotProps">
+          <slot name="item" v-bind="slotProps || {}">
+            <span class="item-text">{{ item.value }}</span>
+          </slot>
+        </template>
+      </CardItem>
+    </el-col>
+  </el-row>
+  <template v-else>
+    <div class="check-card-container">
+      <CardItem
+        v-for="(item, index) in items"
+        :key="item.key ?? index + '-' + item.value"
+        :item="item"
+        :checked="item.checked"
+        :disabled="disabled || item.disabled"
+        :bordered="bordered || item.bordered"
+        :arrow="arrow"
+        @click="onItemClick(item)"
+      >
+        <template #default="slotProps">
+          <slot name="item" v-bind="slotProps || {}">
+            <span class="item-text">{{ item.value }}</span>
+          </slot>
+        </template>
+      </CardItem>
+    </div>
+  </template>
+</template>
+
+<style scoped lang="scss">
+.check-card-container {
+  display: flex;
+  flex-wrap: wrap;
+}
+.item-text {
+  display: inline-block;
+  padding: 5px 8px 3px 8px;
+}
+</style>

+ 26 - 0
src/components/FsCheckCard/props.ts

@@ -0,0 +1,26 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { RowProps } from 'element-plus'
+import type { CheckCardItem } from './types'
+
+export interface CheckCardProps {
+  // 选中值
+  modelValue: string
+  // 数据
+  items?: CheckCardItem[]
+  // 是否多选
+  multiple?: boolean
+  // 是否禁用
+  disabled?: boolean
+  // 是否显示边框
+  bordered?: boolean
+  // 是否需要选中箭头
+  arrow?: boolean
+  // 是否使用删格布局
+  row?: boolean | Partial<RowProps>
+}
+
+/* 事件 */
+export const checkCardEmits = {
+  // 修改modelValue
+  'update:modelValue': (_value: Array<any> | string | number | null) => true
+}

+ 14 - 0
src/components/FsCheckCard/types/index.ts

@@ -0,0 +1,14 @@
+import type { ColProps } from 'element-plus'
+
+export interface CheckCardItem extends Record<keyof any, any> {
+  // 是否选中
+  checked?: boolean
+  // 值
+  value?: any
+  // 是否禁用
+  disabled?: boolean
+  // 是否显示边框
+  bordered?: boolean
+  // 栅格属性
+  col?: Partial<ColProps>
+}

+ 114 - 0
src/components/FsCitySelect/index.vue

@@ -0,0 +1,114 @@
+<script setup lang="ts">
+import type { citySelectProps } from './props'
+import { citySelectEmits } from './props'
+import { getAreaList } from '@/api/area'
+
+const emits = defineEmits(citySelectEmits)
+
+const props = withDefaults(defineProps<citySelectProps>(), {
+  type: 'area',
+  size: 'default',
+  clearable: true
+})
+
+const cascaderRef = ref(null)
+
+// 选中的数据
+const cascaderValue = ref<any>()
+
+const options = ref<any>([])
+
+/* 自定义配置 */
+const setting = {
+  value: 'id'
+}
+
+/* 配置 */
+const cascaderProps = computed(() => {
+  return Object.assign(setting, props.props)
+})
+
+/* 级联数据 */
+const cascaderData = computed(() => {
+  // 选择区
+  if (props.type === 'area') {
+    return options.value
+  } else if (props.type === 'city') {
+    // 选择市返回两级
+    return options.value.map((x: any) => {
+      return {
+        id: x.id,
+        label: x.label,
+        children: x.children.map((y: any) => {
+          return {
+            id: y.id,
+            label: y.label
+          }
+        })
+      }
+    })
+  } else {
+    // 全部返回
+    return options.value.map((x: any) => {
+      return {
+        id: x.id,
+        label: x.label
+      }
+    })
+  }
+})
+
+/* 获取数据 */
+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) => {
+  let value = ''
+  if (Array.isArray(modelValue)) {
+    // 多选情况利用set去重
+    value = [...new Set([...modelValue])].join(',')
+  }
+  emits('update:modelValue', value)
+}
+
+/* 监听cascaderValue */
+watch(cascaderValue, () => {
+  updateModelValue(cascaderValue.value)
+  // 触发change事件
+  emits('change', cascaderValue.value)
+})
+
+watch(
+  () => props.modelValue,
+  () => {
+    // 传入是字符串转为数组
+    if (typeof props.modelValue === 'string') {
+      cascaderValue.value = props.modelValue.split(',')
+    }
+  },
+  {
+    immediate: true
+  }
+)
+</script>
+
+<template>
+  <el-cascader ref="cascaderRef" v-model="cascaderValue" :options="cascaderData" :props="cascaderProps" />
+</template>
+
+<style scoped lang="scss">
+:deep(.el-cascader__dropdown) {
+  min-width: 200px;
+}
+</style>

+ 19 - 0
src/components/FsCitySelect/props.ts

@@ -0,0 +1,19 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { CascaderProps } from 'element-plus'
+export interface citySelectProps {
+  // 选中数据
+  modelValue: string
+  // 地区类型
+  type?: 'province' | 'city' | 'area'
+  props?: CascaderProps
+  // 自定义数据源
+  datasource?: () => Promise<Array<any>>
+}
+
+/* 事件 */
+export const citySelectEmits = {
+  // 选择事件
+  change: (_value: any) => true,
+  // 修改modelValue
+  'update:modelValue': (_value: any) => true
+}

+ 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
+}

+ 177 - 0
src/components/FsPrinter/index.vue

@@ -0,0 +1,177 @@
+<script setup lang="ts">
+import { getPrintContainer, doPrint, doPrintOnFrame, mergeOptions, usePrinter } from './util'
+import type { PrintOption } from './types'
+import { printerEmits } from './props'
+import type { PrinterProps } from './props'
+
+const props = withDefaults(defineProps<PrinterProps>(), {})
+
+const emits = defineEmits(printerEmits)
+
+const { printId } = usePrinter(() => {
+  updateModelValue(false)
+  onDone()
+})
+
+/** 打印容器 */
+const container = shallowRef<Element>(getPrintContainer())
+
+/** 是否显示 */
+const visible = ref<boolean>(false)
+
+/** 是否显示在文档流中 */
+const isStatic = computed<boolean>(() => {
+  return props.static
+})
+
+/** 打印 */
+const print = (options?: any) => {
+  visible.value = true
+  nextTick(() => {
+    const option: PrintOption = {
+      printId,
+      title: props.title,
+      margin: props.margin,
+      direction: props.direction,
+      orientation: props.orientation,
+      options: mergeOptions(props.options, options)
+    }
+    if (props.target === '_iframe') {
+      doPrintOnFrame(option)
+      visible.value = false
+    } else {
+      doPrint(option)
+      visible.value = false
+      updateModelValue(false)
+      onDone()
+    }
+  })
+}
+
+/** 打印完成事件 */
+const onDone = () => {
+  emits('done')
+}
+
+/** 更新绑定值 */
+const updateModelValue = (value: boolean) => {
+  emits('update:modelValue', value)
+}
+
+watch(
+  () => props.modelValue,
+  value => {
+    if (value) {
+      print()
+    }
+  },
+  { immediate: true }
+)
+
+defineExpose({
+  print
+})
+</script>
+
+<template>
+  <Teleport :to="container" :disabled="isStatic && !visible">
+    <table :class="['custom-printer', { 'is-open': visible }, { 'is-static': isStatic }]">
+      <thead v-if="$slots.header">
+        <tr>
+          <td>
+            <div class="custom-printer-header" :style="headerStyle">
+              <slot name="header"></slot>
+            </div>
+          </td>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <td>
+            <div class="custom-printer-body" :style="bodyStyle">
+              <slot></slot>
+            </div>
+          </td>
+        </tr>
+      </tbody>
+      <tfoot v-if="$slots.footer">
+        <tr>
+          <td>
+            <div class="custom-printer-footer" :style="footerStyle">
+              <slot name="footer"></slot>
+            </div>
+          </td>
+        </tr>
+      </tfoot>
+    </table>
+  </Teleport>
+</template>
+
+<style lang="scss">
+/* 打印容器 */
+#custom-printer-container {
+  pointer-events: none;
+  display: none;
+}
+
+/* 页眉页脚 */
+.custom-printer {
+  width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+  table-layout: fixed;
+
+  & > thead > tr > td,
+  & > tbody > tr > td,
+  & > tfoot > tr > td {
+    padding: 0;
+    border: none;
+  }
+
+  &:not(.is-open):not(.is-static) {
+    display: none;
+  }
+
+  .custom-printer-header,
+  .custom-printer-footer {
+    display: flex;
+    justify-content: space-between;
+    box-sizing: border-box;
+  }
+
+  &:not(.is-open) .custom-printer-header,
+  &:not(.is-open) .custom-printer-footer {
+    display: none;
+  }
+
+  .custom-printer-body {
+    box-sizing: border-box;
+  }
+}
+
+/* 打印状态 */
+html.custom-printing,
+html.custom-printing > body {
+  color: #000 !important;
+  background: #fff !important;
+  height: auto !important;
+  min-height: auto !important;
+  max-height: auto !important;
+  width: auto !important;
+  min-width: auto !important;
+  max-width: auto !important;
+  overflow: visible !important;
+}
+
+html.custom-printing > body > * {
+  display: none !important;
+}
+
+html.custom-printing #custom-printer-container {
+  display: block !important;
+
+  * {
+    pointer-events: none !important;
+  }
+}
+</style>

+ 38 - 0
src/components/FsPrinter/props.ts

@@ -0,0 +1,38 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { CSSProperties } from 'vue'
+import type { PrintDirection, PrintOrientation, PrintTarget } from './types'
+
+export interface PrinterProps {
+  // 是否打印
+  modelValue?: boolean
+  // 页眉样式
+  headerStyle?: Partial<CSSProperties> | Array<Partial<CSSProperties>>
+  // 内容样式
+  bodyStyle?: Partial<CSSProperties> | Array<Partial<CSSProperties>>
+  // 页脚样式
+  footerStyle?: Partial<CSSProperties> | Array<Partial<CSSProperties>>
+  // 标题
+  title?: string
+  // 页间距
+  margin?: string | number
+  // 纸张方向
+  direction?: PrintDirection | null
+  // 纸张旋转
+  orientation?: PrintOrientation | null
+  // 打印位置
+  target?: PrintTarget | null
+  // 是否显示在文档流中
+  static?: boolean
+  // 打印方法参数
+  options?: Record<string, any>
+}
+
+/**
+ * 事件
+ */
+export const printerEmits = {
+  /** 更新打印状态 */
+  'update:modelValue': (_value: boolean) => true,
+  /** 打印完成的事件 */
+  done: () => true
+}

+ 48 - 0
src/components/FsPrinter/types/index.ts

@@ -0,0 +1,48 @@
+/**
+ * 纸张方向
+ */
+export type PrintDirection = 'landscape' | 'portrait' | string
+
+/**
+ * 纸张旋转
+ */
+export type PrintOrientation = 'upright' | 'rotate-left' | 'rotate-right'
+
+/**
+ * 打印位置
+ */
+export type PrintTarget = '_self' | '_blank' | '_iframe'
+
+/**
+ * 打印参数
+ */
+export interface PrintOption {
+  /** id */
+  printId: string
+  /** 页面标题 */
+  title?: string
+  /** 页间距 */
+  margin?: string | number
+  /** 纸张方向 */
+  direction?: string | null
+  /** 纸张旋转 */
+  orientation?: string | null
+  /** 打印方法参数 */
+  options?: any
+}
+
+/**
+ * pdf 打印参数
+ */
+export interface PrintPdfOption {
+  /** pdf 链接地址 */
+  url?: string
+  /** 直接指定 arraybuffer 数据 */
+  arraybuffer?: ArrayBuffer
+  /** 打印完成的回调 */
+  done?: () => void
+  /** 错误回调 */
+  error?: (status: number, result: string) => void
+  /** 打印方法参数 */
+  options?: any
+}

+ 219 - 0
src/components/FsPrinter/util.ts

@@ -0,0 +1,219 @@
+import type { PrintOption, PrintPdfOption } from './types'
+export const printContainerId = 'custom-printer-container'
+export const printFrameId = 'custom-printer-iframe'
+export const printingClass = 'custom-printing'
+
+/**
+ * 创建并获取打印容器
+ */
+export function getPrintContainer(): Element {
+  const container = document.getElementById(printContainerId)
+  if (container) {
+    return container
+  }
+  const elem = document.createElement('div')
+  elem.id = printContainerId
+  document.body.appendChild(elem)
+  return elem
+}
+
+/**
+ * 打印
+ */
+export function doPrint(option: PrintOption) {
+  const $html = document.querySelector('html')
+  if ($html) {
+    $html.classList.add(printingClass)
+    // 打印设置
+    const elem = document.createElement('style')
+    elem.setAttribute('type', 'text/css')
+    elem.setAttribute('media', 'print')
+    elem.innerHTML = getOptionCss(option)
+    document.body.appendChild(elem)
+    // 修改页面标题
+    const title = document.title
+    if (option.title != null && option.title !== '') {
+      document.title = option.title
+    }
+    // 打印
+    ;(window as any).print(option.options)
+    // 打印结束
+    $html.classList.remove(printingClass)
+    document.body.removeChild(elem)
+    // 恢复页面标题
+    if (option.title != null) {
+      document.title = title
+    }
+  }
+}
+
+/**
+ * 在子窗口中打印
+ */
+export function doPrintOnFrame(opt: PrintOption) {
+  const pFrame = getPrintFrame()
+  const pWin = pFrame.contentWindow
+  if (!pWin) {
+    return
+  }
+  pWin.focus()
+  const pDoc = pFrame.contentDocument || pWin.document
+  if (!pDoc) {
+    return
+  }
+  //
+  const container = getPrintContainer()
+  Array.from(container.querySelectorAll('input[type="text"], input[type="number"]')).forEach((el: any) => {
+    el.setAttribute('value', el.value)
+  })
+  //
+  pDoc.open()
+  const printOption = opt.options ? `JSON.parse('${JSON.stringify(opt.options)}')` : ''
+  const optHtml = `
+  <style type="text/css" media="print">
+    ${getOptionCss(opt)}
+  </style>
+  <script>
+    const $html = document.querySelector('html');
+    if($html && $html.classList && $html.classList.add) {
+      $html.classList.add('${printingClass}');
+    }
+    window.onload = function() {
+      if(${opt.title == null ? 0 : 1}) {
+        document.title = '${opt.title}';
+      }
+      window.print(${printOption});
+      window.parent.postMessage('customPrintDone_${opt.printId}', '*');
+    };
+  </script>
+  `
+  const html = document.querySelector('html')?.outerHTML || ''
+  const content = html
+    .replace(/<script/g, '<textarea style="display:none;" ')
+    .replace(/<\/script>/g, '</textarea>')
+    .replace(/<\/html>/, optHtml + '</html>')
+  pDoc.write('<!DOCTYPE html>' + content)
+  pDoc.close()
+  return pWin
+}
+
+/**
+ * 创建并获取打印子窗口
+ */
+export function getPrintFrame(): HTMLIFrameElement {
+  removePrintFrame()
+  const elem = document.createElement('iframe')
+  elem.id = printFrameId
+  elem.style.width = '0px'
+  elem.style.height = '0px'
+  elem.style.position = 'fixed'
+  elem.style.visibility = 'hidden'
+  document.body.appendChild(elem)
+  elem.focus()
+  return elem
+}
+
+/**
+ * 移除打印子窗口
+ */
+export function removePrintFrame() {
+  const pFrame = document.getElementById(printFrameId)
+  if (pFrame && pFrame.parentNode) {
+    pFrame.parentNode.removeChild(pFrame)
+  }
+}
+
+/**
+ * 生成打印设置的样式
+ */
+export function getOptionCss(opt: PrintOption) {
+  const css = ['@page {']
+  if (opt.margin != null && opt.margin !== '') {
+    const v = typeof opt.margin === 'number' ? opt.margin + 'px' : opt.margin
+    css.push(`margin: ${v};`)
+  }
+  if (opt.direction != null && opt.direction !== '') {
+    css.push(`size: ${opt.direction};`)
+  }
+  if (opt.orientation != null && opt.orientation !== '') {
+    css.push(`page-orientation: ${opt.orientation};`)
+  }
+  css.push('}')
+  return css.join(' ')
+}
+
+/**
+ * 合并打印方法参数
+ * @param options 参数
+ * @param userOptions 自定义参数
+ */
+export function mergeOptions(options?: any, userOptions?: any) {
+  if (options == null) {
+    return userOptions
+  }
+  return Object.assign({}, options, userOptions)
+}
+
+/**
+ * usePrinter
+ */
+export function usePrinter(done: () => void) {
+  const printId = Date.now() + ''
+  const onMessage = (e: MessageEvent<any>) => {
+    if (e.data === `customPrintDone_${printId}`) {
+      removePrintFrame()
+      done && done()
+    }
+  }
+  window.addEventListener('message', onMessage)
+  onBeforeUnmount(() => {
+    window.removeEventListener('message', onMessage)
+  })
+  return { printId }
+}
+
+/**
+ * 打印 pdf
+ * @param option 打印参数
+ */
+export function printPdf(option: PrintPdfOption) {
+  const pFrame: any = getPrintFrame()
+  pFrame.onload = () => {
+    const url = pFrame.getAttribute('src')
+    if (url) {
+      pFrame.focus()
+      pFrame.contentWindow && pFrame.contentWindow.print(option.options)
+      option.done && option.done()
+      URL.revokeObjectURL(url)
+    }
+  }
+
+  // 开始打印
+  const doPrint = (buffer: ArrayBuffer) => {
+    const blob = new window.Blob([buffer], { type: 'application/pdf' })
+    // if (window.navigator && window.navigator['msSaveOrOpenBlob']) {
+    //   window.navigator['msSaveOrOpenBlob'](blob, 'print.pdf')
+    //   return
+    // }
+    pFrame.setAttribute('src', window.URL.createObjectURL(blob))
+  }
+
+  // 请求 pdf 数据
+  if (option.arraybuffer) {
+    doPrint(option.arraybuffer)
+    return
+  }
+  if (option.url) {
+    const req = new window.XMLHttpRequest()
+    req.open('GET', option.url, true)
+    req.responseType = 'arraybuffer'
+    req.onload = () => {
+      if ([200, 201].indexOf(req.status) !== -1) {
+        doPrint(req.response)
+        return
+      }
+      option.error && option.error(req.status, req.statusText)
+    }
+    req.send()
+  }
+}

+ 415 - 0
src/components/FsTableSelect/index.vue

@@ -0,0 +1,415 @@
+<script setup lang="ts">
+import type { TableSelectProps } from './props'
+import { tableSelectEmits } from './props'
+import { ElPopover } from 'element-plus'
+import type { VxeTableInstance } from 'vxe-table'
+
+const emits = defineEmits(tableSelectEmits)
+const props = withDefaults(defineProps<TableSelectProps>(), {
+  modelValue: '',
+  size: 'default',
+  clearable: true,
+  valueKey: 'id',
+  labelKey: 'name',
+  placement: 'bottom-start',
+  placeholder: '请选择',
+  popperWidth: 560,
+  tagType: 'info',
+  maxTagCount: 5
+})
+
+/* 格式化提交数据 */
+const transformData = (value: any) => {
+  let result = ''
+  if (value) {
+    if (Array.isArray(value)) {
+      result = value
+        .map(item => {
+          return JSON.stringify({
+            [props.valueKey]: item[props.valueKey],
+            [props.labelKey]: item[props.labelKey]
+          })
+        })
+        .join(',')
+    } else {
+      result = JSON.stringify({ [props.valueKey]: value[props.valueKey], [props.labelKey]: value[props.labelKey] })
+    }
+  }
+  return result
+}
+
+// popover 实例
+const popoverRef = ref<InstanceType<typeof ElPopover>>()
+// 表格实例
+const tableRef = ref<VxeTableInstance<any>>()
+// 是否显示
+const visible = ref(false)
+// 选中展示的数据
+const selectLabel = ref<string | Array<any>>('')
+// 加载状态
+const loading = ref(false)
+// 表格数据
+const tableData = ref<any[]>([])
+// 页码
+const pageNo = ref<number>(1)
+// 总数
+const tableTotal = ref<number>(0)
+
+// 是否未选中
+const isEmpty = computed<boolean>(() => {
+  return props.modelValue == null || props.modelValue === ''
+})
+
+// 是否需要清空图标
+const closeEnable = computed<boolean>(() => {
+  return props.clearable && !props.disabled && !isEmpty.value
+})
+
+// 多选显示的标签
+const currentValues = computed(() => {
+  if (selectLabel.value == null) {
+    return selectLabel.value ?? []
+  }
+  return selectLabel.value.slice(0, props.maxTagCount)
+})
+// 多选折叠的标签
+const omittedValues = computed(() => {
+  if (selectLabel.value == null) {
+    return []
+  }
+  return selectLabel.value.slice(props.maxTagCount)
+})
+
+/* 打开弹窗 */
+const onFocus = (e: FocusEvent) => {
+  emits('focus', e)
+}
+
+/* 关闭弹窗 */
+const onBlur = (e: FocusEvent) => {
+  emits('blur', e)
+}
+
+/* 清除事件 */
+const onClear = () => {
+  updateModelValue('')
+  selectLabel.value = ''
+  // 取消表格全部选中
+  tableRef.value?.clearCheckboxRow()
+  emits('clear')
+}
+
+/* 单个清除事件 */
+const onItemClear = (item: any) => {
+  const list = [...(selectLabel.value as Array<any>)]
+  const index = list.findIndex(x => x[props.labelKey] === item[props.labelKey])
+  list.splice(index, 1)
+  selectLabel.value = list
+
+  // 取消表格选中数据
+  tableRef.value?.toggleCheckboxRow(item)
+  updateModelValue(list)
+  emits('item-clear', { item, list })
+}
+
+/* 表格单选事件 */
+const tableRadioChange = (data: any) => {
+  selectLabel.value = data.row[props.labelKey]
+  visible.value = false
+  // 发出选择事件
+  updateModelValue(data.row)
+  emits('change', data.row)
+}
+
+/* 表格多选择事件 */
+const tableCheckboxChange = (data: any) => {
+  let result: Array<any> = []
+  if (data.checked) {
+    // 使用 Set 去重
+    const uniqueArray = Array.from(
+      new Set([...selectLabel.value, ...data.records].map((x: any) => JSON.stringify(x)))
+    ).map((str: any) => JSON.parse(str))
+    selectLabel.value = uniqueArray
+    result = uniqueArray
+  } else {
+    const selects = selectLabel.value as Array<any>
+    const index = selects.findIndex(x => x[props.valueKey] === data.row[props.valueKey])
+    selects?.splice(index, 1)
+    result = selects
+  }
+  // 发出选择事件
+  updateModelValue(result)
+  emits('change', selectLabel.value)
+}
+
+/* initValue 改变 */
+const initValueChange = (value: any) => {
+  // 如果是字符串,则解析成对象
+  value = typeof value === 'string' ? JSON.parse(value) : value
+  if (props.initValue) {
+    // 处理回显数据
+    if (props.multiple) {
+      selectLabel.value = value
+    } else {
+      selectLabel.value = value[props.labelKey] as string
+    }
+    updateModelValue(value)
+  }
+}
+
+/* 分页改变事件 */
+const paginationChange = (data: number) => {
+  pageNo.value = data
+  request()
+}
+
+/* 表格请求完成 */
+const tableDone = () => {
+  nextTick(() => {
+    const newTableData = tableRef.value?.getTableData().tableData
+    newTableData?.forEach(item => {
+      if (props.multiple) {
+        const temp =
+          Array.isArray(selectLabel.value) && selectLabel.value.find(x => x[props.valueKey] === item[props.valueKey])
+        temp && tableRef.value?.setCheckboxRow(item, true)
+      } else {
+        const temp = item[props.valueKey] === (props.modelValue && JSON.parse(props.modelValue)[props.valueKey])
+        temp && tableRef.value?.setRadioRow(item)
+      }
+    })
+  })
+}
+
+/* 请求数据 */
+const request = (where?: any) => {
+  if (typeof props.tableConfig?.datasource === 'function') {
+    loading.value = true
+    if (where && where.pageNo) {
+      pageNo.value = where.pageNo
+    }
+    props.tableConfig
+      .datasource({
+        pageNo: pageNo.value,
+        pageSize: props.tableConfig.pageSize,
+        ...where
+      })
+      .then((res: any) => {
+        tableTotal.value = res.total || res.totalCount
+        tableData.value = res.infos || res.list || res.records || res.data || res.info
+        tableDone()
+      })
+      .catch((e: any) => {
+        console.warn(e)
+      })
+      .finally(() => {
+        loading.value = false
+      })
+  } else {
+    console.warn('tableConfig.datasource 必须为 Promise')
+  }
+}
+request()
+
+/* 更新选中值 */
+const updateModelValue = (value: any) => {
+  if (value) {
+    emits('update:modelValue', props.transformData ? props.transformData(value) : transformData(value))
+  } else {
+    emits('update:modelValue', '')
+  }
+}
+
+/* 更新气泡位置 */
+watch(currentValues, () => {
+  if (
+    popoverRef.value &&
+    popoverRef.value.popperRef &&
+    popoverRef.value.popperRef.popperInstanceRef &&
+    popoverRef.value.popperRef.popperInstanceRef.update
+  ) {
+    popoverRef.value.popperRef.popperInstanceRef.update()
+  }
+})
+
+watch(
+  () => props.initValue,
+  () => {
+    initValueChange(props.initValue)
+  },
+  { immediate: true }
+)
+
+defineExpose({
+  request
+})
+</script>
+
+<template>
+  <el-popover
+    ref="popoverRef"
+    v-model:visible="visible"
+    :placement="placement"
+    :width="popperWidth"
+    :popper-class="popperClass"
+    :popper-options="popperOptions"
+    :disabled="disabled"
+    trigger="click"
+    transition="el-zoom-in-top"
+    teleported
+  >
+    <template #reference>
+      <div class="table-select-container" :class="{ 'is-multiple': multiple, 'is-visible': visible }">
+        <el-input
+          :size="size"
+          :disabled="disabled"
+          :placeholder="multiple && !isEmpty ? '' : placeholder"
+          :readonly="true"
+          :validateEvent="false"
+          :modelValue="multiple ? '' : selectLabel"
+          @focus="onFocus"
+          @blur="onBlur"
+        >
+          <template v-if="$slots.prefix" #prefix>
+            <slot name="prefix"></slot>
+          </template>
+          <template #suffix>
+            <div class="select-suffix">
+              <ElIcon v-if="closeEnable" class="select-clear" @click.stop="onClear">
+                <slot name="clearIcon">
+                  <CircleClose />
+                </slot>
+              </ElIcon>
+              <ElIcon class="select-down" :class="{ 'select-down-rotate': visible }">
+                <slot name="suffixIcon">
+                  <ArrowDown />
+                </slot>
+              </ElIcon>
+            </div>
+          </template>
+        </el-input>
+        <div class="table-multiple-tag" :class="{ 'is-disabled': disabled }" v-if="multiple">
+          <el-tag
+            :type="tagType"
+            size="small"
+            disable-transitions
+            :closable="!disabled"
+            style="margin-right: 5px"
+            v-for="item in currentValues"
+            :key="item[valueKey]"
+            @close="onItemClear(item)"
+          >
+            {{ item[labelKey] }}
+          </el-tag>
+          <el-tag v-if="omittedValues && omittedValues.length" size="small" disable-transitions :type="tagType">
+            + {{ omittedValues.length }} ...
+          </el-tag>
+        </div>
+      </div>
+    </template>
+    <!-- 上方插槽 -->
+    <slot name="top-extra"></slot>
+    <vxe-table
+      :data="tableData"
+      :row-config="{ isCurrent: true, isHover: true }"
+      :radio-config="{ trigger: multiple ? '' : 'row' }"
+      :checkbox-config="{ trigger: multiple ? 'row' : '' }"
+      ref="tableRef"
+      @radio-change="tableRadioChange"
+      @checkbox-all="tableCheckboxChange"
+      @checkbox-change="tableCheckboxChange"
+      v-loading="loading"
+    >
+      <vxe-column type="checkbox" width="60" v-if="multiple"></vxe-column>
+      <vxe-column type="radio" width="40" v-else> </vxe-column>
+      <vxe-column v-for="(item, index) in tableConfig?.column" :key="index" v-bind="item">
+        <template #default="slotProps" v-if="item.slot">
+          <slot :name="item.slot" v-bind="slotProps || {}"></slot>
+        </template>
+      </vxe-column>
+    </vxe-table>
+    <div class="table-select-pagination" v-if="tableTotal">
+      <el-pagination
+        background
+        layout="total, prev, pager, next, jumper"
+        size="small"
+        :pager-count="5"
+        :page-size="tableConfig && tableConfig.pageSize"
+        :total="tableTotal"
+        v-model:current-page="pageNo"
+        @current-change="paginationChange"
+      />
+    </div>
+    <!-- 下方插槽 -->
+    <slot name="bottom-extra"></slot>
+  </el-popover>
+</template>
+
+<style scoped lang="scss">
+.table-select-container {
+  width: 100%;
+  position: relative;
+
+  .select-clear {
+    position: absolute;
+    right: 10px;
+    top: 49%;
+    transform: translateY(-50%);
+    cursor: pointer;
+    border-radius: 50%;
+    overflow: hidden;
+    z-index: 2;
+    color: #fff;
+    background-color: var(--el-color-info-light-3);
+    opacity: 0;
+    &:hover {
+      background-color: var(--el-color-info);
+    }
+  }
+  .select-down {
+    transition: transform 0.2s;
+  }
+  .select-down-rotate {
+    transform: rotate(180deg);
+  }
+
+  &.is-multiple {
+    :deep(.el-input) {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      top: 0;
+      left: 0;
+    }
+  }
+  &.is-visible {
+    :deep(.el-input:not(.is-disabled) .el-input__wrapper) {
+      box-shadow: 0 0 0 1px var(--el-color-primary) inset;
+    }
+  }
+
+  &:hover {
+    .select-clear {
+      opacity: 1;
+    }
+  }
+}
+
+.table-multiple-tag {
+  position: relative;
+  top: 0px;
+  width: calc(100% - 24px);
+  height: 100%;
+  min-height: 34px;
+  z-index: 2;
+  padding: 0px 10px 2px 10px;
+  box-sizing: border-box;
+  &.is-disabled {
+    cursor: not-allowed;
+  }
+}
+.table-select-pagination {
+  display: flex;
+  justify-content: center;
+  margin-top: 12px;
+}
+</style>

+ 58 - 0
src/components/FsTableSelect/props.ts

@@ -0,0 +1,58 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { InputProps, PopoverProps, TagProps } from 'element-plus'
+import type { TableConfig } from './types'
+
+export interface TableSelectProps {
+  // 绑定值
+  modelValue: any
+  // 是否多选
+  multiple?: boolean
+  // 是否禁用
+  disabled?: boolean
+  // 尺寸
+  size?: InputProps['size']
+  // 是否支持清除
+  clearable?: boolean
+  // value 的属性名
+  valueKey?: string
+  // label 的属性名
+  labelKey?: string
+  // 回显数据,用于后端分页显示
+  initValue?: any
+  // 气泡位置
+  placement?: PopoverProps['placement']
+  // 占位符
+  placeholder?: string
+  // popover 宽度
+  popperWidth?: number | string
+  // 自定义 popper 类名
+  popperClass?: string
+  // popper 配置项
+  popperOptions?: PopoverProps['popperOptions']
+  // 表格配置
+  tableConfig?: TableConfig
+  // tag 类型
+  tagType?: TagProps['type']
+  // 最多显示多少个tag
+  maxTagCount?: number
+  // 自定义数据转换方法
+  transformData?: Function
+}
+
+/**
+ * 事件
+ */
+export const tableSelectEmits = {
+  // 更新绑定值
+  'update:modelValue': (_value: any) => true,
+  // 清除按钮点击事件
+  clear: () => true,
+  // 多选单个清除事件
+  'item-clear': (_value: object) => true,
+  // 获取焦点事件
+  focus: (_e: FocusEvent) => true,
+  // 失去焦点事件
+  blur: (_e: FocusEvent) => true,
+  // 输入框值改变事件
+  change: (_value: string | Array<any>) => true
+}

+ 24 - 0
src/components/FsTableSelect/types/index.ts

@@ -0,0 +1,24 @@
+/* 多选选中数据文本 */
+export interface TableConfig {
+  // 表格列
+  column?: Array<TableColumn>
+  // 表格数据
+  datasource: (...args: any) => Promise<any>
+  // 每页显示条数
+  pageSize?: Number
+}
+
+interface TableColumn {
+  // 类型
+  type?: string
+  // 列名
+  field?: string
+  // 表格宽度
+  width?: number | string
+  // 对齐方式
+  align?: string
+  // 标题
+  title?: string
+  // 插槽
+  slot?: string
+}

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

@@ -0,0 +1,304 @@
+<script setup lang="ts">
+import type { Ref, CSSProperties } from 'vue'
+import type { ElTooltipProps, TooltipInstance } from 'element-plus'
+import { getOffset, getPopperProps, scrollIntoView } from './util'
+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
+})
+
+const emits = defineEmits(tourEmits)
+
+/** 气泡触发组件 */
+const triggerRef = ref<any>(null)
+
+/** 气泡组件 */
+const tooltipRef = ref<TooltipInstance | null>(null)
+
+/** 气泡组件属性 */
+const tooltipProps = shallowRef<Partial<ElTooltipProps>>({})
+
+/** 是否打开引导 */
+const visible = ref<boolean>(false)
+
+/** 当前步骤 */
+const step = shallowRef<TourStep | null>(null)
+
+/** 是否是最后一步 */
+const isLast = ref<boolean>(false)
+
+/** 盒子样式 */
+const boxStyle: Ref<CSSProperties> = ref<CSSProperties>({})
+
+/** 是否显示遮罩 */
+const showMask = ref<boolean>(false)
+
+/** 开始引导 */
+const start = () => {
+  if (!props.steps || props.modelValue == null || props.modelValue < 0 || props.modelValue >= props.steps.length) {
+    close()
+    return
+  }
+  step.value = props.steps[props.modelValue]
+  if (!step.value) {
+    return
+  }
+
+  isLast.value = props.modelValue === props.steps.length - 1
+  const { mask, popoverProps, target, padding } = step.value
+  showMask.value = mask ?? props.mask
+  const el = typeof target === 'function' ? target() : target
+  if (el) {
+    scrollIntoView(el)
+    // 气泡形式
+    const { width, height } = el.getBoundingClientRect()
+    const { top, left } = getOffset(el)
+    const space = padding ?? props.padding ?? 0
+    boxStyle.value = {
+      width: width + space + space + 'px',
+      height: height + space + space + 'px',
+      top: top - space + 'px',
+      left: left - space + 'px'
+    }
+  } else {
+    // 弹窗形式
+    boxStyle.value = {
+      width: '0px',
+      height: '0px',
+      top: '50%',
+      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)
+}
+
+/** 关闭引导 */
+const close = () => {
+  visible.value = false
+  boxStyle.value = {}
+  step.value = null
+  showMask.value = false
+  tooltipProps.value = getPopperProps()
+}
+
+/** 更新步骤值 */
+const updateModelValue = (value?: number | null) => {
+  emits('update:modelValue', value)
+}
+
+/** 上一步 */
+const onPrev = () => {
+  if (props.modelValue != null && props.steps != null && props.steps.length && props.modelValue > 0) {
+    updateModelValue(props.modelValue - 1)
+  }
+}
+
+/** 下一步 */
+const onNext = () => {
+  if (
+    props.modelValue != null &&
+    props.steps != null &&
+    props.steps.length &&
+    props.modelValue < props.steps.length - 1
+  ) {
+    updateModelValue(props.modelValue + 1)
+  }
+}
+
+/** 结束 */
+const onFinish = () => {
+  updateModelValue(null)
+}
+
+/** 更新气泡位置 */
+const updatePopper = () => {
+  tooltipRef.value && tooltipRef.value.updatePopper()
+}
+
+onMounted(() => {
+  start()
+})
+
+watch(
+  () => props.modelValue,
+  () => {
+    start()
+  }
+)
+</script>
+
+<template>
+  <Teleport to="body">
+    <div :class="['tour', { 'show-mask': showMask }, { 'is-open': visible }]" :style="{ zIndex: zIndex }">
+      <div class="tour-box" :style="boxStyle"></div>
+      <div ref="triggerRef" class="tour-reference" :style="boxStyle"></div>
+      <el-tooltip
+        v-bind="tooltipProps"
+        ref="tooltipRef"
+        :virtualRef="triggerRef"
+        :virtualTriggering="true"
+        :disabled="!visible"
+      >
+        <template #content>
+          <div v-if="steps && step" class="popover-body">
+            <div v-if="step.title" class="tour-title">
+              <slot name="title" :step="step" :current="modelValue">
+                {{ step.title }}
+              </slot>
+            </div>
+            <div class="tour-text">
+              <slot name="text" :step="step" :current="modelValue">
+                {{ step.description }}
+              </slot>
+            </div>
+            <slot name="footer" :step="step" :current="modelValue">
+              <div class="tour-footer">
+                <div class="tour-counter">{{ (modelValue || 0) + 1 }}/{{ steps.length }}</div>
+                <div class="tour-action">
+                  <el-button v-if="!isLast" size="small" @click="onFinish"> 跳过 </el-button>
+                  <el-button v-if="modelValue !== 0" size="small" @click="onPrev"> 上一步 </el-button>
+                  <el-button v-if="!isLast" size="small" type="primary" @click="onNext"> 下一步 </el-button>
+                  <el-button v-if="isLast" size="small" type="primary" @click="onFinish"> 结束 </el-button>
+                </div>
+              </div>
+            </slot>
+          </div>
+        </template>
+      </el-tooltip>
+    </div>
+  </Teleport>
+</template>
+
+<style lang="scss">
+.tour {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: var(--el-index-popper);
+  display: none;
+
+  &.is-open {
+    display: block;
+  }
+}
+
+/* 带高亮的遮罩 */
+.tour-box {
+  position: absolute;
+  border: 2px solid var(--el-color-primary);
+  border-radius: var(--border-radius);
+  transition: (left 0.2s, top 0.2s, width 0.2s, height 0.2s);
+}
+
+.tour.show-mask > .tour-box {
+  border: none;
+  box-shadow: 0 0 0 1000vw var(--el-overlay-color-lighter);
+}
+
+/* 气泡定位元素 */
+.tour-reference {
+  position: absolute;
+  box-sizing: border-box;
+}
+
+/* 标题 */
+.tour-title {
+  font-weight: bold;
+  color: var(--el-text-color-primary);
+  margin-bottom: 8px;
+}
+
+/* 内容 */
+.tour-text {
+  font-size: var(--el-font-size-small);
+}
+
+/* 底部 */
+.tour-footer {
+  display: flex;
+  align-items: center;
+  margin-top: 16px;
+}
+
+/* 指示器 */
+.tour-counter {
+  color: var(--el-text-color-secondary);
+  font-size: var(--el-font-size-extra-small);
+}
+
+/* 操作按钮 */
+.tour-action {
+  flex: 1;
+  text-align: right;
+
+  .el-button {
+    padding-left: 8px;
+    padding-right: 8px;
+
+    & + .el-button {
+      margin-left: 8px;
+    }
+  }
+}
+
+.tour-popover > .popover-body {
+  padding: 12px 16px;
+}
+
+/* 气泡弹窗效果 */
+.tour-popover.tour-modal {
+  width: 500px;
+  margin: 0 auto;
+  left: 0 !important;
+  top: 50% !important;
+  right: 0 !important;
+  bottom: auto !important;
+  transform: translateY(-50.1%) !important;
+  transform-origin: center !important;
+  max-width: calc(100% - 32px);
+  box-sizing: border-box;
+
+  & > .el-popper__arrow {
+    display: none;
+  }
+}
+</style>

+ 26 - 0
src/components/FsTour/props.ts

@@ -0,0 +1,26 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { TourStep } from './types'
+
+export interface TourProps {
+  // 是否只显示一次
+  once?: boolean
+  // 唯一标识(显示一次时必传)
+  tourKey?: string
+  // 当前处于第几步
+  modelValue?: number
+  // 步骤
+  steps?: TourStep[]
+  // 是否开启遮罩层
+  mask?: boolean
+  // 高亮区内间距
+  padding?: number
+  // 层级
+  zIndex?: number
+}
+
+/**
+ * 事件
+ */
+export const tourEmits = {
+  'update:modelValue': (_value?: number | null) => true
+}

+ 24 - 0
src/components/FsTour/types/index.ts

@@ -0,0 +1,24 @@
+// 引导步骤指向元素
+export type TourStepTarget = HTMLElement | (() => HTMLElement)
+
+// 引导步骤
+export interface TourStep {
+  // 指向元素
+  target?: TourStepTarget
+  // 标题
+  title?: string
+  // 描述
+  description?: string
+  // 内间距
+  padding?: number
+  // 是否开启遮罩层
+  mask?: boolean
+  // 气泡组件属性
+  popoverProps?: any
+}
+
+// 元素距离
+export interface Offset {
+  top: number
+  left: number
+}

+ 50 - 0
src/components/FsTour/util.ts

@@ -0,0 +1,50 @@
+import type { PopoverProps } from 'element-plus'
+import type { Offset } from './types'
+
+/**
+ * 获取元素距浏览器窗口的距离
+ * @param el 元素
+ */
+export function getOffset(el: HTMLElement): Offset {
+  const { top, left } = el.getBoundingClientRect()
+  const { scrollY, scrollX } = el.ownerDocument.defaultView ?? {}
+  return {
+    top: top + (scrollY ?? 0),
+    left: left + (scrollX ?? 0)
+  }
+}
+
+/**
+ * 获取气泡组件属性
+ * @param visible 是否显示
+ * @param model 是否是弹窗
+ * @param props 自定义属性
+ */
+export function getPopperProps(visible?: boolean, modal?: boolean, props?: PopoverProps): Partial<PopoverProps> {
+  const classes = ['tour-popover']
+  if (modal) {
+    classes.push('tour-modal')
+  }
+  if (props && props.popperClass && typeof props.popperClass === 'string') {
+    classes.push(props.popperClass)
+  }
+  return {
+    trigger: 'click',
+    placement: 'top',
+    teleported: false,
+    transition: 'ele-tour-fast',
+    persistent: false,
+    effect: 'light',
+    ...props,
+    visible: visible ?? false,
+    popperClass: classes.join(' ')
+  }
+}
+
+/**
+ * 让元素可见
+ * @param el 元素
+ */
+export function scrollIntoView(el: HTMLElement) {
+  el.scrollIntoView({ behavior: 'instant', block: 'nearest' })
+}

+ 1 - 32
src/components/core/GlobalAside.vue

@@ -1,42 +1,11 @@
 <script lang="ts" setup>
-import { useMenuStore } from '@/stores/menu'
 import { useThemeStore } from '@/stores/theme'
-import config from '@/config/defaultSetting'
-import router from '@/router'
-
-const handleLogoClick = () => {
-  router.push({ name: config.homeRouteName })
-}
-
-const menuStore = useMenuStore()
-const collapse = computed(() => menuStore.collapse)
 
 const themeStore = useThemeStore()
-const themeStyle = computed(() => themeStore.themeStyle)
-const logoStyle = computed(() => {
-  if (themeStyle.value.name === 'nav-light') {
-    return {
-      color: themeStyle.value.textColor,
-      backgroundColor: themeStyle.value.bgColor
-    }
-  }
-  return {
-    color: '#fff',
-    backgroundColor: themeStyle.value.bgColor
-  }
-})
 </script>
 
 <template>
-  <div
-    class="logo flex items-center justify-center cursor-pointer px-10px"
-    :class="{ collapse: collapse }"
-    :style="logoStyle"
-    @click="handleLogoClick"
-  >
-    <img src="/logo.png" class="h-36px" />
-    <div class="ml-2 line2" :class="{ hidden: collapse }">{{ config.title }}</div>
-  </div>
+  <global-logo v-if="!themeStore.logoInHeader" />
   <global-menu />
 </template>
 

+ 1 - 0
src/components/core/GlobalHeader.vue

@@ -121,6 +121,7 @@ const handleSelect = (menu: any) => {
 
 <template>
   <header class="layout-header" :style="headerStyle">
+    <global-logo v-if="themeStore.logoInHeader" />
     <div class="flex flex-grow items-center justify-between px-4">
       <div class="flex flex-grow">
         <el-button link @click="handleToggle">

+ 54 - 0
src/components/core/GlobalLogo.vue

@@ -0,0 +1,54 @@
+<script setup lang="ts">
+import { useMenuStore } from '@/stores/menu'
+import { useThemeStore } from '@/stores/theme'
+import router from '@/router'
+import config from '@/config/defaultSetting'
+
+const menuStore = useMenuStore()
+
+const collapse = computed(() => menuStore.collapse)
+
+const themeStore = useThemeStore()
+
+const themeStyle = computed(() => themeStore.themeStyle)
+
+const logoStyle = computed(() => {
+  if (themeStore.logoInHeader) {
+    return {
+      color: '#000'
+    }
+  }
+  if (themeStyle.value.name === 'nav-light') {
+    return {
+      color: themeStyle.value.textColor,
+      backgroundColor: themeStyle.value.bgColor
+    }
+  }
+  return {
+    color: '#fff',
+    backgroundColor: themeStyle.value.bgColor
+  }
+})
+
+const handleLogoClick = () => {
+  router.push({ name: config.homeRouteName })
+}
+</script>
+
+<template>
+  <div
+    class="logo flex items-center justify-center cursor-pointer px-10px"
+    :class="{ collapse: !themeStore.logoInHeader && collapse }"
+    :style="logoStyle"
+    @click="handleLogoClick"
+  >
+    <img src="/logo.png" class="h-36px" />
+    <div class="ml-2 line2" :class="{ hidden: collapse }">{{ config.title }}</div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.collapse {
+  width: calc(var(--el-menu-icon-width) + var(--el-menu-base-level-padding) * 2);
+}
+</style>

+ 5 - 1
src/components/core/GlobalSetting.vue

@@ -100,10 +100,14 @@ const themeNav = computed(() => themeStore.themeNav)
       <div>显示标签页</div>
       <el-switch v-model="themeStore.showTabs"></el-switch>
     </div>
-    <div class="flex justify-between items-center">
+    <div class="flex justify-between items-center mb-4">
       <div>标签页持久化</div>
       <el-switch v-model="themeStore.keepAliveTabs"></el-switch>
     </div>
+    <div class="flex justify-between items-center">
+      <div>LOGO置于顶部</div>
+      <el-switch v-model="themeStore.logoInHeader"></el-switch>
+    </div>
   </el-drawer>
 </template>
 

+ 1 - 0
src/config/defaultSetting.ts

@@ -7,6 +7,7 @@ export default {
   uploadApi: '/file/upload', // 文件上传接口
   showTabs: true, // 显示标签页
   keepAliveTabs: true, // 标签页持久化
+  logoInHeader: true, // 头部logo
   oss: true, // 开启oss上传
   ossHost: 'https://fskj-res.oss-cn-zhangjiakou.aliyuncs.com/', // oss域名
   uploadSuccessCb: (res: any) => import.meta.env.VITE_BASE_PATH + res.data, // 文件上传成功回调

+ 33 - 0
src/config/uploadConfig.ts

@@ -0,0 +1,33 @@
+import request from '@/utils/request'
+import { uuid } from '@/utils/utils'
+
+const uploadApi = '/common/upload'
+
+export default {
+  uploadApi,
+  basePath: import.meta.env.VITE_BASE_PATH,
+  uploadFunc: (file: any, onUploadProgress: any): Promise<any> => {
+    const formData = new FormData()
+    formData.append('file', file)
+
+    return request
+      .post(uploadApi, formData, {
+        onUploadProgress
+      })
+      .then((res: any) => {
+        return { data: res.data }
+      })
+  },
+  transformData: (data: string) => {
+    // 将逗号分隔的字符串转换为数组
+    return data
+      .split(',')
+      .filter((item: string) => item)
+      .map(x => ({
+        key: uuid(),
+        url: x,
+        status: 'success',
+        name: x.split('/').pop()
+      }))
+  }
+}

+ 190 - 0
src/hooks/useExcel.ts

@@ -0,0 +1,190 @@
+import ExcelJS from 'exceljs'
+
+/* 导出的参数 */
+export interface IExcel {
+  // 文件名称
+  fileName?: string
+  // 表头数据
+  rows: Array<Array<string | number>>
+  // 表格数据
+  data: Array<Array<any>>
+  // 表格列宽
+  width?: Array<number>
+  // 需要合并的表格
+  merge?: Array<any>
+}
+
+export const useExcel = () => {
+  /**
+   * 导入excel
+   * @param file 传入文件
+   * @param fileMaxSize number 最大文件大小 单位 MB
+   */
+  const importExcel = (file: File, fileMaxSize: number = 20) => {
+    return new Promise((resolve, reject) => {
+      if (
+        !['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(
+          file.type
+        )
+      ) {
+        reject(new Error('只能选择 excel 文件'))
+        return
+      }
+      if (file.size / 1024 / 1024 > fileMaxSize) {
+        reject(new Error('大小不能超过 20MB'))
+        return
+      }
+      const reader = new FileReader()
+      reader.onload = e => {
+        const workbook = new ExcelJS.Workbook()
+        workbook.xlsx.load(e.target?.result as any).then(() => {
+          const title: string[] = []
+          const list: Record<string, any>[] = []
+          workbook.eachSheet(sheet => {
+            list.splice(0, list.length)
+            sheet.eachRow({ includeEmpty: true }, row => {
+              title.splice(0, title.length)
+              const item: Record<string, any> = {}
+              row.eachCell({ includeEmpty: true }, cell => {
+                const key = cell.address.slice(0, -String(cell.row).length)
+                title.push(key)
+                const isMerged = cell.isMerged && cell.address !== cell.master.address
+                item[key] = {
+                  value: cell.value,
+                  colspan: isMerged ? 0 : 1,
+                  rowspan: isMerged ? 0 : 1
+                }
+              })
+              list.push(item)
+            })
+            // 生成表格的跨行跨列
+            sheet.eachRow({ includeEmpty: true }, row => {
+              row.eachCell({ includeEmpty: true }, cell => {
+                const master = cell.master
+                if (cell.isMerged && cell.address !== master.address) {
+                  const mk = master.address.slice(0, -String(master.row).length)
+                  const mItem = list[Number(master.row) - 1][mk]
+                  const rs = Math.abs(Number(cell.row) - Number(master.row)) + 1
+                  rs > mItem.rowspan && (mItem.rowspan = rs)
+                  const cs = Math.abs(Number(cell.col) - Number(master.col)) + 1
+                  cs > mItem.colspan && (mItem.colspan = cs)
+                }
+              })
+            })
+          })
+          resolve({ title, list })
+        })
+      }
+      reader.readAsArrayBuffer(file)
+    })
+  }
+
+  /**
+   * 导出excel
+   */
+  const exportExcel = (options: IExcel) => {
+    return new Promise((resolve, reject) => {
+      options = Object.assign(
+        {
+          fileName: options.fileName || Date.now()
+        },
+        options
+      )
+      const workbook = new ExcelJS.Workbook()
+      const sheet = workbook.addWorksheet('Sheet1')
+      if (!(Array.isArray(options.rows) && Array.isArray(options.rows[0]))) {
+        reject(new Error('表头数据必须为二维数组'))
+        return
+      }
+      if (!Array.isArray(options.data)) {
+        reject(new Error('表格数据必须为数组'))
+        return
+      }
+      // 设置表格宽度
+      const widths =
+        Array.isArray(options.width) && options.width.length
+          ? options.width
+          : Array.from(Array(options.rows[0].length).keys()).map(() => 16)
+
+      // 设置表头
+      options.rows.forEach(row => {
+        sheet.addRow(row)
+      })
+
+      // 设置列宽
+      options.data.forEach(item => {
+        sheet.addRow(item)
+      })
+
+      // 设置列宽
+      widths.forEach((width, index) => {
+        sheet.getColumn(index + 1).width = width
+      })
+
+      // 设置样式
+      sheet.eachRow({ includeEmpty: true }, (row, rowIndex) => {
+        row.height = 20
+        row.eachCell({ includeEmpty: true }, cell => {
+          cell.border = {
+            top: { style: 'thin' },
+            left: { style: 'thin' },
+            bottom: { style: 'thin' },
+            right: { style: 'thin' }
+          }
+          cell.alignment = {
+            vertical: 'middle',
+            horizontal: 'center'
+          }
+          cell.font = { size: 12, bold: rowIndex < options.rows.length }
+        })
+      })
+
+      // 合并单元格
+      if (Array.isArray(options.merge)) {
+        options.merge.forEach(item => {
+          sheet.mergeCells(item)
+        })
+      }
+      // 下载文件
+      workbook.xlsx.writeBuffer().then(data => {
+        download(data, `${options.fileName}.xlsx`)
+      })
+    })
+  }
+
+  /* 转为二维数组 */
+  const toTwoArray = (list: Record<string, any>[]) => {
+    const arr: Array<Array<string | number | null | undefined>> = []
+    list.forEach((item: any) => {
+      const brr = Object.values(item).map((ite: any) => ite.value)
+      arr.push(brr)
+    })
+    return arr
+  }
+
+  /**
+   * 下载文件
+   * @param data 二进制数据,如果传入是http地址,则直接下载
+   * @param name 文件名
+   * @param type 文件类型
+   */
+  const download = (data: Blob | ArrayBuffer | string, name: string, type?: string) => {
+    let url = ''
+    if (typeof data === 'string') {
+      url = data
+    } else {
+      const blob = new Blob([data], { type: type || 'application/octet-stream' })
+      url = window.URL.createObjectURL(blob)
+    }
+    const a = document.createElement('a')
+    a.href = url
+    a.download = name
+    a.style.display = 'none'
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+  }
+
+  return { importExcel, toTwoArray, exportExcel }
+}

+ 26 - 21
src/layouts/BasicLayout.vue

@@ -16,30 +16,35 @@ const themeStore = useThemeStore()
     </router-view>
   </div>
   <el-container class="layout-container" v-else>
-    <el-aside>
-      <global-aside />
-    </el-aside>
+    <el-header v-if="themeStore.logoInHeader">
+      <global-header />
+    </el-header>
     <el-container>
-      <el-header>
-        <global-header />
-      </el-header>
-      <el-main style="padding: 0">
-        <global-tabs v-if="themeStore.showTabs"></global-tabs>
-        <div
-          class="overflow-auto"
-          style="padding: var(--main-padding)"
-          :style="{ height: themeStore.showTabs ? 'calc(100% - 41px)' : '100%' }"
-        >
-          <router-view v-slot="{ Component }">
-            <keep-alive :include="themeStore.keepAliveTabs ? routerStore.keepAliveRouter : []">
-              <component :is="Component" />
-            </keep-alive>
-          </router-view>
-        </div>
-      </el-main>
-      <!-- <el-footer>
+      <el-aside>
+        <global-aside />
+      </el-aside>
+      <el-container>
+        <el-header v-if="!themeStore.logoInHeader">
+          <global-header />
+        </el-header>
+        <el-main style="padding: 0">
+          <global-tabs v-if="themeStore.showTabs"></global-tabs>
+          <div
+            class="overflow-auto"
+            style="padding: var(--main-padding)"
+            :style="{ height: themeStore.showTabs ? 'calc(100% - 41px)' : '100%' }"
+          >
+            <router-view v-slot="{ Component }">
+              <keep-alive :include="themeStore.keepAliveTabs ? routerStore.keepAliveRouter : []">
+                <component :is="Component" />
+              </keep-alive>
+            </router-view>
+          </div>
+        </el-main>
+        <!-- <el-footer>
         <global-footer />
       </el-footer> -->
+      </el-container>
     </el-container>
   </el-container>
 </template>

+ 4 - 7
src/main.ts

@@ -64,7 +64,9 @@ import router from './router'
 import './assets/main.css'
 import 'virtual:svg-icons-register'
 
-import { ossUpload } from '@/utils/utils'
+import uploadConfig from './config/uploadConfig'
+
+// import { ossUpload } from '@/utils/utils'
 
 // import WujieVue from 'wujie-vue3'
 
@@ -117,12 +119,7 @@ app.use(router)
 app.use(ElementPlus)
 app.use(useTable)
 app.use(FsAdminCore, {
-  upload: {
-    oss: true,
-    ossHost: '',
-    ossUpload,
-    baseApi: import.meta.env.VITE_BASE_API
-  }
+  upload: uploadConfig
 })
 // app.use(WujieVue)
 app.use(directives)

+ 97 - 1
src/router/asyncRouter.ts

@@ -10,7 +10,6 @@ const asyncRouter: RouteRecordRaw[] = [
       icon: 'House'
     }
   },
-
   // -- APPEND HERE --
   {
     path: 'https://jijian.sxidc.com/',
@@ -278,6 +277,103 @@ const asyncRouter: RouteRecordRaw[] = [
       }
     ]
   },
+  {
+    path: '/extension',
+    name: 'extension',
+    meta: { title: '扩展组件', icon: 'StarFilled' },
+    children: [
+      {
+        path: '/extension/tableSelect',
+        name: 'tableSelect',
+        component: () => import('@/views/extension/tableSelect/index.vue'),
+        meta: {
+          title: '下拉表格',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/imageUpload',
+        name: 'imageUpload',
+        component: () => import('@/views/extension/imageUpload/index.vue'),
+        meta: {
+          title: '图片上传',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/splitePanel',
+        name: 'splitePanel',
+        component: () => import('@/views/extension/splitePanel/index.vue'),
+        meta: {
+          title: '分割面板',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/excel',
+        name: 'excel',
+        component: () => import('@/views/extension/excel/index.vue'),
+        meta: {
+          title: '导入导出Excel',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/checkCard',
+        name: 'checkCard',
+        component: () => import('@/views/extension/checkCard/index.vue'),
+        meta: {
+          title: '可选卡片',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/printer',
+        name: 'printer',
+        component: () => import('@/views/extension/printer/index.vue'),
+        meta: {
+          title: '打印组件',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/tour',
+        name: 'tour',
+        component: () => import('@/views/extension/tour/index.vue'),
+        meta: {
+          title: '引导组件',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/stausText',
+        name: 'stausText',
+        component: () => import('@/views/extension/stausText/index.vue'),
+        meta: {
+          title: '文本状态',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/citySelect',
+        name: 'citySelect',
+        component: () => import('@/views/extension/citySelect/index.vue'),
+        meta: {
+          title: '城市选择',
+          icon: 'MoreFilled'
+        }
+      },
+      {
+        path: '/extension/map',
+        name: 'extensionMap',
+        component: () => import('@/views/extension/map/index.vue'),
+        meta: {
+          title: '地图选择',
+          icon: 'MoreFilled'
+        }
+      }
+    ]
+  },
   {
     path: '/iframe',
     name: 'iframe',

+ 6 - 0
src/shims-vue.d.ts

@@ -0,0 +1,6 @@
+declare module '*.vue' {
+  import { DefineComponent } from 'vue'
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
+  const component: DefineComponent<{}, {}, any>
+  export default component
+}

+ 3 - 1
src/stores/theme.ts

@@ -45,6 +45,7 @@ export const useThemeStore = defineStore('theme', () => {
   // 界面显示
   const showTabs = useStorage('showTabs', config.showTabs)
   const keepAliveTabs = useStorage('keepAliveTabs', config.keepAliveTabs)
+  const logoInHeader = useStorage('logoInHeader', config.logoInHeader)
 
   const initTheme = () => {
     initThemeColor()
@@ -62,6 +63,7 @@ export const useThemeStore = defineStore('theme', () => {
     setThemeNav,
     initTheme,
     showTabs,
-    keepAliveTabs
+    keepAliveTabs,
+    logoInHeader
   }
 })

+ 3 - 0
src/utils/constants.ts

@@ -80,5 +80,8 @@ export const themeNavList = [
   }
 ]
 
+// logo 是否置于顶栏
+export const logoInHeader = true
+
 export const ACCESS_TOKEN = 'Authorization'
 export const TOKEN_PREFIX = 'Bearer '

+ 2 - 1
src/utils/request.ts

@@ -5,7 +5,8 @@ import { useUserStore } from '@/stores/user'
 import { logout } from '@/utils/micro'
 
 const request = axios.create({
-  baseURL: import.meta.env.VITE_BASE_API
+  baseURL: import.meta.env.VITE_BASE_API,
+  timeout: 1000
 })
 
 // 异常拦截处理器

+ 194 - 0
src/views/extension/checkCard/index.vue

@@ -0,0 +1,194 @@
+<script setup lang="ts">
+import type { CheckCardItem } from '@/components/FsCheckCard/types'
+
+const select = ref('React')
+
+const select2 = ref('')
+
+const select3 = ref('编码')
+
+const select4 = ref('编码')
+
+const items4 = ref<CheckCardItem[]>([{ value: '编码' }, { value: '摸鱼' }, { value: '喝水' }])
+const items5 = ref<CheckCardItem[]>([{ value: '编码' }, { value: '摸鱼' }, { value: '喝水' }])
+
+const items6 = ref([
+  {
+    value: 1,
+    img: 'https://gw.alipayobjects.com/mdn/member_frontWeb/afts/img/A*oRlnSYAsgYQAAAAAAAAAAABkARQnAQ'
+  },
+  {
+    value: 2,
+    img: 'https://e.gitee.com/assets/images/wechatpay.png'
+  },
+  {
+    value: 3,
+    img: 'https://cn.unionpay.com/upowhtml/cn/resources/images/header/homepage-logo.png'
+  }
+])
+
+const items = ref<CheckCardItem[]>([
+  {
+    value: 'Vue',
+    label: 'Vue',
+    col: { md: 6, sm: 12, xs: 24 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'Vue 是一套用于构建用户界面的渐进式框架。'
+  },
+  {
+    value: 'React',
+    label: 'React',
+    col: { span: 6 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'React 用于构建 Web 和原生交互界面的库'
+  },
+  {
+    value: 'Angular',
+    label: 'Angular',
+    col: { span: 6 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'Angular 是一套用于构建用户界面的渐进式框架。'
+  }
+])
+
+const items2 = ref<CheckCardItem[]>([
+  {
+    value: 'Vue',
+    label: 'Vue',
+    col: { md: 6, sm: 12, xs: 24 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'Vue 是一套用于构建用户界面的渐进式框架。'
+  },
+  {
+    value: 'React',
+    label: 'React',
+    col: { span: 6 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'React 用于构建 Web 和原生交互界面的库'
+  },
+  {
+    value: 'Angular',
+    label: 'Angular',
+    col: { span: 6 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'Angular 是一套用于构建用户界面的渐进式框架。'
+  }
+])
+
+const items3 = ref<CheckCardItem[]>([
+  {
+    value: 'Vue',
+    label: 'Vue',
+    col: { md: 6, sm: 12, xs: 24 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'Vue 是一套用于构建用户界面的渐进式框架。',
+    disabled: true,
+    style: { color: '#fff', background: '#009688' }
+  },
+  {
+    value: 'React',
+    label: 'React',
+    col: { span: 6 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'React 用于构建 Web 和原生交互界面的库'
+  },
+  {
+    value: 'Angular',
+    label: 'Angular',
+    col: { span: 6 },
+    src: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+    content: 'Angular 是一套用于构建用户界面的渐进式框架。'
+  }
+])
+
+const allDisabled = ref(false)
+</script>
+
+<template>
+  <el-card shadow="never" header="基础使用">
+    已选中:{{ select }}
+    <fs-check-card v-model="select" :items="items" :row="{ gutter: 10 }" class="mt-3">
+      <template #item="{ item }">
+        <div class="flex p-2" v-if="item">
+          <div>
+            <el-image :src="item.src" fit="cover" class="w-50px h-50px border-rounded"></el-image>
+          </div>
+          <div class="ml-3">
+            <div class="font-bold">{{ item.label }}</div>
+            <div>{{ item.content }}</div>
+          </div>
+        </div>
+      </template>
+    </fs-check-card>
+  </el-card>
+  <el-card shadow="never" header="多选示例" class="mt-3">
+    已选中:{{ select2 }}
+    <fs-check-card v-model="select2" :items="items2" :row="{ gutter: 10 }" multiple class="mt-3">
+      <template #item="{ item }">
+        <div class="flex p-2" v-if="item">
+          <div>
+            <el-image :src="item.src" fit="cover" class="w-50px h-50px border-rounded"></el-image>
+          </div>
+          <div class="ml-3">
+            <div class="font-bold">{{ item.label }}</div>
+            <div>{{ item.content }}</div>
+          </div>
+        </div>
+      </template>
+    </fs-check-card>
+  </el-card>
+  <el-card shadow="never" header="禁用" class="mt-3">
+    <fs-check-card
+      v-model="select2"
+      :items="items3"
+      :row="{ gutter: 10 }"
+      multiple
+      :disabled="allDisabled"
+      class="mt-3"
+    >
+      <template #item="{ item }">
+        <div class="flex p-2" v-if="item">
+          <div>
+            <el-image :src="item.src" fit="cover" class="w-50px h-50px border-rounded"></el-image>
+          </div>
+          <div class="ml-3">
+            <div class="font-bold">{{ item.label }}</div>
+            <div>{{ item.content }}</div>
+          </div>
+        </div>
+      </template>
+    </fs-check-card>
+    <div class="flex items-center text-sm">
+      <span>禁用全部:</span>
+      <el-radio-group v-model="allDisabled">
+        <el-radio label="是" :value="true"> </el-radio>
+        <el-radio label="否" :value="false"> </el-radio>
+      </el-radio-group>
+    </div>
+  </el-card>
+  <el-card shadow="never" header="标签模式" class="mt-3">
+    <div class="flex items-center">
+      <span class="text-sm">多选:</span>
+      <fs-check-card v-model="select3" :items="items4" multiple> </fs-check-card>
+    </div>
+
+    <div class="flex items-center">
+      <span class="text-sm">单选:</span>
+      <fs-check-card v-model="select4" :items="items5" class="mt-3"> </fs-check-card>
+    </div>
+  </el-card>
+  <el-card shadow="never" header="自定义内容" class="mt-3">
+    <div class="flex items-center">
+      <span class="text-sm">支付方式:</span>
+      <fs-check-card v-model="select3" :items="items6">
+        <template #item="{ item }">
+          <div class="flex items-center justify-center p-1" v-if="item">
+            <el-image :src="item.img" class="w-98px h-30px"></el-image>
+          </div>
+        </template>
+      </fs-check-card>
+    </div>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

+ 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>

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

@@ -0,0 +1,13 @@
+<script setup lang="ts">
+import Base from './base.vue'
+import Multiple from './multiple.vue'
+import Custom from './custom.vue'
+</script>
+
+<template>
+  <Base />
+  <Multiple class="mt-3" />
+  <Custom class="mt-3" />
+</template>
+
+<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>

+ 156 - 0
src/views/extension/excel/export.vue

@@ -0,0 +1,156 @@
+<script setup lang="ts">
+import { ElMessage } from 'element-plus'
+import { useExcel, type IExcel } from '@/hooks/useExcel'
+
+const { exportExcel } = useExcel()
+
+const tableData = [
+  {
+    name: '张三',
+    province: '山西',
+    city: '太原',
+    zone: '小店区',
+    street: '平阳路街道',
+    address: '平阳景苑30栋1单元',
+    price: 18
+  },
+  {
+    name: '李四',
+    province: '山东',
+    city: '青岛',
+    zone: '市北区',
+    street: '香港中路',
+    address: '中心大厦1号楼2单元',
+    price: 85
+  },
+  {
+    name: '王五',
+    province: '浙江',
+    city: '杭州',
+    zone: '西湖区',
+    street: '文三路',
+    address: '西溪风景区3栋9单元',
+    price: 8
+  },
+  {
+    name: '赵六',
+    province: '福建',
+    city: '泉州',
+    zone: '丰泽区',
+    street: '南洋街道',
+    address: '南洋村6幢1单元',
+    price: 16
+  },
+  {
+    name: '孙七',
+    province: '河北',
+    city: '武汉',
+    zone: '武昌区',
+    street: '武昌大道',
+    address: '两湖花园16幢2单元',
+    price: 12
+  },
+  {
+    name: '周八',
+    province: '河北',
+    city: '石家庄',
+    zone: '黄山区',
+    street: '汤口镇',
+    address: '温泉村21号',
+    price: 11
+  }
+]
+
+// 选中数据
+const selects = ref([])
+
+/* 导出 */
+const exportFile = () => {
+  const excelParams: IExcel = {
+    fileName: '用户数据',
+    rows: [['用户名', '省', '市', '区', '街道', '详细地址', '金额']],
+    width: [16, 16, 16, 16, 30, 40, 15],
+    data: []
+  }
+  tableData.forEach((item: any) => {
+    excelParams.data.push([item.name, item.province, item.city, item.zone, item.street, item.address, item.price])
+  })
+  exportExcel(excelParams)
+    .then(() => {
+      ElMessage.success('导出成功')
+    })
+    .catch(e => {
+      ElMessage.error(e.message)
+    })
+}
+
+/* 导出合并表格 */
+const exportFile2 = () => {
+  const excelParams: IExcel = {
+    fileName: '用户数据',
+    rows: [
+      ['用户名', '地址', '', '', '', '', '金额'],
+      ['', '省', '市', '区', '街道', '详细地址', '']
+    ],
+    width: [16, 16, 16, 16, 30, 40, 15],
+    data: [],
+    merge: ['A1:A2', 'B1:F1', 'G1:G2'] // 传递合并参数
+  }
+  tableData.forEach((item: any) => {
+    excelParams.data.push([item.name, item.province, item.city, item.zone, item.street, item.address, item.price])
+  })
+  exportExcel(excelParams)
+    .then(() => {
+      console.log()
+    })
+    .catch(e => {
+      ElMessage.error(e.message)
+    })
+}
+
+/* 选择导出 */
+const exportFile3 = () => {
+  const excelParams: IExcel = {
+    fileName: '用户数据',
+    rows: [['用户名', '省', '市', '区', '街道', '详细地址', '金额']],
+    width: [16, 16, 16, 16, 30, 40, 15],
+    data: []
+  }
+  selects.value.forEach((item: any) => {
+    excelParams.data.push([item.name, item.province, item.city, item.zone, item.street, item.address, item.price])
+  })
+  exportExcel(excelParams)
+    .then(() => {
+      console.log()
+    })
+    .catch(e => {
+      ElMessage.error(e.message)
+    })
+}
+
+const handleSelectionChange = (data: any) => {
+  selects.value = data
+}
+</script>
+
+<template>
+  <el-card shadow="never">
+    <el-button type="primary" @click="exportFile">导出</el-button>
+    <el-button type="primary" @click="exportFile2">导出合并表格</el-button>
+    <el-button type="primary" :disabled="!selects.length" @click="exportFile3">导出选中</el-button>
+    <el-table :data="tableData" border style="width: 100%" class="mt-3" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="50" />
+      <el-table-column prop="name" label="用户名"></el-table-column>
+      <el-table-column label="地址" align="center">
+        <el-table-column prop="province" label="省" />
+        <el-table-column prop="city" label="市" />
+        <el-table-column prop="zone" label="区" />
+        <el-table-column prop="street" label="街道" />
+        <el-table-column prop="address" label="详细地址" />
+      </el-table-column>
+      <el-table-column prop="price" label="金额" />
+    </el-table>
+  </el-card>
+</template>
+
+<style scoped></style>

+ 82 - 0
src/views/extension/excel/import.vue

@@ -0,0 +1,82 @@
+<script setup lang="ts">
+import { ElMessage } from 'element-plus'
+import { useExcel } from '@/hooks/useExcel'
+
+const { importExcel, toTwoArray } = useExcel()
+
+// 表格数据
+const tableData = ref<Record<string, any>[]>([])
+// 表格二维数据
+const tableDataTwo = ref<Record<string, any>[]>([])
+// 表头数据
+const column = ref<Array<string>>(['A', 'B', 'C', 'D', 'E', 'F', 'G'])
+
+const importFile = (file: File) => {
+  importExcel(file).then(({ title, list }: any) => {
+    column.value = title
+    tableData.value = list
+
+    tableDataTwo.value = toTwoArray(list)
+  })
+
+  return false
+}
+
+const getTwoData = () => {
+  console.log(tableDataTwo.value)
+  ElMessage.success('数据已打印在控制台')
+}
+</script>
+
+<template>
+  <el-card shadow="never">
+    <div class="flex">
+      <el-upload action="" accept=".xls,.xlsx" :show-upload-list="false" :before-upload="importFile">
+        <el-button type="primary">导入</el-button>
+      </el-upload>
+      <el-button type="primary" @click="getTwoData" class="ml-3">获取表格二维数据</el-button>
+    </div>
+    <table class="table" border="1">
+      <colgroup>
+        <col width="52" />
+        <col v-for="item in column" :key="item" />
+      </colgroup>
+      <thead>
+        <tr>
+          <th></th>
+          <th v-for="item in column" :key="item" style="text-align: center">
+            {{ item }}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="(item, index) in tableData" :key="index">
+          <td style="text-align: center">{{ index + 1 }}</td>
+          <template v-for="key in column">
+            <td
+              v-if="item[key]?.colspan !== 0 && item[key].rowspan !== 0"
+              :key="key"
+              :colspan="item[key].colspan"
+              :rowspan="item[key].rowspan"
+              style="text-align: center"
+            >
+              {{ item[key].value }}
+            </td>
+          </template>
+        </tr>
+        <tr v-if="!tableData.length">
+          <td :colspan="column.length + 1" style="text-align: center; background: none">暂无数据</td>
+        </tr>
+      </tbody>
+    </table>
+  </el-card>
+</template>
+
+<style scoped>
+.table {
+  width: 100%;
+  border-collapse: collapse;
+  border-spacing: 1px;
+  border-color: var(--el-border-color);
+}
+</style>

+ 17 - 0
src/views/extension/excel/index.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import ImportExcel from './import.vue'
+import ExportExcel from './export.vue'
+</script>
+
+<template>
+  <ExportExcel class="mb-3" />
+  <ImportExcel />
+</template>
+
+<style scoped>
+.table {
+  width: 100%;
+  border-collapse: collapse;
+  border-spacing: 1px;
+}
+</style>

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

@@ -0,0 +1,85 @@
+<script setup lang="ts">
+import { ElMessage } from 'element-plus'
+import config from '@/config/defaultSetting'
+const images = ref('')
+// 手动上传数据
+const images2 = ref('')
+const images3 = ref('')
+const imageUploadRef = ref()
+
+const disabled = ref(false)
+
+const onRemove = (file: any) => {
+  console.log(file)
+}
+
+const uploadFunction = (item: any) => {
+  return new Promise(() => {
+    item.status = 'uploading'
+    item.progress = 50
+    setTimeout(() => {
+      item.progress = 90
+    }, 1000)
+    setTimeout(() => {
+      item.progress = 100
+      item.status = Math.random() > 0.5 ? 'success' : 'danger'
+    }, 1500)
+  })
+}
+
+const upload = () => {
+  imageUploadRef.value?.submit()
+}
+
+const submit = () => {
+  if (!imageUploadRef.value?.checkUpload()) {
+    ElMessage.error('请等待图片上传完成')
+    return
+  }
+  ElMessage.success('上传完成')
+}
+</script>
+
+<template>
+  <el-card header="基础示例" shadow="never">
+    <el-image-upload
+      v-model="images"
+      :limit="5"
+      :uploadFunction="uploadFunction"
+      :disabled="disabled"
+      :action="config.uploadApi"
+      drag
+      @remove="onRemove"
+    />
+    <div class="flex items-center mt-2">
+      <div>是否禁用:</div>
+      <el-radio-group v-model="disabled">
+        <el-radio label="是" :value="true"> </el-radio>
+        <el-radio label="否" :value="false"> </el-radio>
+      </el-radio-group>
+    </div>
+    <div>
+      {{ images }}
+    </div>
+  </el-card>
+  <el-card header="支持多选" shadow="never" class="mt-3">
+    <el-image-upload v-model="images2" multiple :limit="5" :uploadFunction="uploadFunction" @remove="onRemove" />
+  </el-card>
+  <el-card header="基础示例" shadow="never" class="mt-3">
+    <el-image-upload
+      v-model="images3"
+      :auto-upload="false"
+      :limit="5"
+      :uploadFunction="uploadFunction"
+      :item-style="{ width: '50px', height: '100px' }"
+      ref="imageUploadRef"
+      @remove="onRemove"
+    />
+    <div class="flex items-center mt-2">
+      <el-button type="primary" @click="upload">手动上传</el-button>
+      <el-button type="primary" @click="submit">检查是否上传完毕</el-button>
+    </div>
+  </el-card>
+</template>
+
+<style scoped></style>

+ 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: any) => {
+        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>

+ 122 - 0
src/views/extension/map/index.vue

@@ -0,0 +1,122 @@
+<script setup lang="ts">
+import Amap from './amap.vue'
+import AmapTrack from './amapTrack.vue'
+let bmap: any = null
+let lushu: any = null
+
+const data = [
+  {
+    licensePlate: '',
+    speed: 47,
+    sendData: 1718434061000,
+    lat: 36.483671256780845,
+    lon: 112.35835768559537
+  },
+  {
+    licensePlate: '',
+    speed: 47,
+    sendData: 1718434061000,
+    lat: 36.48392562378963,
+    lon: 112.35380873633531
+  },
+  {
+    licensePlate: '',
+    speed: 47,
+    sendData: 1718434061000,
+    lat: 36.484445885520906,
+    lon: 112.34942980034347
+  },
+  {
+    licensePlate: '',
+    speed: 47,
+    sendData: 1718434061000,
+    lat: 36.484613829160985,
+    lon: 112.34826673209832
+  },
+  {
+    licensePlate: '',
+    speed: 47,
+    sendData: 1718434061000,
+    lat: 36.48548191169621,
+    lon: 112.34776796383788
+  },
+  {
+    licensePlate: '',
+    speed: 47,
+    sendData: 1718434061000,
+    lat: 36.48727931765302,
+    lon: 112.34804304633398
+  }
+]
+
+const points: any = []
+
+onMounted(() => {
+  initMap()
+})
+
+const initMap = () => {
+  const center = new (window as any).BMap.Point(112.35835768559537, 36.483671256780845)
+  bmap = new (window as any).BMap.Map('map') // 创建Map实例
+  bmap.centerAndZoom(center, 14) // 初始化地图,设置中心点坐标和地图级别
+  bmap.enableScrollWheelZoom(true) // 开启鼠标滚轮缩放
+
+  // 初始化数据
+  for (let i = 0; i < data.length; i++) {
+    points.push(new (window as any).BMap.Point(data[i].lon, data[i].lat))
+  }
+
+  createTrack()
+}
+
+/* 开始动画 */
+const start = () => {
+  if (lushu) {
+    lushu.start()
+    lushu.showInfoWindow()
+  }
+}
+
+/* 暂停动画 */
+const pause = () => {
+  if (lushu) {
+    lushu.pause()
+  }
+}
+
+/* 创建轨迹动画 */
+const createTrack = () => {
+  // 实例化一个驾车导航用来生成路线
+  bmap.addOverlay(new (window as any).BMap.Polyline(points, { strokeColor: 'red' }))
+  bmap.setViewport(points)
+
+  lushu = new (window as any).BMapLib.LuShu(bmap, points, {
+    defaultContent: '默认内容',
+    autoView: true, //是否开启自动视野调整,如果开启那么路书在运动过程中会根据视野自动调整
+    icon: new (window as any).BMap.Icon('/car.png', new (window as any).BMap.Size(52, 26), {
+      anchor: new (window as any).BMap.Size(27, 13)
+    }),
+    speed: 500,
+    enableRotation: true //是否设置marker随着道路的走向进行旋转
+  })
+  console.log(lushu)
+  // const start = new (window as any).BMap.Point(points[0].lng, points[0].lat)
+  // const end = new (window as any).BMap.Point(points[points.length - 1].lng, points[points.length - 1].lat)
+}
+</script>
+
+<template>
+  <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>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

+ 179 - 0
src/views/extension/printer/index.vue

@@ -0,0 +1,179 @@
+<script setup lang="ts">
+import type { PrintDirection, PrintOrientation, PrintTarget } from '@/components/FsPrinter/types'
+import { ElLoading } from 'element-plus'
+import FsPrinter from '@/components/FsPrinter/index.vue'
+import PrintContract from './printContract.vue'
+import { printPdf } from '@/components/FsPrinter/util'
+
+const isPrinter = ref(false)
+
+interface Option {
+  direction?: PrintDirection
+  orientation?: PrintOrientation
+  margin: string
+  title: string
+  target: PrintTarget
+  static: boolean
+}
+
+/** 打印参数 */
+const option = reactive<Option>({
+  direction: void 0,
+  orientation: void 0,
+  margin: '',
+  title: '',
+  target: '_iframe',
+  static: false
+})
+
+const tableData = [
+  {
+    date: '2016-05-03',
+    name: 'Tom',
+    address: 'No. 189, Grove St, Los Angeles'
+  },
+  {
+    date: '2016-05-02',
+    name: 'Tom',
+    address: 'No. 189, Grove St, Los Angeles'
+  },
+  {
+    date: '2016-05-04',
+    name: 'Tom',
+    address: 'No. 189, Grove St, Los Angeles'
+  },
+  {
+    date: '2016-05-01',
+    name: 'Tom',
+    address: 'No. 189, Grove St, Los Angeles'
+  }
+]
+
+const status = ref('1')
+const status2 = ref(['1'])
+
+const contractData = reactive({
+  partyA: '张三',
+  partyB: '李四',
+  address: '太原市小店区龙兴街1号'
+})
+
+const contractRef = ref()
+
+const onPrint = () => {
+  isPrinter.value = true
+}
+
+const onPrint2 = () => {
+  contractRef.value?.print()
+}
+
+const onPrintPdf = () => {
+  const loading = ElLoading.service({ text: '加载中' })
+  printPdf({
+    url: '/test.pdf',
+    done: () => {
+      loading.close()
+    }
+  })
+}
+</script>
+
+<template>
+  <el-card header="自定义打印" shadow="never">
+    <el-form label-width="80px" style="max-width: 320px" @submit.prevent="">
+      <el-form-item label="纸张方向">
+        <el-select clearable class="ele-fluid" placeholder="不设置" v-model="option.direction">
+          <el-option value="landscape" label="横向" />
+          <el-option value="portrait" label="纵向" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="纸张旋转">
+        <el-select clearable class="ele-fluid" placeholder="不旋转" v-model="option.orientation">
+          <el-option value="rotate-left" label="向左旋转" />
+          <el-option value="rotate-right" label="向右旋转" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="页面间距">
+        <el-input clearable v-model="option.margin" placeholder="不设置" />
+      </el-form-item>
+      <el-form-item label="页面标题">
+        <el-input clearable v-model="option.title" placeholder="默认" />
+      </el-form-item>
+      <el-form-item label="打印位置">
+        <el-select class="ele-fluid" v-model="option.target">
+          <el-option value="_iframe" label="子窗口" />
+          <el-option value="_self" label="当前窗口" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="始终显示">
+        <el-radio-group v-model="option.static">
+          <el-radio :value="false" label="否" />
+          <el-radio :value="true" label="是" />
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="onPrint">打印</el-button>
+      </el-form-item>
+    </el-form>
+    <fs-printer
+      v-model="isPrinter"
+      :direction="option.direction"
+      :orientation="option.orientation"
+      :margin="option.margin"
+      :title="option.title"
+      :target="option.target"
+      :static="option.static"
+      :body-style="{ overflow: 'hidden' }"
+    >
+      <div style="overflow: auto">
+        <el-table :data="tableData">
+          <el-table-column prop="date" label="Date" width="180" />
+          <el-table-column prop="name" label="Name" width="180">
+            <template #default="{ row }">
+              <el-tag type="success">{{ row.name }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="address" label="Address" width="300" />
+        </el-table>
+        <div style="margin: 12px 0">更多表单组件演示:</div>
+        <div>
+          <el-radio-group v-model="status">
+            <el-radio value="1">Option 1</el-radio>
+            <el-radio value="2">Option 2</el-radio>
+          </el-radio-group>
+        </div>
+        <div>
+          <el-checkbox-group v-model="status2">
+            <el-checkbox value="1">Option 1</el-checkbox>
+            <el-checkbox value="2">Option 2</el-checkbox>
+          </el-checkbox-group>
+        </div>
+      </div>
+    </fs-printer>
+  </el-card>
+  <el-card header="合同打印" shadow="never" class="mt-3">
+    <div class="w-260px">
+      <el-form ref="form" :model="contractData" label-width="80px">
+        <el-form-item label="甲方" prop="name">
+          <el-input placeholder="请填写甲方" :maxLength="20" v-model="contractData.partyA"></el-input>
+        </el-form-item>
+        <el-form-item label="己方" prop="name">
+          <el-input placeholder="请填写己方" :maxLength="20" v-model="contractData.partyB"></el-input>
+        </el-form-item>
+        <el-form-item label="地址" prop="name">
+          <el-input placeholder="请填写甲方" :maxLength="20" v-model="contractData.address"></el-input>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="onPrint2">打印</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+    <print-contract ref="contractRef" :data="contractData" :is-static="option.static" :target="option.target" />
+  </el-card>
+  <el-card shadow="never" header="pdf 示例" class="mt-3">
+    <el-button type="primary" @click="onPrintPdf">打印pdf</el-button>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

+ 149 - 0
src/views/extension/printer/printContract.vue

@@ -0,0 +1,149 @@
+<!-- 房屋租赁合同 -->
+<template>
+  <fs-printer
+    ref="printerRef"
+    margin="0mm 12mm 10mm 12mm"
+    :header-style="{
+      padding: '26px 0 2px 0',
+      fontSize: '13px',
+      borderBottom: '1px solid #666',
+      marginBottom: '26px'
+    }"
+    :body-style="{ padding: '0 14px', fontSize: '17px', lineHeight: 2.5 }"
+    :target="target"
+    :static="isStatic"
+  >
+    <template #header>
+      <div style="color: #444">
+        <img src="/logo.png" style="height: 15px; width: 15px; vertical-align: -2px" />
+        <span> 方是公寓房屋租赁合同</span>
+      </div>
+      <div style="color: #888">电话:0.51-8888888</div>
+    </template>
+    <div v-if="!!data" style="page-break-after: always">
+      <div style="font-size: 35px; text-align: center">房屋租赁合同</div>
+      <div style="margin-top: 12px">
+        <span>甲方:</span>
+        <span class="demo-input" style="min-width: 280px">
+          {{ data.partyA }}
+        </span>
+      </div>
+      <div>
+        <span>乙方:</span>
+        <span class="demo-input" style="min-width: 280px">
+          {{ data.partyB }}
+        </span>
+      </div>
+      <div>
+        <span>房屋地址:</span>
+        <span class="demo-input" style="min-width: 280px">
+          {{ data.address }}
+        </span>
+      </div>
+      <div>甲、乙双方就房屋租赁事宜,达成如下协议:</div>
+      <div style="margin: 8px 0 4px 0">一、租赁期限</div>
+      <div>1、租赁期限自______年______月______日起至______年______月______日止。</div>
+      <div>2、租赁期满后,如果乙方需要继续租赁,则甲方有权优先考虑与乙方续签租赁合同。</div>
+      <div style="margin: 8px 0 4px 0">二、房屋使用费用</div>
+      <div>1、乙方承担租赁期间的水费、电费、燃气费、物业管理费等使用费用。</div>
+      <div>2、乙方在入住前应一次性向甲方支付押金______元,用于租赁期内的房屋维修和卫生保洁。</div>
+      <div>
+        3、乙方在租赁期间应妥善保管房屋及其设备设施,不得人为损坏。
+        如因乙方过错导致房屋设备设施损坏,乙方应承担维修或更换费用。
+      </div>
+      <div style="margin: 8px 0 4px 0">三、房屋使用要求</div>
+      <div>1、乙方应遵守国家相关法律法规,不得将房屋用于违法活动。</div>
+      <div>2、乙方应爱护房屋及其设备设施,不得随意改造和拆除。</div>
+      <div>3、乙方不得将房屋转租他人,不得将房屋用于存放危险品和易燃易爆品。</div>
+      <div style="margin: 8px 0 4px 0">四、支付方式</div>
+      <div>1、乙方应按月支付租金,租金为______元/月。</div>
+      <div>2、乙方应在每月______日前将租金支付给甲方,付款方式为(现金、银行转账等)。</div>
+      <div style="margin: 8px 0 4px 0">五、合同解除和终止</div>
+      <div>1、在租赁期内,如果乙方符合下列条件之一,则甲方有权解除合同:</div>
+      <div>(1)乙方将房屋转租他人;</div>
+      <div>(2)乙方将房屋用于违法活动;</div>
+      <div>(3)乙方未按时支付租金达30天以上。</div>
+      <div>2、在租赁期内,如果甲方符合下列条件之一,则乙方有权解除合同:</div>
+      <div>(1)甲方将房屋擅自出租他人;</div>
+      <div>(2)甲方将房屋用于违法活动;</div>
+      <div>(3)甲方未按时提供房屋及其设备设施的正常使用。</div>
+      <div>
+        3、合同期满后,如果乙方不再续租,则甲方有权在3日内收回房屋。如果乙方需要继续租赁,
+        则应在本合同期满前3个月内向甲方提出书面申请,并双方重新签订租赁合同。
+      </div>
+      <div style="margin: 8px 0 4px 0">六、其他事项</div>
+      <div>1、本合同自双方签字盖章之日起生效。</div>
+      <div>2、本合同一式两份,甲、乙双方各执一份,具有同等法律效力。</div>
+      <div style="margin-top: 28px">
+        <span>甲方:</span>
+        <span class="demo-input" style="min-width: 220px">
+          {{ data.partyA }}
+        </span>
+        <span>&emsp;&emsp;乙方:</span>
+        <span class="demo-input" style="min-width: 220px">
+          {{ data.partyB }}
+        </span>
+      </div>
+      <div>
+        <span>电话:</span>
+        <span class="demo-input" style="min-width: 220px"></span>
+        <span>&emsp;&emsp;电话:</span>
+        <span class="demo-input" style="min-width: 220px"></span>
+      </div>
+      <div>
+        <span>日期:</span>
+        <span class="demo-input" style="min-width: 220px">{{ data.date }}</span>
+        <span>&emsp;&emsp;日期:</span>
+        <span class="demo-input" style="min-width: 220px">{{ data.date }}</span>
+      </div>
+    </div>
+    <div class="demo-title">图片打印示例:</div>
+    <img src="/logo.png" style="width: 150px; vertical-align: -2px" />
+  </fs-printer>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import type { PrintTarget } from '@/components/FsPrinter/types'
+
+defineProps<{
+  /** 合同数据 */
+  data?: any
+  /** 始终显示 */
+  isStatic: boolean
+  /** 打印位置 */
+  target: PrintTarget
+}>()
+
+/** 打印组件 */
+const printerRef = ref()
+
+/** 打印 */
+const print = () => {
+  if (printerRef.value) {
+    printerRef.value?.print()
+  }
+}
+
+defineExpose({ print })
+</script>
+
+<style lang="scss" scoped>
+.demo-input {
+  height: 24px;
+  line-height: 24px;
+  max-width: 80%;
+  padding: 0 14px;
+  display: inline-block;
+  border-bottom: 1px solid #000;
+  vertical-align: middle;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.demo-title {
+  font-size: 22px;
+  margin-bottom: 24px;
+  line-height: 1.2;
+}
+</style>

+ 149 - 0
src/views/extension/splitePanel/index.vue

@@ -0,0 +1,149 @@
+<script setup lang="ts">
+// 是否显示折叠按钮
+const allowCollapse = ref<boolean>(true)
+
+// 是否支持自由拉伸
+const resizable = ref<boolean>(true)
+
+// 是否上下布局模式
+const vertical = ref<boolean>(false)
+
+// 是否反转布局方向
+const reverse = ref<boolean>(false)
+</script>
+
+<template>
+  <el-card shadow="never">
+    <el-form label-width="120px">
+      <el-form-item label="显示折叠按钮">
+        <el-radio-group v-model="allowCollapse">
+          <el-radio label="是" :value="true"></el-radio>
+          <el-radio label="否" :value="false"></el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="支持自由拉伸">
+        <el-radio-group v-model="resizable">
+          <el-radio label="是" :value="true"></el-radio>
+          <el-radio label="否" :value="false"></el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="上下布局模式">
+        <el-radio-group v-model="vertical">
+          <el-radio label="是" :value="true"></el-radio>
+          <el-radio label="否" :value="false"></el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="反转布局方向">
+        <el-radio-group v-model="reverse">
+          <el-radio label="是" :value="true"></el-radio>
+          <el-radio label="否" :value="false"></el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <el-split-panel
+      space="0px"
+      size="160px"
+      :allow-collapse="allowCollapse"
+      :resizable="resizable"
+      :vertical="vertical"
+      :reverse="reverse"
+      :custom-style="{ background: '#E3E2F5', overflow: 'hidden', border: 'none' }"
+      :body-style="{ background: '#CBF3FE', overflow: 'hidden' }"
+      style="height: 360px"
+    >
+      <div>边栏</div>
+      <template #body>
+        <div>内容</div>
+      </template>
+    </el-split-panel>
+  </el-card>
+  <el-card shadow="never" class="mt-5">
+    <div style="margin: 0 0 8px 0">先左右再上下</div>
+    <el-split-panel
+      space="0px"
+      :min-size="40"
+      :max-size="-40"
+      :resizable="true"
+      :custom-style="{
+        background: 'rgba(185, 182, 229, .4)',
+        overflow: 'hidden',
+        border: 'none'
+      }"
+      :body-style="{ overflow: 'hidden' }"
+      :responsive="false"
+      style="height: 400px"
+    >
+      <div>边栏</div>
+      <template #body>
+        <el-split-panel
+          space="0px"
+          :min-size="40"
+          :max-size="-40"
+          :vertical="true"
+          :resizable="true"
+          :custom-style="{
+            background: 'rgba(171, 199, 255, .5)',
+            overflow: 'hidden',
+            border: 'none'
+          }"
+          :body-style="{
+            background: 'rgba(125, 226, 252, .4)',
+            overflow: 'hidden'
+          }"
+          :responsive="false"
+          style="height: 400px"
+        >
+          <div>内容一</div>
+          <template #body>
+            <div>内容二</div>
+          </template>
+        </el-split-panel>
+      </template>
+    </el-split-panel>
+    <div style="margin: 16px 0 8px 0">先上下再左右</div>
+    <el-split-panel
+      space="0px"
+      size="120px"
+      :min-size="40"
+      :max-size="-40"
+      :vertical="true"
+      :resizable="true"
+      :custom-style="{
+        background: 'rgba(185, 182, 229, .4)',
+        overflow: 'hidden',
+        border: 'none'
+      }"
+      :body-style="{ overflow: 'hidden' }"
+      :responsive="false"
+      style="height: 400px"
+    >
+      <div>顶栏</div>
+      <template #body>
+        <el-split-panel
+          space="0px"
+          :min-size="40"
+          :max-size="-40"
+          :resizable="true"
+          :custom-style="{
+            background: 'rgba(171, 199, 255, .5)',
+            overflow: 'hidden',
+            border: 'none'
+          }"
+          :body-style="{
+            background: 'rgba(125, 226, 252, .4)',
+            overflow: 'hidden'
+          }"
+          :responsive="false"
+          style="height: 100%"
+        >
+          <div>边栏</div>
+          <template #body>
+            <div>内容</div>
+          </template>
+        </el-split-panel>
+      </template>
+    </el-split-panel>
+  </el-card>
+</template>
+
+<style scoped></style>

+ 10 - 0
src/views/extension/stausText/index.vue

@@ -0,0 +1,10 @@
+<template>
+  <el-card header="文本状态" shadow="never">
+    <el-fs-dot type="primary" text="运行中"></el-fs-dot>
+    <el-fs-dot type="success" text="已上线" class="ml-5"></el-fs-dot>
+    <el-fs-dot type="warning" text="异常" class="ml-5"></el-fs-dot>
+    <el-fs-dot type="info" text="关闭" class="ml-5"></el-fs-dot>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

+ 93 - 0
src/views/extension/tableSelect/base.vue

@@ -0,0 +1,93 @@
+<script setup lang="ts">
+import type { TableConfig } from '@/components/FsTableSelect/types'
+
+const data = ref('')
+
+const datas = ref('')
+
+const request = function ({ pageNo }: any) {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      const data = {
+        list: [
+          { name: '张三' + pageNo, sex: '男', id: '' + pageNo + 1 },
+          { name: '李四' + pageNo, sex: '女', id: '' + pageNo + 2 },
+          { name: '王五' + pageNo, sex: '男', id: '' + pageNo + 3 },
+          { name: '赵六' + pageNo, sex: '女', id: '' + pageNo + 4 },
+          { name: '张三' + pageNo, sex: '男', id: '' + pageNo + 5 }
+        ],
+        total: 10
+      }
+      resolve(data)
+    }, 1500)
+  })
+}
+
+const tableConfig: TableConfig = {
+  column: [
+    {
+      title: '#',
+      type: 'seq',
+      width: 45,
+      align: 'center'
+    },
+    {
+      title: '姓名',
+      field: 'name',
+      align: 'center'
+    },
+    {
+      title: '性别',
+      align: 'center',
+      slot: 'sex'
+    }
+  ],
+  /* 实际开发应传入接口方法 */
+  datasource: request,
+  pageSize: 5
+}
+
+// 自定义数据格式
+const transformData = (data: any) => {
+  return data.id
+}
+
+const transformData2 = (data: any) => {
+  return data.map((item: any) => item.id)
+}
+</script>
+
+<template>
+  <el-form label-width="80px">
+    <el-row :gutter="15">
+      <el-col :span="6">
+        <el-form-item label="单选">
+          <fs-table-select v-model="data" :tableConfig="tableConfig" :transform-data="transformData">
+            <template #sex="{ row }">
+              <el-tag size="small">{{ row.sex }}</el-tag>
+            </template>
+          </fs-table-select>
+          选中数据: {{ data }}
+        </el-form-item>
+      </el-col>
+      <el-col :span="6">
+        <el-form-item label="多选">
+          <fs-table-select
+            v-model="datas"
+            :tableConfig="tableConfig"
+            multiple
+            tagType="success"
+            :transform-data="transformData2"
+          >
+            <template #sex="{ row }">
+              <el-tag size="small">{{ row.sex }}</el-tag>
+            </template>
+          </fs-table-select>
+          选中数据: {{ datas }}
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+
+<style scoped></style>

+ 86 - 0
src/views/extension/tableSelect/disabled.vue

@@ -0,0 +1,86 @@
+<script setup lang="ts">
+import FsTableSelect from '@/components/FsTableSelect/index.vue'
+import type { TableConfig } from '@/components/FsTableSelect/types'
+
+const data = ref('')
+
+const datas = ref('')
+
+const isDisabled = ref(true)
+
+const request = function ({ pageNo }: any) {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      const data = {
+        list: [
+          { name: '张三' + pageNo, sex: '男', id: '' + pageNo + 1 },
+          { name: '李四' + pageNo, sex: '女', id: '' + pageNo + 2 },
+          { name: '王五' + pageNo, sex: '男', id: '' + pageNo + 3 },
+          { name: '赵六' + pageNo, sex: '女', id: '' + pageNo + 4 },
+          { name: '张三' + pageNo, sex: '男', id: '' + pageNo + 5 }
+        ],
+        total: 10
+      }
+      resolve(data)
+    }, 1500)
+  })
+}
+
+const tableConfig: TableConfig = {
+  column: [
+    {
+      title: '#',
+      type: 'seq',
+      width: 45,
+      align: 'center'
+    },
+    {
+      title: '姓名',
+      field: 'name',
+      align: 'center'
+    },
+    {
+      title: '性别',
+      align: 'center',
+      slot: 'sex'
+    }
+  ],
+  datasource: request,
+  pageSize: 5
+}
+</script>
+
+<template>
+  <el-form label-width="80px">
+    <el-row :gutter="15">
+      <el-col :span="24">
+        <el-form-item label="">
+          <el-radio-group v-model="isDisabled">
+            <el-radio label="禁用" :value="true"> </el-radio>
+            <el-radio label="启用" :value="false"> </el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-col>
+      <el-col :span="6">
+        <el-form-item label="单选">
+          <fs-table-select v-model="data" :tableConfig="tableConfig" :disabled="isDisabled">
+            <template #sex="{ row }">
+              <el-tag size="small">{{ row.sex }}</el-tag>
+            </template>
+          </fs-table-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="6">
+        <el-form-item label="多选">
+          <fs-table-select v-model="datas" :tableConfig="tableConfig" :disabled="isDisabled" multiple>
+            <template #sex="{ row }">
+              <el-tag size="small">{{ row.sex }}</el-tag>
+            </template>
+          </fs-table-select>
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+
+<style scoped></style>

+ 19 - 0
src/views/extension/tableSelect/index.vue

@@ -0,0 +1,19 @@
+<script setup lang="ts">
+import Base from './base.vue'
+import Disabled from './disabled.vue'
+import Search from './search.vue'
+</script>
+
+<template>
+  <el-card header="基础示例" shadow="never">
+    <Base class="mt-3" />
+  </el-card>
+  <el-card header="是否禁用" shadow="never" class="mt-5">
+    <Disabled class="mt-3" />
+  </el-card>
+  <el-card header="自定义搜索" shadow="never" class="mt-5">
+    <Search class="mt-3" />
+  </el-card>
+</template>
+
+<style scoped></style>

+ 173 - 0
src/views/extension/tableSelect/search.vue

@@ -0,0 +1,173 @@
+<script setup lang="ts">
+import FsTableSelect from '@/components/FsTableSelect/index.vue'
+import type { TableConfig } from '@/components/FsTableSelect/types'
+
+const data = ref('')
+const datas = ref('')
+
+const initValue = ref({})
+const initValue2 = ref<Array<any>>([])
+
+const current = ref(null)
+
+const request = function ({ pageNo }: any) {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      const data = {
+        total: 20,
+        list: [
+          { name: '张三' + pageNo, sex: '男', id: '' + pageNo + 1 },
+          { name: '李四' + pageNo, sex: '女', id: '' + pageNo + 2 },
+          { name: '王五' + pageNo, sex: '男', id: '' + pageNo + 3 },
+          { name: '赵六' + pageNo, sex: '女', id: '' + pageNo + 4 },
+          { name: '张三' + pageNo, sex: '男', id: '' + pageNo + 5 }
+        ]
+      }
+      resolve(data)
+    }, 500)
+  })
+}
+
+const tableConfig: TableConfig = {
+  column: [
+    {
+      title: '#',
+      type: 'seq',
+      width: 45,
+      align: 'center'
+    },
+    {
+      title: '姓名',
+      field: 'name',
+      align: 'center'
+    },
+    {
+      title: '性别',
+      align: 'center',
+      slot: 'sex'
+    }
+  ],
+  datasource: request,
+  pageSize: 5
+}
+
+const tableSelectChange = (value: any) => {
+  current.value = value
+}
+
+const tableSelectClear = () => {
+  current.value = null
+}
+
+const tableSelectItemClear = ({ list }: any) => {
+  current.value = list
+}
+
+const tableSelectRef = ref()
+const tableSelectRef2 = ref()
+const form = ref({
+  name: '',
+  sex: ''
+})
+
+const search = () => {
+  tableSelectRef.value.reload({ pageNo: 1, ...form.value })
+}
+const search2 = () => {
+  tableSelectRef2.value.reload({ pageNo: 1, ...form.value })
+}
+
+const setInitValue = () => {
+  initValue.value = { name: '张三2', sex: '男', id: '21' }
+  initValue2.value = [
+    { name: '李四2', sex: '女', id: '22' },
+    { name: '王五3', sex: '男', id: '33' },
+    { name: '王五4', sex: '男', id: '43' },
+    { name: '赵六4', sex: '女', id: '44' }
+  ]
+}
+</script>
+
+<template>
+  <el-form label-width="90px">
+    <el-row :gutter="15">
+      <el-col :span="24">
+        <el-form-item>
+          <el-button type="primary" @click="setInitValue">设置回显数据</el-button>
+        </el-form-item>
+      </el-col>
+      <el-col :span="6">
+        <el-form-item label="单选" prop="name">
+          <fs-table-select
+            v-model="data"
+            :tableConfig="tableConfig"
+            :init-value="initValue"
+            @change="tableSelectChange"
+            @clear="tableSelectClear"
+            @item-clear="tableSelectItemClear"
+            ref="tableSelectRef"
+          >
+            <template #top-extra>
+              <el-form :model="form" label-width="40px">
+                <el-row :gutter="15">
+                  <el-col :span="8">
+                    <el-form-item label="姓名">
+                      <el-input placeholder="请填写姓名" :maxLength="20" v-model="form.name"></el-input>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="性别" prop="sex">
+                      <el-select placeholder="请选择性别" v-model="form.sex"></el-select>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-button type="primary" @click="search">查询</el-button>
+                  </el-col>
+                </el-row>
+              </el-form>
+            </template>
+            <template #sex="{ row }">
+              <el-tag size="small">{{ row.sex }}</el-tag>
+            </template>
+          </fs-table-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="6">
+        <el-form-item label="多选">
+          <fs-table-select
+            v-model="datas"
+            :init-value="initValue2"
+            :tableConfig="tableConfig"
+            multiple
+            ref="tableSelectRef2"
+          >
+            <template #top-extra>
+              <el-form :model="form" label-width="40px">
+                <el-row :gutter="15">
+                  <el-col :span="8">
+                    <el-form-item label="姓名" prop="name">
+                      <el-input placeholder="请填写姓名" :maxLength="20" v-model="form.name"></el-input>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="性别" prop="sex">
+                      <el-select placeholder="请选择性别" v-model="form.sex"></el-select>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-button type="primary" @click="search2">查询</el-button>
+                  </el-col>
+                </el-row>
+              </el-form>
+            </template>
+            <template #sex="{ row }">
+              <el-tag size="small">{{ row.sex }}</el-tag>
+            </template>
+          </fs-table-select>
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+
+<style scoped></style>

+ 145 - 0
src/views/extension/tour/index.vue

@@ -0,0 +1,145 @@
+<script setup lang="ts">
+import type { TourStep } from '@/components/FsTour/types'
+import type { ElButton } from 'element-plus'
+const current = ref()
+const uploadRef1 = ref<InstanceType<typeof ElButton>>()
+const saveRef1 = ref<InstanceType<typeof ElButton>>()
+const moreRef1 = ref<InstanceType<typeof ElButton>>()
+
+/** 步骤 */
+const steps = ref<TourStep[]>([
+  {
+    target: () => uploadRef1.value?.$el,
+    title: '修改数据',
+    description: '点击这个按钮在弹出框中选择想要修改的数据即可.'
+  },
+  {
+    target: () => saveRef1.value?.$el,
+    title: '保存数据',
+    description: '数据录入完成后点击这个按钮即可提交数据到后台.'
+  },
+  {
+    target: () => moreRef1.value?.$el,
+    title: '如何进行更多的操作',
+    description: '鼠标移入到此按钮上即可展示出更多的操作功能.'
+  }
+])
+
+const current2 = ref()
+const uploadRef2 = ref<InstanceType<typeof ElButton>>()
+const saveRef2 = ref<InstanceType<typeof ElButton>>()
+const moreRef2 = ref<InstanceType<typeof ElButton>>()
+/** 步骤 */
+const steps2 = ref<TourStep[]>([
+  {
+    target: () => uploadRef2.value?.$el,
+    title: '修改数据',
+    description: '点击这个按钮在弹出框中选择想要修改的数据即可.'
+  },
+  {
+    target: () => saveRef2.value?.$el,
+    title: '保存数据',
+    description: '数据录入完成后点击这个按钮即可提交数据到后台.'
+  },
+  {
+    target: () => moreRef2.value?.$el,
+    title: '如何进行更多的操作',
+    description: '鼠标移入到此按钮上即可展示出更多的操作功能.'
+  }
+])
+
+const current3 = ref()
+const uploadRef3 = ref<InstanceType<typeof ElButton>>()
+const saveRef3 = ref<InstanceType<typeof ElButton>>()
+const moreRef3 = ref<InstanceType<typeof ElButton>>()
+const steps3 = ref<TourStep[]>([
+  {
+    title: '欢迎使用 xxxx管理系统 系统',
+    description: '下面将为您介绍一些常用功能的操作说明, 如果之前已经为您介绍过, 您可以直接点击跳过结束指引.'
+  },
+  {
+    target: () => uploadRef3.value?.$el,
+    title: '如何进行文件上传',
+    description: '点击这个按钮在弹出框中选择想要上传的文件即可.'
+  },
+  {
+    target: () => saveRef3.value?.$el,
+    title: '如何提交数据',
+    description: '数据录入完成后点击这个按钮即可提交数据到后台.',
+    mask: false
+  },
+  {
+    target: () => moreRef3.value?.$el,
+    title: '如何进行更多的操作',
+    description: '鼠标移入到此按钮上即可展示出更多的操作功能.'
+  }
+])
+
+const onStart1 = () => {
+  current.value = 0
+}
+
+const onStart2 = () => {
+  current2.value = 0
+}
+
+const onStart3 = () => {
+  current3.value = 0
+}
+</script>
+
+<template>
+  <el-card header="基础用法" shadow="never">
+    <div>
+      <el-button type="primary" @click="onStart1">开始引导</el-button>
+    </div>
+    <div style="margin-top: 20px">
+      <el-button ref="uploadRef1">修改</el-button>
+      <el-button ref="saveRef1" type="primary">保存</el-button>
+      <el-button ref="moreRef1">更多</el-button>
+    </div>
+    <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">
+    <div>
+      <el-button type="primary" @click="onStart2">开始引导</el-button>
+    </div>
+    <div style="margin-top: 20px">
+      <el-button ref="uploadRef2">修改</el-button>
+      <el-button ref="saveRef2" type="primary">保存</el-button>
+      <el-button ref="moreRef2">更多</el-button>
+    </div>
+    <fs-tour v-model="current2" :steps="steps2" :mask="false" />
+  </el-card>
+
+  <el-card header="混合弹窗等多种形式" shadow="never" class="mt-3">
+    <div>
+      <el-button type="primary" @click="onStart3">开始引导</el-button>
+    </div>
+    <div style="margin-top: 20px">
+      <el-button ref="uploadRef3">Upload</el-button>
+      <el-button ref="saveRef3" type="primary">Save</el-button>
+      <el-button ref="moreRef3">More</el-button>
+    </div>
+    <fs-tour v-model="current3" :steps="steps3">
+      <template #text="{ step, current }">
+        <template v-if="current === 0">
+          <div style="margin-bottom: 10px">
+            <img
+              src="https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*P0S-QIRUbsUAAAAAAAAAAABkARQnAQ"
+              style="height: 184px; width: 100%; object-fit: cover"
+            />
+          </div>
+          <div>{{ step.description }}</div>
+        </template>
+      </template>
+    </fs-tour>
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>

Some files were not shown because too many files changed in this diff