普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月2日首页

全局重复接口取消&重复提示

2025年12月2日 10:46

重复接口取消

思路

我们创建了一个pendingMap对象,它用于存储每个请求的标识(请求路径、请求方式、请求参数3个维度唯一标识)和取消函数。当请求被发出时,我们将其添加到pendingMap中。再次调用pendingMap存在此请求,则取消请求,另外再此接口返回时,同样取消请求。

实现

1、创建AxiosCanceler类,定义了addPending、removeAllPending和removePending三个方法。addPending方法用于将请求添加到pendingMap中。removeAllPending方法可以用于取消所有请求,而removePending方法可以用于取消单个请求。

import type { AxiosRequestConfig } from 'axios'
import { generateRequestCode } from './index'

// 用于存储每个请求的标识和取消函数
const pendingMap = new Map<string, AbortController>()

export class AxiosCanceler {
  /**
   * 添加请求
   * @param config 请求配置
   */
  public addPending(config: AxiosRequestConfig): void {
    // 立刻移除重复请求
    this.removePending(config)
    // 请求唯一标识code
    const requestCode = generateRequestCode(config)
    // 取消请求对象
    const controller = new AbortController()
    config.signal = controller.signal
    if (!pendingMap.has(requestCode)) {
      // 如果当前请求不在等待中,将其添加到等待中
      pendingMap.set(requestCode, controller)
    }
  }

  /**
   * 清除所有等待中的请求
   */
  public removeAllPending(): void {
    pendingMap.forEach((abortController) => {
      if (abortController) {
        abortController.abort()
      }
    })
    this.reset()
  }

  /**
   * 移除请求
   * @param config 请求配置
   */
  public removePending(config: AxiosRequestConfig): void {
    const requestCode = generateRequestCode(config)
    if (pendingMap.has(requestCode)) {
      // 如果当前请求在等待中,取消它并将其从等待中移除
      const abortController = pendingMap.get(requestCode)
      if (abortController) {
        abortController.abort(requestCode)
      }
      pendingMap.delete(requestCode)
    }
  }

  /**
   * 重置
   */
  public reset(): void {
    pendingMap.clear()
  }
}

2、创建获取唯一标识请求code的方法generateRequestCode,通过url、method、data、params来唯一标识

import type { AxiosRequestConfig } from 'axios'

/**
 * 标准化参数对象
 * @param {Record<string, any> | null | undefined} params - 需要标准化的参数
 * @returns {Record<string, any>} - 标准化后的参数对象
 */
function normalizeParams(params?: Record<string, any> | null | undefined): Record<string, any> {
  // 处理undefined和未传参的情况
  if (arguments.length === 0 || params === undefined) {
    return {}
  }

  // 处理null和其他空值情况
  if (params === null) {
    return {}
  }

  // 如果是字符串,尝试解析为JSON对象
  if (typeof params === 'string') {
    try {
      const parsed = JSON.parse(params)
      if (typeof parsed === 'object' && parsed !== null) {
        return sortObjectDeep(parsed)
      }
    } catch (e) {
      // 解析失败,返回空对象
    }
    return {}
  }

  // 如果不是对象类型,返回空对象
  if (typeof params !== 'object') {
    return {}
  }

  // 如果是数组,返回空对象
  if (Array.isArray(params)) {
    return {}
  }

  // 检查是否为空对象
  if (Object.keys(params).length === 0) {
    return {}
  }

  // 对非空对象进行深度排序
  return sortObjectDeep(params)
}

/**
 * 深度排序对象
 * @template T - 输入对象类型
 * @param {T} obj - 需要排序的对象
 * @returns {T} - 排序后的对象
 */
function sortObjectDeep<T>(obj: T): T {
  if (typeof obj !== 'object' || obj === null) {
    return obj
  }

  if (Array.isArray(obj)) {
    return obj.map(sortObjectDeep).sort() as T
  }

  return Object.keys(obj as Record<string, any>)
    .sort()
    .reduce((result: Record<string, any>, key: string) => {
      result[key] = sortObjectDeep((obj as Record<string, any>)[key])
      return result
    }, {}) as T
}

/**
 * 生成请求唯一编码
 * @param {AxiosRequestConfig} config - Axios请求配置
 * @returns {string} - 唯一编码
 */
