Browse Source

新增卡片、打印

XueNing 6 months ago
parent
commit
b9244f09b4

BIN
public/test.pdf


+ 2 - 0
src/components.d.ts

@@ -16,8 +16,10 @@ declare module 'vue' {
     Exception: typeof import('./components/Exception.vue')['default']
     FsCheckCard: typeof import('./components/FsCheckCard/index.vue')['default']
     FsImageUpload: typeof import('./components/FsImageUpload/index.vue')['default']
+    FsPrinter: typeof import('./components/FsPrinter/index.vue')['default']
     FsSplitPanel: typeof import('./components/FsSplitPanel/index.vue')['default']
     FsTableSelect: typeof import('./components/FsTableSelect/index.vue')['default']
+    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']

+ 25 - 5
src/components/FsCheckCard/components/CardItem.vue

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

+ 60 - 4
src/components/FsCheckCard/index.vue

@@ -1,8 +1,54 @@
 <script setup lang="ts">
-import { checkCardProps } from './props'
+import { checkCardProps, checkCardEmits } from './props'
+import type { CheckCardItem } from './types'
 import CardItem from './components/CardItem.vue'
 
-defineProps(checkCardProps)
+const props = defineProps(checkCardProps)
+const emit = defineEmits(checkCardEmits)
+
+const onItemClick = (item: CheckCardItem) => {
+  // 是否禁用
+  if (props.disabled || item.disabled) {
+    return
+  }
+
+  // 是否多选
+  if (props.multiple) {
+    item.checked = !item.checked
+    const select = props.items?.filter(x => x.checked)
+    updateModelValue(select ? select.map(x => x.value) : null)
+  } else {
+    // 单选
+    const select = props.items?.find(x => x.checked)
+    select && (select.checked = false)
+    item.checked = !item.checked
+    updateModelValue((item.checked ? item.value : '') as any)
+  }
+}
+
+/* 修改modelValue */
+const updateModelValue = (modelValue: any) => {
+  emit('update:modelValue', modelValue)
+}
+// 分割的字符串 1,2,3,45,6
+watch(
+  () => props.modelValue,
+  () => {
+    if (props.modelValue) {
+      if (Array.isArray(props.modelValue)) {
+        props.items?.forEach(item => {
+          item.checked = (props.modelValue as Array<any>).includes(item.value)
+        })
+      } else {
+        const select = props.items?.find(x => x.value === props.modelValue)
+        select && (select.checked = true)
+      }
+    }
+  },
+  {
+    immediate: true
+  }
+)
 </script>
 
 <template>
@@ -14,9 +60,12 @@ defineProps(checkCardProps)
         :disabled="disabled || item.disabled"
         :bordered="bordered || item.bordered"
         :arrow="arrow"
+        @click="onItemClick(item)"
       >
         <template #default="slotProps">
-          <slot name="item" v-bind="slotProps || {}">{{ item.value }}</slot>
+          <slot name="item" v-bind="slotProps || {}">
+            <span class="item-text">{{ item.value }}</span>
+          </slot>
         </template>
       </CardItem>
     </el-col>
@@ -31,9 +80,12 @@ defineProps(checkCardProps)
         :disabled="disabled || item.disabled"
         :bordered="bordered || item.bordered"
         :arrow="arrow"
+        @click="onItemClick(item)"
       >
         <template #default="slotProps">
-          <slot name="item" v-bind="slotProps || {}">{{ item.value }}</slot>
+          <slot name="item" v-bind="slotProps || {}">
+            <span class="item-text">{{ item.value }}</span>
+          </slot>
         </template>
       </CardItem>
     </div>
@@ -45,4 +97,8 @@ defineProps(checkCardProps)
   display: flex;
   flex-wrap: wrap;
 }
+.item-text {
+  display: inline-block;
+  padding: 5px 8px 3px 8px;
+}
 </style>

+ 10 - 3
src/components/FsCheckCard/props.ts

