Browse Source

新增分割面板、excel导入导出、可选卡片

XueNing 6 months ago
parent
commit
9229709bc1

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "axios": "^1.7.2",
     "dayjs": "^1.11.11",
     "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
+ 427 - 0
pnpm-lock.yaml


+ 3 - 0
src/components.d.ts

@@ -7,13 +7,16 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
+    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']
     FsImageUpload: typeof import('./components/FsImageUpload/index.vue')['default']
+    FsSplitPanel: typeof import('./components/FsSplitPanel/index.vue')['default']
     FsTableSelect: typeof import('./components/FsTableSelect/index.vue')['default']
     GlobalAside: typeof import('./components/core/GlobalAside.vue')['default']
     GlobalFooter: typeof import('./components/core/GlobalFooter.vue')['default']

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

@@ -0,0 +1,62 @@
+<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 {
+  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;
+
+  &.is-bordered {
+    border-color: var(--el-border-color);
+  }
+  &.is-checked {
+    border-color: var(--el-color-primary);
+    .check-card-arrow {
+      position: absolute;
+      top: 3px;
+      right: 3px;
+      width: 0;
+      height: 0;
+      border: 6px solid transparent;
+      border-top-color: var(--el-color-primary);
+      border-right-color: var(--el-color-primary);
+      border-top-right-radius: var(--el-border-radius-small);
+      box-sizing: border-box;
+    }
+  }
+
+  &:hover {
+    border-color: var(--el-color-primary);
+  }
+}
+</style>

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

@@ -0,0 +1,48 @@
+<script setup lang="ts">
+import { checkCardProps } from './props'
+import CardItem from './components/CardItem.vue'
+
+defineProps(checkCardProps)
+</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"
+      >
+        <template #default="slotProps">
+          <slot name="item" v-bind="slotProps || {}">{{ item.value }}</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"
+      >
+        <template #default="slotProps">
+          <slot name="item" v-bind="slotProps || {}">{{ item.value }}</slot>
+        </template>
+      </CardItem>
+    </div>
+  </template>
+</template>
+
+<style scoped lang="scss">
+.check-card-container {
+  display: flex;
+  flex-wrap: wrap;
+}
+</style>

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

@@ -0,0 +1,33 @@
+import type { RowProps } from 'element-plus'
+import type { CheckCardItem } from './types'
+
+export const checkCardProps = {
+  // 选中值
+  modelValue: {
+    type: [Array, String, Number, Boolean],
+    default: () => {
+      return null
+    }
+  },
+  // 数据
+  items: Object as PropType<CheckCardItem[]>,
+  // 是否多选
+  multiple: Boolean,
+  // 是否禁用
+  disabled: Boolean,
+  // 是否显示边框
+  bordered: {
+    type: Boolean,
+    default: true
+  },
+  // 是否需要选中箭头
+  arrow: {
+    type: Boolean,
+    default: true
+  },
+  // 是否使用栅格布局
+  row: {
+    type: [Boolean, Object] as PropType<boolean | RowProps>,
+    default: true
+  }
+}

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

@@ -0,0 +1,20 @@
+import type { CSSProperties } from 'vue'
+import type { ColProps } from 'element-plus'
+
+export interface CheckCardItem extends Record<keyof any, any> {
+  // key
+  key?: string | number | symbol
+  /**  */
+  // 值
+  value?: string | number | boolean
+  // 是否禁用
+  disabled?: boolean
+  // 是否显示边框
+  bordered?: boolean
+  // 自定义类名
+  class?: string
+  // 自定义样式
+  style?: CSSProperties
+  // 栅格属性
+  col?: Partial<ColProps>
+}

+ 198 - 59
src/components/FsImageUpload/index.vue

@@ -1,71 +1,126 @@
-<script lang="ts">
+<script setup lang="ts">
+import { ElMessage } from 'element-plus'
 import { imageUploadProps, imageUploadEmits } from './props'
 import type { UploadItem } from './types'
 