export function generateRequestCode(
  config: AxiosRequestConfig,
): string {
  // 确保config存在
  if (!config) {
    throw new Error('请求配置为必填参数')
  }

  // 确保url和method存在
  if (!config.url || !config.method) {
    throw new Error('URL和method为必填参数')
  }

  // 处理params的特殊情况
  const normalizedParams = normalizeParams(config.params)

  const normalizedData = normalizeParams(config.data)
  // 拼接字符串
  const stringToHash = `${config.url.toLowerCase()}|${config.method.toUpperCase()}|${JSON.stringify(normalizedParams)}|${JSON.stringify(normalizedData)}`
  
  return stringToHash
}

3、修改请求拦截器

instance.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    ... 
    axiosCanceler.addPending(config)
    return config
  },
  (error) => {
    return Promise.reject(error.data.error.message)
  },
)

// 响应拦截器
instance.interceptors.response.use(
  function(response) {
    ....
    // 移除请求
    axiosCanceler.removePending(response.config)
    return Promise.resolve(response)
  },
  function(error) {
    // 请求被取消时,返回错误提示
    if (isCancel(error)) {
      return Promise.reject('重复请求,已取消')
    }
    // 移除请求
    const { response } = error
    if (response && response.config) {
      axiosCanceler.removePending(response.config)
    } else if (error.config) {
      // 处理请求被取消的情况
      axiosCanceler.removePending(error.config)
    }
    if (response) {
      return Promise.reject(response)
    } else {
      /*
       * 处理断网的情况
       * eg:请求超时或断网时,更新state的network状态
       * network状态在app.vue中控制着一个全局的断网提示组件的显示隐藏
       * 后续增加断网情况下做的一些操作
       */
      // 断网情况下,清除所有请求
      axiosCanceler.removeAllPending()
    }
  },
)
// 只需要考虑单一职责,这块只封装axios
export default instance

重复提示

这里我是通过直接二次封装element的消息提示方法,我这里采用的是vue3,统一入口方式去做的,如果是vue2,可以在main.js将$message挂载到this之前做一下重写

代码

import { ElMessage, MessageHandler } from 'element-plus'

// 防止重复弹窗
let messageInstance: MessageHandler | null = null

interface MessageOptions {
  message: string
  type?: 'success' | 'warning' | 'info' | 'error'
  [key: string]: any
}

const mainMessage = (options: MessageOptions | string): MessageHandler => {
  // 如果弹窗已存在先关闭
  if (messageInstance) {
    messageInstance.close()
  }

  const messageOptions = typeof options === 'string'
    ? { message: options }
    : options

  messageInstance = ElMessage(messageOptions)
  return messageInstance
}


const extendedMainMessage: any = mainMessage

const arr: Array<'success' | 'warning' | 'info' | 'error'> = ['success', 'warning', 'info', 'error']
arr.forEach((type) => {
  extendedMainMessage[type] = (options: MessageOptions | string) => {
    const messageOptions = typeof options === 'string'
      ? { message: options }
      : { ...options }

    messageOptions.type = type
    return mainMessage(messageOptions)
  }
})

// message消息提示
export const $success = (msg: string) => {
  mainMessage({
    message: msg || '操作成功',
    type: 'success',
  })
}
export const $warning = (msg: string) => {
  mainMessage({
    message: msg || '操作失败',
    type: 'warning',
  })
}
export const $error = (msg: string) => {
  mainMessage({
    message: msg || '操作失败',
    type: 'error',
  })
}

export const $info = (msg: string) => {
  mainMessage({
    message: msg,
    type: 'info',
  })
}

使用

此时连续调用始终只会有一个消息体出现(successsuccess、error、warningwarning、info均共用同一个消息体)

import { $error, $success } from '@/hooks/index'

$error('错误提示1')
$error('错误提示2')

前端导出页面内容为PDF

2025年12月2日 10:45

1.前言

接到一个将窗合同签署弹窗导出盖章之后的内容为PDF需求,首先想到后端导出,后端反馈导出样式把控困难,实现时间非常久。前端被迫接活导出PDF🤡。

2.实现方案

采用html2Canvas对页面内容进行截图,生成页面内容对应的图片,之后通过JsPDF将图片添加到pdf文件,并导出为PDF文件

html2Canvas + JsPDF

import html2Canvas from 'html2canvas'
import JsPDF from 'jspdf'

3.具体代码实现

具体代码位置:pv-admin\src\views\devopsManage\noticeManage\list\components\previewDialog.vue

这里大概阐述一下代码逻辑。

1.采用html2Canvas生成一张完整的图片

2.判断图片是否大于一页,如果只有一页则通过pdf.addImage方法塞入pdf