@@ -1,10 +1,11 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
 import type { RowProps } from 'element-plus'
 import type { CheckCardItem } from './types'
 
 export const checkCardProps = {
   // 选中值
   modelValue: {
-    type: [Array, String, Number, Boolean],
+    type: Object, // ToDo
     default: () => {
       return null
     }
@@ -27,7 +28,13 @@ export const checkCardProps = {
   },
   // 是否使用栅格布局
   row: {
-    type: [Boolean, Object] as PropType<boolean | RowProps>,
-    default: true
+    type: [Boolean, Object] as PropType<boolean | Partial<RowProps>>,
+    default: false
   }
 }
+
+/* 事件 */
+export const checkCardEmits = {
+  // 修改modelValue
+  'update:modelValue': (_value: Array<any> | string | number | null) => true
+}

+ 3 - 9
src/components/FsCheckCard/types/index.ts

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

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

@@ -40,10 +40,6 @@ const onUpload = (file: File) => {
     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
@@ -61,7 +57,7 @@ const onUpload = (file: File) => {
   images.value.push(item)
   // 是否自动上传
   if (props.autoUpload) {
-    uploadItem(images.value.at(-1) as UploadItem)
+    uploadItem(item)
   }
   return false
 }
@@ -97,12 +93,14 @@ const onRetry = (index: number) => {
 
 /* 修改modelValue */
 const updateModelValue = (items: UploadItem[]) => {
+  // ToDo:这里需要优化,最后格式为'url1,url2,url3'
   emit('update:modelValue', items)
 }
 
 watch(
   () => props.modelValue,
   () => {
+    // ToDo:这里需要优化,需要自行处理格式
     images.value = props.modelValue || []
   },
   { immediate: true }

+ 11 - 9
src/components/FsImageUpload/props.ts

@@ -2,6 +2,8 @@
 import type { CSSProperties } from 'vue'
 import type { UploadItem } from './types'
 import type { ProgressProps } from 'element-plus'
+
+// ToDo 默认方式改为withDefaults
 export const imageUploadProps = {
   // 已上传列表
   modelValue: {
@@ -27,14 +29,14 @@ export const imageUploadProps = {
   // 可上传数量
   limit: {
     type: Number,
-    default: 5
+    default: 9
   },
   // 是否支持多选
   multiple: {
     type: Boolean,
     default: false
   },
-  // 上传类型
+  // 上传类型 ToDo: 图片类型去掉,内部自行限制是否是图片
   accept: {
     type: String,
     default: 'image/png,image/jpeg'
@@ -42,18 +44,18 @@ export const imageUploadProps = {
   // 文件大小限制(MB)
   fileSize: {
     type: Number,
-    default: 1024 * 5
+    default: 5
   },
-  // item 样式
-  itemStyle: Object as PropType<string | CSSProperties>,
+  // item 样式  Todo: 改为 width height iconStyle
+  itemStyle: Object as PropType<Partial<CSSProperties> | Array<Partial<CSSProperties>>>,
   // 上传按钮样式
-  buttonStyle: Object as PropType<string | CSSProperties>,
+  buttonStyle: Object as PropType<Partial<CSSProperties> | Array<Partial<CSSProperties>>>,
   // 上传进度条配置
   progressProps: Object as PropType<ProgressProps>,
-  // 上传方法  // ToDo: 上传是否可以使用utils 中的 oss 上传方法
+  // 上传方法
+  // ToDo: 结合项目中cofing文件的文件路径实现,增加上传文件key属性
   uploadFunction: {
-    type: Function as PropType<(file: any) => Promise<any>>,
-    required: true
+    type: Function as PropType<(file: any) => Promise<any>>
   }
 }
 

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

@@ -0,0 +1,177 @@
+<!-- 打印 -->
+<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>
+
+<script setup lang="ts">
+import { getPrintContainer, doPrint, doPrintOnFrame, mergeOptions, usePrinter } from './util'
+import type { PrintOption } from './types'
+import { printerProps, printerEmits } from './props'
+
+const props = defineProps(printerProps)
+
+const emit = 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 = () => {
+  emit('done')
+}
+
+/** 更新绑定值 */
+const updateModelValue = (value: boolean) => {
+  emit('update:modelValue', value)
+}
+
+watch(
+  () => props.modelValue,
+  value => {
+    if (value) {
+      print()
+    }
+  },
+  { immediate: true }
+)
+
+defineExpose({
+  print
+})
+</script>
+
+<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>

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

@@ -0,0 +1,43 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { PropType, ExtractPropTypes, CSSProperties } from 'vue'
+import type { PrintDirection, PrintOrientation, PrintTarget } from './types'
+
+/**
+ * 属性
+ */
+export const printerProps = {
+  // 是否打印
+  modelValue: Boolean,
+  // 页眉样式
+  headerStyle: Object as PropType<Partial<CSSProperties> | Array<Partial<CSSProperties>>>,
+  // 内容样式
+  bodyStyle: Object as PropType<Partial<CSSProperties> | Array<Partial<CSSProperties>>>,
+  // 页脚样式
+  footerStyle: Object as PropType<Partial<CSSProperties> | Array<Partial<CSSProperties>>>,
+  // 标题
+  title: String,
+  // 页间距
+  margin: [String, Number],
+  // 纸张方向
+  direction: String as PropType<PrintDirection | null>,
+  // 纸张旋转
+  orientation: String as PropType<PrintOrientation | null>,
+  // 打印位置
+  target: String as PropType<PrintTarget | null>,
+  // 是否显示在文档流中
+  static: Boolean,
+  // 打印方法参数
+  options: Object
+}
+
+export type PrinterProps = ExtractPropTypes<typeof printerProps>
+
+/**
+ * 事件
+ */
+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()
+  }
+}

+ 1 - 2
src/components/FsSplitPanel/index.vue

@@ -7,8 +7,7 @@
       { 'is-reverse': reverse },
       { 'is-vertical': vertical },
       { 'is-collapse': isCollapse },
-      { 'is-resizing': resizing },
-      { 'is-flex-table': flexTable }
+      { 'is-resizing': resizing }
     ]"
     :style="{
       '--split-size': resizedSize ?? size,

+ 3 - 10
src/components/FsSplitPanel/props.ts

@@ -15,9 +15,9 @@ export const splitPanelProps = {
   // 间距
   space: String,
   // 自定义样式
-  customStyle: Object as PropType<string | CSSProperties>,
+  customStyle: Object as PropType<Partial<CSSProperties> | Array<Partial<CSSProperties>>>,
   // 自定义内容样式
-  bodyStyle: Object as PropType<string | CSSProperties>,
+  bodyStyle: Object as PropType<Partial<CSSProperties> | Array<Partial<CSSProperties>>>,
   // 是否可折叠
   allowCollapse: Boolean,
   // 折叠按钮样式
@@ -29,14 +29,7 @@ export const splitPanelProps = {
   // 是否反向布局
   reverse: Boolean,
   // 是否可拉伸宽度
-  resizable: Boolean,
-  // 内部表格弹性布局
-  flexTable: Boolean,
-  // 是否开启响应式
-  responsive: {
-    type: Boolean,
-    default: null
-  }
+  resizable: Boolean
 }
 
 export type SplitPanelProps = ExtractPropTypes<typeof splitPanelProps>

+ 11 - 11
src/components/FsTableSelect/index.vue

@@ -2,7 +2,10 @@
 import { tableSelectProps, tableSelectEmits } from './props'
 import { ElPopover } from 'element-plus'
 import type { VxeTableInstance } from 'vxe-table'
-
+// ToDo: emit => emits
+// ToDo: modelvalue => {key, value}
+// 提供一个转换数据的方法 transformData 默认json => string
+// 提交数据转换 submitData => json
 const emit = defineEmits(tableSelectEmits)
 const props = defineProps(tableSelectProps)
 
@@ -24,7 +27,7 @@ const pageIndex = ref<number>(1)
 // 是否未选中
 const isEmpty = computed<boolean>(() => {
   if (!props.multiple) {
-    return props.modelValue == null || props.modelValue === ''
+    return !!props.modelValue
   }
   return !Array.isArray(props.modelValue) || !props.modelValue.length
 })
@@ -50,16 +53,12 @@ const omittedValues = computed(() => {
 })
 
 onMounted(() => {
-  if (props.initValue) {
-    initValueChange(props.initValue)
-  }
+  initValueChange(props.initValue)
 })
 
 /* 打开弹窗 */
 const onFocus = (e: FocusEvent) => {
-  if (props.automaticDropdown && !visible.value) {
-    visible.value = true
-  }
+  visible.value = true
   emit('focus', e)
 }
 
@@ -121,7 +120,7 @@ const tableCheckboxChange = (data: any) => {
 }
 
 /* initValue 改变 */
-const initValueChange = (value: any | Array<any>) => {
+const initValueChange = (value: any) => {
   if (props.initValue) {
     // 处理回显数据
     if (props.multiple) {
@@ -139,7 +138,7 @@ const paginationChange = (data: number) => {
   request()
 }
 
-/* 重新加载表格 */
+/* 重新加载表格 ToDo:直接使用request */
 const reload = (where: any) => {
   request(where)
 }
@@ -166,6 +165,7 @@ const request = (where?: any) => {
   if (typeof props.tableConfig?.datasource === 'function') {
     loading.value = true
     if (where && where.pageIndex) {
+      // ToDo: pageIndex => pageNo
       pageIndex.value = where.pageIndex
     }
     props.tableConfig
@@ -175,7 +175,7 @@ const request = (where?: any) => {
         ...where
       })
       .then((res: any) => {
-        tableData.value = res
+        tableData.value = res.infos || res.list || res.records || res.data || res.info
         tableDone()
       })
       .catch((e: any) => {

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

@@ -35,15 +35,13 @@ export const tableSelectProps = {
     type: String,
     default: 'name'
   },
-  // 回显数据,用于后分页显示
+  // 回显数据,用于后分页显示
   initValue: [Object, Array],
   // 气泡位置
   placement: {
     type: String as PropType<PopoverProps['placement']>,
     default: 'bottom-start'
   },
-  // 是否在输入框获得焦点后自动弹出选项菜单
-  automaticDropdown: Boolean,
   // 占位符
   placeholder: {
     type: String,
@@ -64,7 +62,7 @@ export const tableSelectProps = {
     default: {
       columns: [],
       loading: false,
-      total: 0
+      total: 0 // total || totalCount
     }
   },
   // tag 类型

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

@@ -0,0 +1,271 @@
+<!-- 漫游式引导 -->
+<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>
+
+<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 { tourProps, tourEmits } from './props'
+
+const props = defineProps(tourProps)
+
+const emit = 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%'
+    }
+  }
+  // 显示
+  visible.value = true
+  tooltipProps.value = getPopperProps(true, !el, popoverProps as any)
+  nextTick(() => {
+    updatePopper()
+  })
+}
+
+/** 关闭引导 */
+const close = () => {
+  visible.value = false
+  boxStyle.value = {}
+  step.value = null
+  showMask.value = false
+  tooltipProps.value = getPopperProps()
+}
+
+/** 更新步骤值 */
+const updateModelValue = (value?: number | null) => {
+  emit('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>
+
+<style scoped 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 {
+  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>

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

@@ -0,0 +1,36 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { TourStep } from './types'
+
+/**
+ * 属性
+ */
+export const tourProps = {
+  /** 当前处于第几步 */
+  modelValue: Number,
+  /** 步骤 */
+  steps: {
+    type: Array as PropType<TourStep[]>,
+    required: true
+  },
+  /** 是否开启遮罩层 */
+  mask: {
+    type: Boolean,
+    default: true
+  },
+  /** 高亮区内间距 */
+  padding: {
+    type: Number,
+    default: 6
+  },
+  /** 层级 */
+  zIndex: Number
+}
+
+export type TourProps = ExtractPropTypes<typeof tourProps>
+
+/**
+ * 事件
+ */
+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: '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' })
+}

+ 2 - 2
src/hooks/useExcel.ts

@@ -80,7 +80,7 @@ export const useExcel = () => {
   }
 
   /**
-   * 导出excel
+   * 导出excel ToDo:增加传入接口地址选项,如果存在接口地址,则直接请求接口
    */
   const exportExcel = (options: IExcel) => {
     return new Promise((resolve, reject) => {
@@ -164,7 +164,7 @@ export const useExcel = () => {
   }
 
   /**
-   * 下载文件
+   * 下载文件  ToDo:如果传入是http地址,则直接下载
    * @param data 二进制数据
    * @param name 文件名
    * @param type 文件类型

+ 18 - 1
src/router/asyncRouter.ts

@@ -55,7 +55,24 @@ const asyncRouter: RouteRecordRaw[] = [
       icon: 'MoreFilled'
     }
   },
-
+  {
+    path: '/printer',
+    name: 'printer',
+    component: () => import('@/views/printer/index.vue'),
+    meta: {
+      title: '打印组件',
+      icon: 'MoreFilled'
+    }
+  },
+  {
+    path: '/tour',
+    name: 'tour',
+    component: () => import('@/views/tour/index.vue'),
+    meta: {
+      title: '引导组件',
+      icon: 'MoreFilled'
+    }
+  },
   // -- APPEND HERE --
   {
     path: 'https://jijian.sxidc.com/',

+ 149 - 4
src/views/checkCard/index.vue

@@ -2,13 +2,61 @@
 import FsCheckCard from '@/components/FsCheckCard/index.vue'
 import type { CheckCardItem } from '@/components/FsCheckCard/types'
 
-const select = ref('')
+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',
-    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 和原生交互界面的库',
+    disabled: true
+  },
+  {
+    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 是一套用于构建用户界面的渐进式框架。'
@@ -28,11 +76,40 @@ const items = ref<CheckCardItem[]>([
     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">
-    <fs-check-card v-model="select" :items="items" bordered>
+  <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>
@@ -46,6 +123,74 @@ const items = ref<CheckCardItem[]>([
       </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>

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

@@ -66,6 +66,7 @@ const submit = () => {
       :auto-upload="false"
       :limit="5"
       :uploadFunction="uploadFunction"
+      :item-style="{ width: '50px', height: '100px' }"
       ref="imageUploadRef"
       @remove="onRemove"
     />

+ 179 - 0
src/views/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/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>

+ 1 - 1
src/views/tableSelect/base.vue

@@ -60,7 +60,7 @@ const tableConfig: TableConfig = {
       </el-col>
       <el-col :span="6">
         <el-form-item label="多选">
-          <fs-table-select v-model="datas" :tableConfig="tableConfig" multiple>
+          <fs-table-select v-model="datas" :tableConfig="tableConfig" multiple tagType="success">
             <template #sex="{ row }">
               <el-tag size="small">{{ row.sex }}</el-tag>
             </template>

+ 49 - 0
src/views/tour/index.vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import FsTour from '@/components/FsTour/index.vue'
+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 onStart1 = () => {
+  current.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">Upload</el-button>
+      <el-button ref="saveRef1" type="primary">Save</el-button>
+      <el-button ref="moreRef1">More</el-button>
+    </div>
+    <fs-tour v-model="current" :steps="steps" />
+  </el-card>
+</template>
+
+<style scoped lang="scss"></style>