-export default defineComponent({
-  props: imageUploadProps,
-  emits: imageUploadEmits,
-  setup(props, { emit }) {
-    // 是否可上传
-    const isUpload = computed<boolean>(() => {
-      return (
-        !props.readonly &&
-        !(
-          typeof props.limit === 'number' &&
-          props.limit > 0 &&
-          props.modelValue != null &&
-          props.modelValue.length >= props.limit
-        )
-      )
-    })
+const emit = defineEmits(imageUploadEmits)
 
-    // 预览图片列表
-    const previewList = computed(() => {
-      if (!props.preview) {
-        return []
-      }
-      return props.modelValue?.map(x => x.url) || []
+const props = defineProps(imageUploadProps)
+
+const images = ref<UploadItem[]>([])
+
+// 是否可上传
+const isUpload = computed<boolean>(() => {
+  return (
+    !props.readonly &&
+    !(typeof props.limit === 'number' && props.limit > 0 && images.value != null && images.value.length >= props.limit)
+  )
+})
+
+// 检查图片是否全部上传完毕
+const checkUpload = () => {
+  return images.value.length == 0 || (images.value.length && !images.value.some(x => x.status != 'success'))
+}
+
+// 预览图片列表
+const previewList = computed(() => {
+  if (!props.preview) {
+    return []
+  }
+  return images.value?.map(x => x.url) || []
+})
+
+/* 选择文件 */
+const onUpload = (file: File) => {
+  console.log(file)
+  if (!isUpload.value || props.disabled) {
+    return false
+  }
+  if (!file.type.startsWith('image')) {
+    ElMessage.error('只能选择图片')
+    return false
+  }
+  if (props.accept.indexOf(file.type) === -1) {
+    ElMessage.error(`只能选择${props.accept}图片`)
+    return false
+  }
+  if (file.size / 1024 / 1024 > props.fileSize) {
+    ElMessage.error(`大小不能超过 ${props.fileSize}MB`)
+    return false
+  }
+
+  const item: UploadItem = {
+    key: Date.now(),
+    name: file.name,
+    status: void 0,
+    progress: 0,
+    file
+  }
+  item.url = window.URL.createObjectURL(file)
+
+  images.value.push(item)
+  // 是否自动上传
+  if (props.autoUpload) {
+    uploadItem(images.value.at(-1) as UploadItem)
+  }
+  return false
+}
+
+/* 上传文件 */
+const uploadItem = (item: UploadItem) => {
+  if (typeof props.uploadFunction === 'function') {
+    props.uploadFunction(item)
+  } else {
+    console.log('请传入uploadFunction')
+  }
+}
+
+/* 手动上传文件 */
+const submit = () => {
+  if (images.value.length) {
+    images.value.forEach(item => {
+      item.status != 'success' && uploadItem(item)
     })
+  }
+}
 
-    /* 选择文件 */
-    const onUpload = (file: File) => {
-      if (!isUpload.value || props.disabled) {
-        return false
-      }
-      const item: UploadItem = {
-        key: Date.now(),
-        name: file.name,
-        status: void 0,
-        progress: 0,
-        file
-      }
-      if (file.type.startsWith('image')) {
-        item.url = window.URL.createObjectURL(file)
-      }
-      // 是否自动上传
-      if (props.autoUpload) {
-        uploadItem(item)
-      }
-      updateModelValue(props.modelValue ? props.modelValue.concat([item]) : [item])
-      return false
-    }
+/* 删除图片 */
+const onRemove = (index: number) => {
+  emit('remove', images.value[index])
+  images.value.splice(index, 1)
+}
 
-    /* 上传文件 */
-    const uploadItem = (item: UploadItem) => {
-      emit('upload', item)
-    }
+/* 重新上传 */
+const onRetry = (index: number) => {
+  uploadItem(images.value[index])
+}
 
-    /* 修改modelValue */
-    const updateModelValue = (items: UploadItem[]) => {
-      emit('update:modelValue', items)
-    }
+/* 修改modelValue */
+const updateModelValue = (items: UploadItem[]) => {
+  emit('update:modelValue', items)
+}
 
-    return {
-      isUpload,
-      previewList,
-      onUpload
-    }
+watch(
+  () => props.modelValue,
+  () => {
+    images.value = props.modelValue || []
+  },
+  { immediate: true }
+)
+
+watch(
+  images.value,
+  () => {
+    updateModelValue(images.value)
+  },
+  {
+    deep: true
   }
+)
+
+defineExpose({
+  checkUpload,
+  submit
 })
 </script>
 
@@ -73,6 +128,31 @@ export default defineComponent({
   <div class="upload-container">
     <div class="upload-image" v-for="(item, index) in modelValue" :key="item.key">
       <el-image :src="item.url" :preview-src-list="previewList" :initial-index="index" fit="cover"></el-image>
+      <div v-if="!readonly && !disabled" class="upload-remove" @click.stop="onRemove(index)">
+        <el-icon size="14">
+          <Close />
+        </el-icon>
+      </div>
+      <div v-if="item.status === 'uploading' || item.status === 'danger'" class="upload-progress">
+        <slot name="progress" :item="item">
+          <div class="upload-text">
+            {{ item.status == 'danger' ? '上传失败' : '上传中' }}
+          </div>
+          <el-progress
+            :showText="false"
+            v-bind="progressProps || {}"
+            :percentage="item.progress"
+            :status="item.status === 'danger' ? 'exception' : void 0"
+          />
+          <div v-if="!disabled">
+            <div v-if="item.status === 'danger'" class="upload-retry" @click.stop="onRetry(index)">
+              <el-icon>
+                <Refresh />
+              </el-icon>
+            </div>
+          </div>
+        </slot>
+      </div>
     </div>
     <div>
       <el-upload
@@ -82,6 +162,7 @@ export default defineComponent({
         :disabled="disabled"
         :show-file-list="false"
         :beforeUpload="onUpload"
+        :drag="drag"
         v-if="isUpload"
       >
         <div class="upload-plus" :style="buttonStyle">
@@ -99,6 +180,7 @@ export default defineComponent({
   display: flex;
   flex-wrap: wrap;
   .upload-image {
+    position: relative;
     width: 100px;
     height: 100px;
     border: 1px dashed #dcdfe6;
@@ -106,6 +188,50 @@ export default defineComponent({
     overflow: hidden;
     margin: 0px 8px 8px 0;
     cursor: pointer;
+    .upload-remove {
+      width: 20px;
+      height: 20px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      position: absolute;
+      right: 0px;
+      top: 0px;
+      background-color: rgba($color: #000000, $alpha: 0.3);
+      color: #fff;
+      border-radius: 0px 0px 0px var(--el-border-radius-round);
+      z-index: 9;
+      &:hover {
+        background-color: var(--el-color-danger);
+      }
+      .el-icon {
+        margin-top: -5px;
+        margin-left: 2px;
+      }
+    }
+    .upload-progress {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      left: 0px;
+      top: 0px;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      padding: 10px;
+      background-color: rgba($color: #000000, $alpha: 0.4);
+      color: #fff;
+      box-sizing: border-box;
+      .upload-text {
+        text-align: center;
+        margin-bottom: 3px;
+        font-size: 12px;
+      }
+      .upload-retry {
+        text-align: center;
+        margin-top: 5px;
+      }
+    }
   }
   .upload-plus {
     width: 100px;
@@ -116,10 +242,23 @@ export default defineComponent({
     color: var(--el-color-info);
     border: 1px dashed #dcdfe6;
     border-radius: var(--el-border-radius-base);
-    background-color: #fff;
     &:hover {
       border-color: var(--el-color-primary);
     }
   }
+  :deep(.el-upload-dragger) {
+    width: auto;
+    height: auto;
+    padding: 0px;
+    margin: 0px;
+    border-color: transparent;
+    border-width: 0px;
+    border-radius: 0px;
+    background-color: transparent;
+    &.is-dragover {
+      outline: 1px dashed var(--el-color-primary);
+      background-color: var(--el-color-primary-light-9);
+    }
+  }
 }
 </style>

+ 18 - 2
src/components/FsImageUpload/props.ts

@@ -1,5 +1,7 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
+import type { CSSProperties } from 'vue'
 import type { UploadItem } from './types'
+import type { ProgressProps } from 'element-plus'
 export const imageUploadProps = {
   // 已上传列表
   modelValue: {
@@ -11,6 +13,8 @@ export const imageUploadProps = {
     type: Boolean,
     default: true
   },
+  // 是否启用拖拽上传
+  drag: Boolean,
   // 是否只读
   readonly: Boolean,
   // 是否禁用
@@ -35,10 +39,22 @@ export const imageUploadProps = {
     type: String,
     default: 'image/png,image/jpeg'
   },
+  // 文件大小限制(MB)
+  fileSize: {
+    type: Number,
+    default: 1024 * 5
+  },
   // item 样式
-  itemStyle: Object,
+  itemStyle: Object as PropType<string | CSSProperties>,
   // 上传按钮样式
-  buttonStyle: Object
+  buttonStyle: Object as PropType<string | CSSProperties>,
+  // 上传进度条配置
+  progressProps: Object as PropType<ProgressProps>,
+  // 上传方法  // ToDo: 上传是否可以使用utils 中的 oss 上传方法
+  uploadFunction: {
+    type: Function as PropType<(file: any) => Promise<any>>,
+    required: true
+  }
 }
 
 export type ImageUploadProps = ExtractPropTypes<typeof imageUploadProps>

+ 491 - 0
src/components/FsSplitPanel/index.vue

@@ -0,0 +1,491 @@
+<!-- 分割面板 -->
+<template>
+  <div
+    ref="rootRef"
+    :class="[
+      'split-panel',
+      { 'is-reverse': reverse },
+      { 'is-vertical': vertical },
+      { 'is-collapse': isCollapse },
+      { 'is-resizing': resizing },
+      { 'is-flex-table': flexTable }
+    ]"
+    :style="{
+      '--split-size': resizedSize ?? size,
+      '--split-space': space
+    }"
+  >
+    <!-- 侧边容器 -->
+    <div ref="wrapRef" class="split-panel-wrap">
+      <div ref="sideRef" class="split-panel-side" :style="customStyle">
+        <slot></slot>
+      </div>
+      <!-- 间距 -->
+      <div class="split-panel-space">
+        <div v-if="resizable" class="split-resize-line" @mousedown="onResize"></div>
+      </div>
+    </div>
+    <!-- 内容 -->
+    <div class="split-panel-body" :style="bodyStyle">
+      <slot name="body" :collapse="isCollapse"></slot>
+    </div>
+    <!-- 折叠按钮 -->
+    <div v-if="allowCollapse" :style="collapseStyle" class="split-collapse-button" @click="toggleCollapse()">
+      <slot name="collapse" :collapse="isCollapse">
+        <ElIcon class="split-collapse-icon">
+          <ArrowUp v-if="vertical" />
+          <ArrowLeft v-else />
+        </ElIcon>
+      </slot>
+    </div>
+    <!-- 小屏幕遮罩层 -->
+    <div class="split-panel-mask" @click="toggleCollapse()"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { splitPanelProps, splitPanelEmits } from './props'
+
+const props = defineProps(splitPanelProps)
+const emit = defineEmits(splitPanelEmits)
+
+// 根节点
+const rootRef = ref<HTMLElement | null>(null)
+
+// 侧边容器节点
+const wrapRef = ref<HTMLElement | null>(null)
+
+// 侧边节点
+const sideRef = ref<HTMLElement | null>(null)
+
+// 是否折叠
+const isCollapse = ref<boolean>(false)
+
+// 拉伸后尺寸
+const resizedSize = ref<string | null>(null)
+
+// 是否正在拉伸
+const resizing = ref<boolean>(false)
+
+/* 切换折叠状态 */
+const toggleCollapse = (collapse?: boolean) => {
+  isCollapse.value = typeof collapse === 'boolean' ? collapse : !isCollapse.value
+  emit('update:collapse', isCollapse.value)
+}
+
+/* 获取最大拉伸尺寸 */
+const getMaxSize = (el: HTMLElement) => {
+  const size = props.vertical ? el.clientHeight : el.clientWidth
+  if (!props.maxSize) {
+    return size
+  }
+  if (props.maxSize < 0) {
+    // 负值形式
+    return size + props.maxSize
+  } else if (props.maxSize < 1) {
+    // 百分比形式
+    return Math.floor(size * props.maxSize)
+  }
+  return Math.min(props.maxSize, size)
+}
+
+/* 拉伸 */
+const onResize = (event: MouseEvent) => {
+  const rootEl = rootRef.value
+  const sideEl = sideRef.value
+  if (!rootEl || !sideEl) {
+    return
+  }
+  resizing.value = true
+  // 获取原始位置
+  const downX = event.clientX
+  const downY = event.clientY
+  const downW = sideEl.clientWidth
+  const downH = sideEl.clientHeight
+  const limitMin = props.minSize || 0
+  const limitMax = getMaxSize(rootEl)
+
+  // 鼠标移动事件
+  const mousemoveFn = (e: MouseEvent) => {
+    const size = props.vertical
+      ? (props.reverse ? downY - e.clientY : e.clientY - downY) + downH
+      : (props.reverse ? downX - e.clientX : e.clientX - downX) + downW
+    resizedSize.value = (size < limitMin ? limitMin : size > limitMax ? limitMax : size) + 'px'
+  }
+
+  // 鼠标抬起事件
+  const mouseupFn = () => {
+    resizing.value = false
+    document.removeEventListener('mousemove', mousemoveFn)
+    document.removeEventListener('mouseup', mouseupFn)
+  }
+
+  // 添加鼠标事件监听
+  document.addEventListener('mousemove', mousemoveFn)
+  document.addEventListener('mouseup', mouseupFn)
+}
+
+watch(
+  [() => props.collapse, () => props.allowCollapse],
+  () => {
+    if (!props.allowCollapse) {
+      isCollapse.value = false
+    } else {
+      isCollapse.value = props.collapse
+    }
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.split-panel {
+  display: flex;
+  position: relative;
+  --split-size: 200px;
+  --split-space: 16px;
+
+  // 侧边容器
+  & > .split-panel-wrap {
+    flex-shrink: 0;
+    box-sizing: border-box;
+    width: calc(var(--split-size) + var(--split-space));
+    display: flex;
+    justify-content: flex-end;
+    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+    opacity: 1;
+
+    // 侧边
+    & > .split-panel-side {
+      flex-shrink: 0;
+      width: var(--split-size);
+      border: 1px solid var(--el-border-color-light);
+      box-sizing: border-box;
+      position: relative;
+    }
+
+    // 间距
+    & > .split-panel-space {
+      flex-shrink: 0;
+      width: var(--split-space);
+      box-sizing: border-box;
+      position: relative;
+
+      // 拉伸线
+      .split-resize-line {
+        width: 12px;
+        height: 100%;
+        position: absolute;
+        left: -6px;
+        z-index: 4;
+        cursor: e-resize;
+
+        &::after {
+          content: '';
+          width: 3px;
+          height: 100%;
+          display: block;
+          margin: 0 auto;
+        }
+
+        &:hover::after {
+          background: var(--el-color-primary);
+        }
+      }
+    }
+  }
+
+  // 内容
+  & > .split-panel-body {
+    flex: 1;
+    overflow: auto;
+    box-sizing: border-box;
+    position: relative;
+  }
+
+  // 折叠按钮
+  & > .split-collapse-button {
+    width: 24px;
+    height: 24px;
+    line-height: 24px;
+    text-align: center;
+    position: absolute;
+    left: var(--split-size);
+    top: 50%;
+    margin: -12px 0 0 -12px;
+    background: var(--el-bg-color-overlay);
+    box-shadow: 0 2px 3px 0px rgba(0, 0, 0, 0.04);
+    border: 1px solid var(--el-border-color-light);
+    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+    border-radius: 50%;
+    cursor: pointer;
+    z-index: 5;
+
+    .split-collapse-icon {
+      font-size: 16px;
+      vertical-align: -2px;
+      color: var(--el-color-info);
+      font-weight: bold;
+      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+      transform: scaleX(1);
+    }
+
+    &:hover .split-collapse-icon {
+      color: var(--el-color-primary);
+    }
+  }
+
+  // 折叠状态
+  &.is-collapse {
+    & > .split-panel-wrap {
+      width: 0 !important;
+      pointer-events: none;
+      opacity: 0;
+    }
+
+    & > .split-collapse-button {
+      left: 0;
+
+      .split-collapse-icon {
+        transform: scaleX(-1);
+      }
+    }
+  }
+
+  // 垂直
+  &.is-vertical {
+    flex-direction: column;
+
+    & > .split-panel-wrap {
+      flex-direction: column;
+      height: calc(var(--split-size) + var(--split-space));
+      width: auto;
+
+      & > .split-panel-side {
+        height: var(--split-size);
+        width: auto;
+      }
+
+      & > .split-panel-space {
+        height: var(--split-space);
+        width: auto;
+
+        .split-resize-line {
+          width: 100%;
+          height: 12px;
+          left: auto;
+          top: -6px;
+          cursor: n-resize;
+
+          &::after {
+            width: 100%;
+            height: 3px;
+            margin: 4px 0 0 0;
+          }
+        }
+      }
+    }
+
+    & > .split-collapse-button {
+      top: var(--split-size);
+      left: 50%;
+
+      .split-collapse-icon {
+        transform: scaleY(1);
+      }
+    }
+
+    &.is-collapse {
+      & > .split-panel-wrap {
+        width: auto !important;
+        height: 0 !important;
+      }
+
+      & > .split-collapse-button {
+        top: 0;
+
+        .split-collapse-icon {
+          transform: scaleY(-1);
+        }
+      }
+    }
+  }
+
+  // 反向
+  &.is-reverse {
+    flex-direction: row-reverse;
+
+    & > .split-panel-wrap {
+      flex-direction: row-reverse;
+
+      & > .split-panel-space .split-resize-line {
+        left: auto;
+        right: -6px;
+      }
+    }
+
+    & > .split-collapse-button {
+      left: auto;
+      right: var(--split-size);
+      margin: -12px -12px 0 0;
+
+      .split-collapse-icon {
+        transform: scaleX(-1);
+      }
+    }
+
+    &.is-collapse > .split-collapse-button {
+      right: 0;
+
+      .split-collapse-icon {
+        transform: scaleX(1);
+      }
+    }
+
+    &.is-vertical {
+      flex-direction: column-reverse;
+
+      & > .split-panel-wrap {
+        flex-direction: column-reverse;
+
+        & > .split-panel-space .split-resize-line {
+          top: auto;
+          right: auto;
+          bottom: -6px;
+        }
+      }
+
+      & > .split-collapse-button {
+        left: 50%;
+        top: auto;
+        bottom: var(--split-size);
+        margin: 0 0 -12px -12px;
+
+        .split-collapse-icon {
+          transform: scaleY(-1);
+        }
+      }
+
+      &.is-collapse > .split-collapse-button {
+        bottom: 0;
+
+        .split-collapse-icon {
+          transform: scaleY(1);
+        }
+      }
+    }
+  }
+
+  // 拉伸状态
+  &.is-resizing {
+    user-select: none;
+
+    & > .split-panel-wrap,
+    & > .split-collapse-button {
+      transition: none;
+    }
+  }
+
+  // 遮罩层
+  .split-panel-mask {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    opacity: 0;
+    backdrop-filter: blur(6px);
+    background: rgba(158, 158, 158, 0.2);
+    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+    pointer-events: none;
+    display: none;
+    z-index: 2;
+  }
+
+  // 内部表格弹性布局
+  &.is-flex-table {
+    flex: 1;
+    overflow: auto;
+
+    & > .split-panel-body,
+    & > .split-panel-wrap > .split-panel-side {
+      display: flex;
+      flex-direction: column;
+
+      & > .pro-table {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        overflow: auto;
+
+        & > .el-table {
+          flex: 1;
+          height: 100%;
+        }
+      }
+    }
+  }
+}
+
+/* 小屏幕样式 */
+@media screen and (max-width: 768px) {
+  .split-panel.is-responsive:not(.is-vertical) {
+    &:not(.is-collapse) {
+      overflow: hidden !important;
+    }
+
+    & > .split-panel-wrap {
+      position: absolute;
+      top: 0;
+      left: 0;
+      bottom: 0;
+
+      & > .split-panel-side {
+        background: var(--el-bg-color-overlay);
+        border: none;
+        z-index: 3;
+      }
+    }
+
+    & > .split-panel-body {
+      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+    }
+
+    & > .split-panel-mask {
+      display: block;
+    }
+
+    &:not(.is-collapse) {
+      & > .split-panel-mask {
+        left: var(--split-size);
+        pointer-events: all;
+        opacity: 1;
+      }
+
+      & > .split-panel-body {
+        transform: translateX(calc(var(--split-size) + var(--split-space)));
+        z-index: 1;
+      }
+    }
+
+    // 反向
+    &.is-reverse {
+      & > .split-panel-wrap {
+        right: 0;
+        left: auto;
+      }
+
+      &:not(.is-collapse) {
+        & > .split-panel-mask {
+          left: 0;
+          right: var(--split-size);
+        }
+
+        & > .split-panel-body {
+          transform: translateX(calc(0px - var(--split-size) - var(--split-space)));
+        }
+      }
+    }
+  }
+}
+</style>

+ 50 - 0
src/components/FsSplitPanel/props.ts

@@ -0,0 +1,50 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+
+import type { CSSProperties } from 'vue'
+
+/**
+ * 属性
+ */
+export const splitPanelProps = {
+  // 默认大小
+  size: String,
+  // 最小尺寸
+  minSize: Number,
+  // 最大尺寸
+  maxSize: Number,
+  // 间距
+  space: String,
+  // 自定义样式
+  customStyle: Object as PropType<string | CSSProperties>,
+  // 自定义内容样式
+  bodyStyle: Object as PropType<string | CSSProperties>,
+  // 是否可折叠
+  allowCollapse: Boolean,
+  // 折叠按钮样式
+  collapseStyle: Object,
+  // 是否折叠
+  collapse: Boolean,
+  // 是否垂直方向
+  vertical: Boolean,
+  // 是否反向布局
+  reverse: Boolean,
+  // 是否可拉伸宽度
+  resizable: Boolean,
+  // 内部表格弹性布局
+  flexTable: Boolean,
+  // 是否开启响应式
+  responsive: {
+    type: Boolean,
+    default: null
+  }
+}
+
+export type SplitPanelProps = ExtractPropTypes<typeof splitPanelProps>
+
+/**
+ * 事件
+ */
+export const splitPanelEmits = {
+  // 更新折叠状态
+  'update:collapse': (_collapse: boolean) => true
+}

+ 190 - 213
src/components/FsTableSelect/index.vue

@@ -1,244 +1,221 @@
-<script lang="ts">
+<script setup lang="ts">
 import { tableSelectProps, tableSelectEmits } from './props'
 import { ElPopover } from 'element-plus'
 import type { VxeTableInstance } from 'vxe-table'
-export default defineComponent({
-  name: 'FsTableSelect',
-  props: tableSelectProps,
-  emits: tableSelectEmits,
-  setup(props, { emit }) {
-    // 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 pageIndex = ref<number>(1)
 
-    // 是否未选中
-    const isEmpty = computed<boolean>(() => {
-      if (!props.multiple) {
-        return props.modelValue == null || props.modelValue === ''
-      }
-      return !Array.isArray(props.modelValue) || !props.modelValue.length
-    })
+const emit = defineEmits(tableSelectEmits)
+const props = defineProps(tableSelectProps)
 
-    // 是否需要清空图标
-    const closeEnable = computed<boolean>(() => {
-      return props.clearable && !props.disabled && !isEmpty.value
-    })
+// 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 pageIndex = ref<number>(1)
 
-    // 多选显示的标签
-    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 isEmpty = computed<boolean>(() => {
+  if (!props.multiple) {
+    return props.modelValue == null || props.modelValue === ''
+  }
+  return !Array.isArray(props.modelValue) || !props.modelValue.length
+})
 
-    onMounted(() => {
-      if (props.initValue) {
-        initValueChange(props.initValue)
-      }
-    })
+// 是否需要清空图标
+const closeEnable = computed<boolean>(() => {
+  return props.clearable && !props.disabled && !isEmpty.value
+})
 
-    /* 打开弹窗 */
-    const onFocus = (e: FocusEvent) => {
-      if (props.automaticDropdown && !visible.value) {
-        visible.value = true
-      }
-      emit('focus', e)
-    }
+// 多选显示的标签
+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 onBlur = (e: FocusEvent) => {
-      emit('blur', e)
-    }
+onMounted(() => {
+  if (props.initValue) {
+    initValueChange(props.initValue)
+  }
+})
 
-    /* 清除事件 */
-    const onClear = () => {
-      updateModelValue(props.multiple ? [] : null)
-      selectLabel.value = ''
-      // 取消表格全部选中
-      tableRef.value?.clearCheckboxRow()
-      emit('clear')
-    }
+/* 打开弹窗 */
+const onFocus = (e: FocusEvent) => {
+  if (props.automaticDropdown && !visible.value) {
+    visible.value = true
+  }
+  emit('focus', e)
+}
 
-    /* 单个清除事件 */
-    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
+/* 关闭弹窗 */
+const onBlur = (e: FocusEvent) => {
+  emit('blur', e)
+}
 
-      // 取消表格选中数据
-      tableRef.value?.toggleCheckboxRow(item)
-      updateModelValue(list.map(x => x[props.valueKey]))
-      emit('item-clear', { item, list })
-    }
+/* 清除事件 */
+const onClear = () => {
+  updateModelValue(props.multiple ? [] : null)
+  selectLabel.value = ''
+  // 取消表格全部选中
+  tableRef.value?.clearCheckboxRow()
+  emit('clear')
+}
 
-    /* 表格单选事件 */
-    const tableRadioChange = (data: any) => {
-      selectLabel.value = data.row[props.labelKey]
-      visible.value = false
-      // 发出选择事件
-      updateModelValue(data.row[props.valueKey])
-      emit('change', data.row)
-    }
+/* 单个清除事件 */
+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
 
-    /* 表格多选择事件 */
-    const tableCheckboxChange = (data: any) => {
-      let result = []
-      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 = selectLabel.value.map(x => x[props.valueKey])
-      } 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.map(x => x[props.valueKey])
-      }
-      // 发出选择事件
-      updateModelValue(result)
-      emit('change', selectLabel.value)
-    }
+  // 取消表格选中数据
+  tableRef.value?.toggleCheckboxRow(item)
+  updateModelValue(list.map(x => x[props.valueKey]))
+  emit('item-clear', { item, list })
+}
 
-    /* initValue 改变 */
-    const initValueChange = (value: any | Array<any>) => {
-      if (props.initValue) {
-        // 处理回显数据
-        if (props.multiple) {
-          selectLabel.value = value as Array<any>
-        } else {
-          selectLabel.value = value[props.labelKey] as any
-        }
-        updateModelValue(props.multiple ? value.map((x: any) => x[props.valueKey]) : value[props.valueKey])
-      }
-    }
+/* 表格单选事件 */
+const tableRadioChange = (data: any) => {
+  selectLabel.value = data.row[props.labelKey]
+  visible.value = false
+  // 发出选择事件
+  updateModelValue(data.row[props.valueKey])
+  emit('change', data.row)
+}
 
-    /* 分页改变事件 */
-    const paginationChange = (data: number) => {
-      pageIndex.value = data
-      request()
-    }
+/* 表格多选择事件 */
+const tableCheckboxChange = (data: any) => {
+  let result = []
+  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 = selectLabel.value.map(x => x[props.valueKey])
+  } 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.map(x => x[props.valueKey])
+  }
+  // 发出选择事件
+  updateModelValue(result)
+  emit('change', selectLabel.value)
+}
 
-    /* 重新加载表格 */
-    const reload = (where: any) => {
-      request(where)
+/* initValue 改变 */
+const initValueChange = (value: any | Array<any>) => {
+  if (props.initValue) {
+    // 处理回显数据
+    if (props.multiple) {
+      selectLabel.value = value as Array<any>
+    } else {
+      selectLabel.value = value[props.labelKey] as any
     }
+    updateModelValue(props.multiple ? value.map((x: any) => x[props.valueKey]) : value[props.valueKey])
+  }
+}
 
-    /* 表格请求完成 */
-    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
-            temp && tableRef.value?.setRadioRow(item)
-          }
-        })
-      })
-    }
+/* 分页改变事件 */
+const paginationChange = (data: number) => {
+  pageIndex.value = data
+  request()
+}
 
-    /* 请求数据 */
-    const request = (where?: any) => {
-      console.log(where)
-      if (typeof props.tableConfig?.datasource === 'function') {
-        loading.value = true
-        if (where && where.pageIndex) {
-          pageIndex.value = where.pageIndex
-        }
-        props.tableConfig
-          .datasource({
-            pageIndex: pageIndex.value,
-            pageSize: props.tableConfig.pageSize,
-            ...where
-          })
-          .then((res: any) => {
-            tableData.value = res
-            tableDone()
-          })
-          .catch((e: any) => {
-            console.warn(e)
-          })
-          .finally(() => {
-            loading.value = false
-          })
+/* 重新加载表格 */
+const reload = (where: any) => {
+  request(where)
+}
+
+/* 表格请求完成 */
+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 {
-        console.warn('tableConfig.datasource 必须为 Promise')
+        const temp = item[props.valueKey] === props.modelValue
+        temp && tableRef.value?.setRadioRow(item)
       }
-    }
-    request()
+    })
+  })
+}
 
-    /* 更新选中值 */
-    const updateModelValue = (value: any) => {
-      emit('update:modelValue', value)
+/* 请求数据 */
+const request = (where?: any) => {
+  if (typeof props.tableConfig?.datasource === 'function') {
+    loading.value = true
+    if (where && where.pageIndex) {
+      pageIndex.value = where.pageIndex
     }
+    props.tableConfig
+      .datasource({
+        pageIndex: pageIndex.value,
+        pageSize: props.tableConfig.pageSize,
+        ...where
+      })
+      .then((res: any) => {
+        tableData.value = res
+        tableDone()
+      })
+      .catch((e: any) => {
+        console.warn(e)
+      })
+      .finally(() => {
+        loading.value = false
+      })
+  } else {
+    console.warn('tableConfig.datasource 必须为 Promise')
+  }
+}
+request()
 
-    /* 更新气泡位置 */
-    watch(currentValues, () => {
-      if (
-        popoverRef.value &&
-        popoverRef.value.popperRef &&
-        popoverRef.value.popperRef.popperInstanceRef &&
-        popoverRef.value.popperRef.popperInstanceRef.update
-      ) {
-        popoverRef.value.popperRef.popperInstanceRef.update()
-      }
-    })
+/* 更新选中值 */
+const updateModelValue = (value: any) => {
+  emit('update:modelValue', value)
+}
 
-    watch(
-      () => props.initValue,
-      () => {
-        initValueChange(props.initValue as object | Array<any>)
-      }
-    )
+/* 更新气泡位置 */
+watch(currentValues, () => {
+  if (
+    popoverRef.value &&
+    popoverRef.value.popperRef &&
+    popoverRef.value.popperRef.popperInstanceRef &&
+    popoverRef.value.popperRef.popperInstanceRef.update
+  ) {
+    popoverRef.value.popperRef.popperInstanceRef.update()
+  }
+})
 
-    return {
-      popoverRef,
-      tableRef,
-      selectLabel,
-      visible,
-      isEmpty,
-      pageIndex,
-      loading,
-      currentValues,
-      omittedValues,
-      tableData,
-      closeEnable,
-      onFocus,
-      onBlur,
-      onClear,
-      onItemClear,
-      tableRadioChange,
-      tableCheckboxChange,
-      paginationChange,
-      reload
-    }
+watch(
+  () => props.initValue,
+  () => {
+    initValueChange(props.initValue as object | Array<any>)
   }
+)
+
+defineExpose({
+  reload
 })
 </script>
 

+ 1 - 2
src/components/FsTableSelect/props.ts

@@ -39,7 +39,7 @@ export const tableSelectProps = {
   initValue: [Object, Array],
   // 气泡位置
   placement: {
-    type: String,
+    type: String as PropType<PopoverProps['placement']>,
     default: 'bottom-start'
   },
   // 是否在输入框获得焦点后自动弹出选项菜单
@@ -64,7 +64,6 @@ export const tableSelectProps = {
     default: {
       columns: [],
       loading: false,
-      data: [],
       total: 0
     }
   },

+ 1 - 1
src/components/FsTableSelect/types/index.ts

@@ -5,7 +5,7 @@ export interface TableConfig {
   // 表格数据
   datasource: (...args: any) => Promise<any>
   // 总数
-  total?: Number
+  total?: Number // ToDo: 表格分页时,total是否可以从datasource中获取
   // 每页显示条数
   pageSize?: Number
 }

+ 186 - 0
src/hooks/useExcel.ts

@@ -0,0 +1,186 @@
+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 二进制数据
+   * @param name 文件名
+   * @param type 文件类型
+   */
+  const download = (data: Blob | ArrayBuffer | string, name: string, type?: string) => {
+    const blob = new Blob([data], { type: type || 'application/octet-stream' })
+    const 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 }
+}

+ 27 - 0
src/router/asyncRouter.ts

@@ -28,6 +28,33 @@ const asyncRouter: RouteRecordRaw[] = [
       icon: 'MoreFilled'
     }
   },
+  {
+    path: '/splitePanel',
+    name: 'splitePanel',
+    component: () => import('@/views/splitePanel/index.vue'),
+    meta: {
+      title: '分割面板',
+      icon: 'MoreFilled'
+    }
+  },
+  {
+    path: '/excel',
+    name: 'excel',
+    component: () => import('@/views/excel/index.vue'),
+    meta: {
+      title: '导入导出Excel',
+      icon: 'MoreFilled'
+    }
+  },
+  {
+    path: '/checkCard',
+    name: 'checkCard',
+    component: () => import('@/views/checkCard/index.vue'),
+    meta: {
+      title: '可选卡片',
+      icon: 'MoreFilled'
+    }
+  },
 
   // -- APPEND HERE --
   {

+ 51 - 0
src/views/checkCard/index.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import FsCheckCard from '@/components/FsCheckCard/index.vue'
+import type { CheckCardItem } from '@/components/FsCheckCard/types'
+
+const select = ref('')
+
+const items = ref<CheckCardItem[]>([
+  {
+    value: 'Vue',
+    label: 'Vue',
+    checked: true,
+    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 是一套用于构建用户界面的渐进式框架。'
+  }
+])
+</script>
+
+<template>
+  <el-card shadow="never">
+    <fs-check-card v-model="select" :items="items" bordered>
+      <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>
+</template>
+
+<style scoped lang="scss"></style>

+ 156 - 0
src/views/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(() => {
+      console.log()
+    })
+    .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/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/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>

+ 69 - 2
src/views/imageUpload/index.vue

@@ -1,11 +1,78 @@
 <script setup lang="ts">
+import { ElMessage } from 'element-plus'
 import ImageUpload from '@/components/FsImageUpload/index.vue'
 const images = ref([])
+// 手动上传数据
+const images2 = ref([])
+const images3 = ref([])
+const imageUploadRef = ref()
+
+const isReload = 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
+  }
+}
 </script>
 
 <template>
-  <el-card header="图片上传" shadow="never">
-    <ImageUpload v-model="images" multiple :limit="5" />
+  <el-card header="基础示例" shadow="never">
+    <ImageUpload
+      v-model="images"
+      :readonly="isReload"
+      :limit="5"
+      :uploadFunction="uploadFunction"
+      drag
+      @remove="onRemove"
+    />
+    <div class="flex items-center mt-2">
+      <div>是否只读:</div>
+      <el-radio-group v-model="isReload">
+        <el-radio label="是" :value="true"> </el-radio>
+        <el-radio label="否" :value="false"> </el-radio>
+      </el-radio-group>
+    </div>
+  </el-card>
+  <el-card header="支持多选" shadow="never" class="mt-3">
+    <ImageUpload v-model="images2" multiple :limit="5" :uploadFunction="uploadFunction" @remove="onRemove" />
+  </el-card>
+  <el-card header="基础示例" shadow="never" class="mt-3">
+    <ImageUpload
+      v-model="images3"
+      :auto-upload="false"
+      :limit="5"
+      :uploadFunction="uploadFunction"
+      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>
 

+ 151 - 0
src/views/splitePanel/index.vue

@@ -0,0 +1,151 @@
+<script setup>
+import FsSplitPanel from '@/components/FsSplitPanel/index.vue'
+
+// 是否显示折叠按钮
+const allowCollapse = ref(true)
+
+// 是否支持自由拉伸
+const resizable = ref(false)
+
+// 是否上下布局模式
+const vertical = ref(false)
+
+// 是否反转布局方向
+const reverse = ref(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>
+    <fs-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>
+    </fs-split-panel>
+  </el-card>
+  <el-card shadow="never" class="mt-5">
+    <div style="margin: 0 0 8px 0">先左右再上下</div>
+    <fs-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>
+        <fs-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>
+        </fs-split-panel>
+      </template>
+    </fs-split-panel>
+    <div style="margin: 16px 0 8px 0">先上下再左右</div>
+    <fs-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>
+        <fs-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>
+        </fs-split-panel>
+      </template>
+    </fs-split-panel>
+  </el-card>
+</template>
+
+<style scoped></style>

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