3.如果图片长度大于一页,则需要将图片分割生成多页,这里图片分割采用的方案是,通过canvas的drawImage方法渲染图片的某个高度到某个高度的内容,然后再转成图片,然后通过pdf.addImage一页一页的塞到pdf

4.通过pdf.output('blob')既可获取pdf文件流。

async generatePdf() {
  this.loading = true
  try {
    // 导出内容对应的dom的ref
    const element = this.$refs.rightContent
    const canvas = await html2Canvas(element, {
      allowTaint: true,
      useCORS: true,
      scale: 2,
      logging: false,
      letterRendering: true,
    })
    
    const imgData = canvas.toDataURL('image/jpeg', 1.0)
    // 初始化一个A4纸大小的PDF
    const pdf = new JsPDF('p', 'pt', 'a4')
    
    // 获取PDF的宽度和高度
    const pdfWidth = pdf.internal.pageSize.getWidth()
    const pdfHeight = pdf.internal.pageSize.getHeight()
    
    // 设置页面边距
    const marginTop = 40
    const marginBottom = 60
    
    // 计算图片的宽度和高度及比例
    const imgWidth = canvas.width
    const imgHeight = canvas.height
    const ratio = pdfWidth / imgWidth
    const scaledImgHeight = imgHeight * ratio
    
    // 使用新的分页方法:按照页面高度切割原始图像
    if (scaledImgHeight <= pdfHeight - marginTop - marginBottom) {
      // 如果内容高度不超过一页,直接添加图像
      pdf.addImage(imgData, 'JPEG', 0, marginTop, pdfWidth, scaledImgHeight)
    } else {
      // 如果内容超过一页,使用多页PDF
      let remainingHeight = imgHeight
      let yOffset = 0
      let pageCount = 0
      
      while (remainingHeight > 0) {
        // 计算当前页能显示的高度(以原始图像高度计算)
        const pageHeightInCanvas = ((pdfHeight - marginTop - marginBottom) / ratio)
        // 计算实际可用的高度
        const heightToPrint = Math.min(pageHeightInCanvas, remainingHeight)
        // 转换为PDF上实际高度
        const heightOnPdf = heightToPrint * ratio
        
        // 创建一个新的canvas,只包含当前页需要的部分
        const tmpCanvas = document.createElement('canvas')
        tmpCanvas.width = imgWidth
        tmpCanvas.height = heightToPrint
        
        const ctx = tmpCanvas.getContext('2d')
        // 将原始canvas的特定部分绘制到临时canvas
        ctx.drawImage(
          canvas,
          0, yOffset, // 源图像的起始位置
          imgWidth, heightToPrint, // 源图像的宽高
          0, 0, // 目标起始位置
          imgWidth, heightToPrint // 目标宽高
        )
        
        // 如果不是第一页,添加新页
        if (pageCount > 0) {
          pdf.addPage()
        }
        
        // 将当前页绘制到PDF
        const pageImgData = tmpCanvas.toDataURL('image/jpeg', 1.0)
        pdf.addImage(pageImgData, 'JPEG', 0, marginTop, pdfWidth, heightOnPdf)
        
        // 更新剩余高度和垂直偏移
        remainingHeight -= heightToPrint
        yOffset += heightToPrint
        pageCount++
      }
    }
    
    // 获取PDF的blob对象
    const pdfBlob = pdf.output('blob')
    // 创建File对象
    const fileName = `运维工作告知函_${this.detail.noticeNo}.pdf`
    const pdfFile = new File([pdfBlob], fileName, { type: 'application/pdf' })
    
    // 生成文件路径
    const filePath = '/notice/' + new Date().getTime() + '/' + fileName
    
    // 直接上传文件
    try {
      const res = await UploadFile(pdfFile, filePath, filePath.substring(1))
      if (res && res.url) {
        this.queryFn(res.url)
      } else {
        this.$message.error('生成PDF失败')
      }
    } catch (error) {
      console.error('上传PDF失败:', error)
      this.$message.error('上传PDF失败')
    }
  } catch (error) {
    console.error('生成PDF失败:', error)
    this.$message.error('生成PDF失败')
  } finally {
    this.loading = false
  }
},

4.效果展示

📎运维工作告知函_YW202507-02.pdf

5.存在问题

采用html2Canvas生成的图片,如果过长,分页时,分页截断的位置不好控制,会出现部分模块被分割到两页(哪怕一行字也看也能会被从中间截断展示)

❌
❌