初始情况,打印没有进度条显示:

点击【打印/查看】,弹出打印对话框,开始加载数据(200份报告),

数据已加载,耗时41.42秒,这个过程页面一直没有变化,需增加进度显示,增强使用体验。

原来的代码:

打印抽屉

src\components\common\PrintDrawer.vue

<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printService } from "@api";
import { PrintContainer } from "@components";
import { JJDTemplate, OperateType, PrintDirection, PrintType, TestReportTemplate, WspjReportTemplate } from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>

一、进度实现

1、前端简单实现

1.1、前端

1.1.1、打印抽屉

src\components\common\PrintDrawer.vue

1、增加进度条组件
        <el-progress
          v-show="progressValue !== 100"
          :text-inside="true"
          :stroke-width="20"
          :percentage="progressValue"
          status="success"
          :format="progressFormat">
        </el-progress>
2、增加进度值、格式化进度显示的内容
// 进度值,进度从1开始,增强使用体验,感觉一开始就工作了
const progressValue = ref(1);

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};
3、增加进度更新逻辑
// 更新进度
const updateProgress = () => {
  // 前端计算进度
  // 1、计算递增的间隔时间
  // 2、设置计数器,进度值从1开始,每次递增1,当到99时停止(防止后端还没有返回数据,进度条就显示为100%,影响使用体验)
  progressValue.value = 1;
  const apartCoefficient = 2; // 间隔系数
  const apartTime = Math.max(100, apartCoefficient * props.printSelection.length); // 递增的间隔时间(毫秒),最小是100毫秒
  progressTimer = setInterval(() => {
    // 计算进度
    const progress = progressValue.value + 1;
    // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
    progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
  }, apartTime);
};
4、清理前端资源
// 清理前端资源
const cleanupFrontendResources = () => {
  // 清理定时器
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = null;
  }

  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};
5、使用进度更新
// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 更新进度
      updateProgress();

      // 获取打印数据

      await

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

    }
  },
  { immediate: true }
);
6、追加渲染完成 - 后续逻辑
// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 所有工作全部完成,进度值设置为100,触发隐藏进度条
  progressValue.value = 100;

  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};
 7、对比代码

更改6处,实现进度条

8、完整代码
<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printService } from "@api";
import { PrintContainer } from "@components";
import { JJDTemplate, OperateType, PrintDirection, PrintType, TestReportTemplate, WspjReportTemplate } from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");
// 进度值,进度从1开始,增强使用体验,感觉一开始就工作了
const progressValue = ref(1);
// 进度定时器
let progressTimer: number | null = null;

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 清理定时器
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = null;
  }

  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 所有工作全部完成,进度值设置为100,触发隐藏进度条
  progressValue.value = 100;

  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

// 更新进度
const updateProgress = () => {
  // 前端计算进度
  // 1、计算递增的间隔时间
  // 2、设置计数器,进度值从1开始,每次递增1,当到99时停止(防止后端还没有返回数据,进度条就显示为100%,影响使用体验)
  progressValue.value = 1;
  const apartCoefficient = 2; // 间隔系数
  const apartTime = Math.max(100, apartCoefficient * props.printSelection.length); // 递增的间隔时间(毫秒),最小是100毫秒
  progressTimer = setInterval(() => {
    // 计算进度
    const progress = progressValue.value + 1;
    // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
    progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
  }, apartTime);
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 更新进度
      updateProgress();

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
        <el-progress
          v-show="progressValue !== 100"
          :text-inside="true"
          :stroke-width="20"
          :percentage="progressValue"
          status="success"
          :format="progressFormat">
        </el-progress>
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>

1.2、应用效果

1.3、优缺点

优点:

  • 实现简单

缺点:

  • 进度条显示与实际数据处理进度不一致,进度条从1到99花费的时间是固定的,遇到大批量数据时,会出现进度条一直显示99,就是不显示出来内容。

2、轮询

2.1、前端

2.1.1、增加 API

src\api\print.ts

/**
 * 打印进度服务
 * @param requestId 请求唯一id
 */
export const printProgressService = async (requestId: string) => {
  return request.get("/print/progress", {
    headers: {
      "X-Request-Id": requestId
    }
  });
};
2.1.2、打印抽屉

src\components\common\PrintDrawer.vue

1、增加进度条组件

同上

2、增加进度值、格式化进度显示的内容

同上

3、增加进度更新逻辑
// 更新进度
const updateProgress = () => {
  // 轮询计算进度
  // 1、动态调整轮询间隔
  // 2、使用 setTimeout 递归调用
  progressValue.value = 1;
  const runProgress = async () => {
    if (isDataLoaded.value) return;
    try {
      const result = await printProgressService(requestId.value);
      const resultValue = Math.trunc((result.data * 100) / props.printSelection.length);
      // 确保进度不倒退
      progressValue.value = Math.max(progressValue.value, resultValue);
      // 根据完成情况动态调整轮询间隔
      const interval = resultValue >= 80 ? 1000 : resultValue >= 50 ? 2000 : 3000;
      if (!isDataLoaded.value) {
        progressTimer = setTimeout(runProgress, interval);
      }
    } catch (error) {
      console.error("获取进度失败:", error);
      // 失败时使用保守的后备方案
      if (progressValue.value < 90 && !isDataLoaded.value) {
        progressValue.value += 5;
        progressTimer = setTimeout(runProgress, 3000);
      }
    }
  };

  // 启动进度
  progressTimer = setTimeout(runProgress, 3000);
};
4、清理前端资源
// 清理前端资源
const cleanupFrontendResources = () => {
  // 清理定时器
  if (progressTimer) {
    clearTimeout(progressTimer);
    progressTimer = null;
  }

  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};
5、使用进度更新

同上

6、追加渲染完成 - 后续逻辑

同上

7、对比代码

更改7处,实现进度条

8、完整代码
<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printProgressService, printService } from "@api";
import { PrintContainer } from "@components";
import { JJDTemplate, OperateType, PrintDirection, PrintType, TestReportTemplate, WspjReportTemplate } from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");
// 进度值,进度从1开始,增强使用体验,感觉一开始就工作了
const progressValue = ref(1);
// 进度定时器
let progressTimer: number | null = null;

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 清理定时器
  if (progressTimer) {
    clearTimeout(progressTimer);
    progressTimer = null;
  }

  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 所有工作全部完成,进度值设置为100,触发隐藏进度条
  progressValue.value = 100;

  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

// 更新进度
const updateProgress = () => {
  // 轮询计算进度
  // 1、动态调整轮询间隔
  // 2、使用 setTimeout 递归调用
  progressValue.value = 1;
  const runProgress = async () => {
    if (isDataLoaded.value) return;
    try {
      const result = await printProgressService(requestId.value);
      const resultValue = Math.trunc((result.data * 100) / props.printSelection.length);
      // 确保进度不倒退
      progressValue.value = Math.max(progressValue.value, resultValue);
      // 根据完成情况动态调整轮询间隔
      const interval = resultValue >= 80 ? 1000 : resultValue >= 50 ? 2000 : 3000;
      if (!isDataLoaded.value) {
        progressTimer = setTimeout(runProgress, interval);
      }
    } catch (error) {
      console.error("获取进度失败:", error);
      // 失败时使用保守的后备方案
      if (progressValue.value < 90 && !isDataLoaded.value) {
        progressValue.value += 5;
        progressTimer = setTimeout(runProgress, 3000);
      }
    }
  };

  // 启动进度
  progressTimer = setTimeout(runProgress, 3000);
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 更新进度
      updateProgress();

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
        <el-progress
          v-show="progressValue !== 100"
          :text-inside="true"
          :stroke-width="20"
          :percentage="progressValue"
          status="success"
          :format="progressFormat">
        </el-progress>
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>

2.2、后端

2.2.1、增加请求上下文处理器

src/main/java/com/weiyu/handler/RequestContextHandler.java

package com.weiyu.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 请求上下文处理器
 */
@Component
@Slf4j
public class RequestContextHandler {
    
    /**
     * 请求进度
     */
    private final Map<String, Integer> requestProgresses = new ConcurrentHashMap<>();

    /**
     * 注册请求,增加 Map 记录
     *
     * @param requestId 请求唯一id
     */
    public void registerRequest(String requestId) {
        requestProgresses.put(requestId, 0);
        log.info("已注册请求 {}", requestId);
    }

    /**
     * 清理请求,移除 Map 记录
     *
     * @param requestId 请求唯一id
     */
    public void cleanupRequest(String requestId) {
        requestProgresses.remove(requestId);
        log.info("已清理请求 {}", requestId);
    }

    /**
     * 更新请求进度
     *
     * @param requestId 请求唯一id
     * @param progress  进度
     */
    public void updateProgress(String requestId, Integer progress) {
        if (progress != null) {
            requestProgresses.put(requestId, progress);
        }
    }

    /**
     * 获取请求进度
     *
     * @param requestId 请求唯一id
     */
    public Optional<Integer> getProgress(String requestId) {
        return Optional.ofNullable(requestProgresses.get(requestId));
    }
}
2.2.2、控制器

src/main/java/com/weiyu/controller/PrintController.java

/**
 * 打印控制器
 */
@RestController
@RequestMapping("/print")
@Slf4j
public class PrintController {

    @Autowired
    private PrintService printService;

    @Autowired
    private RequestContextHandler requestContextHandler;

    /**
     * 获取报告打印数据
     *
     * @param reports 报告列表
     * @param request 请求对象,可以访问请求头属性,获取请求唯一id(X-Request-Id)
     * @return 报告打印数据列表 {@link Result}&lt;{@link List}&lt;{@link ReportPrintDataVO}&gt;&gt;
     */
    @PostMapping("/report")
    public Result<?> printReport(@RequestBody List<Report> reports, HttpServletRequest request) {
        String requestId = request.getHeader("X-Request-Id");
        log.info("【报告编制/打印发放/查询】,查看/打印报告,/print/report,reports = {}, requestId = {}", reports, requestId);

        try {
            if (requestId != null && !requestId.isEmpty()) {
                requestContextHandler.registerRequest(requestId);
            }

            // 执行长时间运行的报告打印任务
            List<ReportPrintDataVO> printDatas = printService.printReport(reports, requestId);
            log.info("报告打印数据 = {}", printDatas);
            return Result.success(printDatas);

        } finally {
            if (requestId != null && !requestId.isEmpty()) {
                requestContextHandler.cleanupRequest(requestId);
            }
        }
    }

     /**
     * 打印进度
     *
     * @param requestId 请求唯一id,直接通过 @RequestHeader("X-Request-Id") 获取
     * @return 进度值
     */
    @GetMapping("/progress")
    public Result<Optional<Integer>> printProgress(@RequestHeader("X-Request-Id") String requestId) {
        log.info("【打印进度】,进度,/print/progress,requestId = {}", requestId);
        return Result.success(requestContextHandler.getProgress(requestId));
    }
}
2.2.3、服务层

src/main/java/com/weiyu/service/impl/PrintServiceImpl.java

/**
 * 打印 Servive 接口实现
 */
@Service
@Slf4j
public class PrintServiceImpl implements PrintService {

    @Autowired
    private TestReportMapper testReportMapper;

    @Autowired
    private WspjReporMapper wspjReporMapper;

    @Autowired
    private RequestContextHandler requestContextHandler;

    /**
     * 获取报告打印数据
     */
    @Override
    public List<ReportPrintDataVO> printReport(List<Report> reports, String requestId) {
        List<ReportPrintDataVO> printDatas = new ArrayList<>();
        int progress = 0;
        
        for (Report report : reports) {
            // 获取评价报告打印数据
            List<WspjReport> wspjReports = wspjReporMapper.selectByOuterApplyId(report.getOuterApplyId());
            List<WspjReportPrintDataVO> wspjReportPrintDatas = getWspjReportPrintData(wspjReports, requestId, false);

            // 获取检验报告打印数据
            List<TestReport> testReports = testReportMapper.selectByOuterApplyId(report.getOuterApplyId());
            List<TestReportPrintDataVO> testReportPrintDatas = getTestReportPrintData(testReports, requestId, false);

            printDatas.add(new ReportPrintDataVO(wspjReportPrintDatas, testReportPrintDatas));

            ++progress;

            // 轮询更新进度(适用于 http/https)
            requestContextHandler.updateProgress(requestId, progress);
        }

        return printDatas;
    }
}

2.3、应用效果

2.4、优缺点

优点:

  • 进度条显示与实际数据处理进度一致

缺点:

  • 请求频繁

3、WebSocket 双向通信

3.1、使用原生 WebSocket

3.1.1、前端
1、打印抽屉

src\components\common\PrintDrawer.vue

1、增加进度条组件

同上

2、增加进度值、格式化进度显示的内容

同上

3、增加进度更新逻辑

// 更新进度
const updateProgress = () => {
  // WebSocket 双向通信计算进度
  // 1、实时计算进度
  // 2、使用 WebSocket 双向通信,后端主动发送进度信息,前端实时接收并更新进度
  ws = new WebSocket(`ws://localhost:8080/ws/print/progress/${encodeURI(requestId.value)}`); // 由后端处理跨域问题

  // WebSocket 收到消息时
  ws.onmessage = (event) => {
    // 解析数据(后端发送的普通文本)
    const value: any = event.data;
    // 更新进度
    if (Number(value)) {
      // 计算进度
      const progress = Math.trunc(((value as number) * 100) / props.printSelection.length);
      // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
      progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
    }
  };
};

4、清理前端资源

// 清理前端资源
const cleanupFrontendResources = () => {
  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }

  // 关闭 WebSocket 连接
  if (ws) {
    ws.close();
    ws = null;
  }
};

5、使用进度更新

同上

6、追加渲染完成 - 后续逻辑

同上

7、对比代码

更改6处,实现进度条

8、完整代码

<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printService } from "@api";
import { PrintContainer } from "@components";
import { JJDTemplate, OperateType, PrintDirection, PrintType, TestReportTemplate, WspjReportTemplate } from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");
// 进度值,进度从1开始,增强使用体验,感觉一开始就工作了
const progressValue = ref(1);
// WebSocket
let ws: WebSocket | null = null;

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }

  // 关闭 WebSocket 连接
  if (ws) {
    ws.close();
    ws = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 所有工作全部完成,进度值设置为100,触发隐藏进度条
  progressValue.value = 100;

  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

// 更新进度
const updateProgress = () => {
  // WebSocket 双向通信计算进度
  // 1、实时计算进度
  // 2、使用 WebSocket 双向通信,后端主动发送进度信息,前端实时接收并更新进度
  progressValue.value = 1;
  ws = new WebSocket(`ws://localhost:8080/ws/print/progress/${encodeURI(requestId.value)}`); // 由后端处理跨域问题

  // WebSocket 收到消息时
  ws.onmessage = (event) => {
    // 解析数据(后端发送的普通文本)
    const value: any = event.data;
    // 更新进度
    if (Number(value)) {
      // 计算进度
      const progress = Math.trunc(((value as number) * 100) / props.printSelection.length);
      // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
      progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
    }
  };
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 更新进度
      updateProgress();

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
        <el-progress
          v-show="progressValue !== 100"
          :text-inside="true"
          :stroke-width="20"
          :percentage="progressValue"
          status="success"
          :format="progressFormat">
        </el-progress>
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>
3.1.2、后端
1、安装依赖

pom.xml

    <!--websocket依赖-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
2、增加 WebSocket 配置类

 src/main/java/com/weiyu/config/WebSocketConfig.java

package com.weiyu.config;

import com.weiyu.handler.SessionWebSocketHandler;
import com.weiyu.interceptors.PathParamWebSocketHandshakeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;

/**
 * WebSocket 配置类
 */
@Configuration
@EnableWebSocket // 开启 WebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private SessionWebSocketHandler sessionWebSocketHandler;

    @Autowired
    private PathParamWebSocketHandshakeInterceptor pathParamWebSocketHandshakeInterceptor;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 这个方法的调用是由 @EnableWebSocket 触发的
        // 登录拦截器(LoginInterceptor/HandlerInterceptor)com/weiyu/interceptors/LoginInterceptor.java
        // 当前这个WebSocket请求路径:/ws/print/progress,有注册处理器,这个路径不会被登录拦截器拦截
        // 例如有个WebSocket请求路径:/ws/check/progress,无注册处理器,这个路径就会被登录拦截器拦截
        registry.addHandler(sessionWebSocketHandler, "/ws/print/progress/**")
                .addInterceptors(pathParamWebSocketHandshakeInterceptor)    // 添加路径参数握手拦截器
                // 解决跨域,如果是 Vite 代理这里必须要设置,如果是 Nginx 代理这里不用设置
                .setAllowedOrigins(
                        "http://localhost:5173",    // Vite 默认端口
                        "http://127.0.0.1:5173"     // 或者用 IP
                );
    }

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        // 这个 Bean 的创建是由 @EnableWebSocket 触发的
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(8192);    // 文本消息流量控制
        container.setMaxBinaryMessageBufferSize(8192);  // 流消息流量控制
        container.setMaxSessionIdleTimeout(300000L);    // 5分钟超时
        return container;
    }
}
3、增加 WebSocket 会话处理器

src/main/java/com/weiyu/handler/SessionWebSocketHandler.java

package com.weiyu.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.lang.NonNull;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * WebSocket 会话处理器
 */
@Component
@Slf4j
public class SessionWebSocketHandler extends TextWebSocketHandler {

    /**
     * 会话列表,使用线程安全的Map来管理多个会话,key为requestId
     */
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    // 连接建立后
    @Override
    public void afterConnectionEstablished(@NonNull WebSocketSession session) throws IOException {
        // 从 session 属性中获取请求唯一id requestId
        String requestId = (String) session.getAttributes().get("requestId");

        if (requestId == null || requestId.trim().isEmpty()) {
            log.warn("WebSocket 连接建立失败:未获取到 requestId,session = {}", session);
            session.close();
            return;
        }

        // 发送连接成功消息(普通字符串)
        session.sendMessage(new TextMessage("WebSocket 连接成功,requestId = " + requestId + ", session = " + session.getId()));
        log.info("WebSocket 连接成功,requestId = {}, session = {}", requestId, session.getId());

        // 保存会话
        sessions.put(requestId, session);
    }

    // 收到消息时
    @Override
    public void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {

    }

    // 发送进度更新通知 - 在任务执行过程中调用
    public void sendProgressUpdate(String requestId, int progress) throws IOException {
        WebSocketSession currentSession = sessions.get(requestId);
        if (currentSession == null || !currentSession.isOpen()) {
            return;
        }

        // 发送进度消息(普通字符串)
        currentSession.sendMessage(new TextMessage(String.valueOf(progress)));
    }
}
4、增加 WebSocket 路径参数握手拦截器

src/main/java/com/weiyu/interceptors/PathParamWebSocketHandshakeInterceptor.java

package com.weiyu.interceptors;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * WebSocket 路径参数握手拦截器
 */
@Component
@Slf4j
public class PathParamWebSocketHandshakeInterceptor implements HandshakeInterceptor {

    private static final Pattern PATH_PATTERN = Pattern.compile("/ws/print/progress/([^/]+)");

    @Override
    public boolean beforeHandshake(@NonNull ServerHttpRequest request,
                                   @NonNull ServerHttpResponse response,
                                   @NonNull WebSocketHandler wsHandler,
                                   @NonNull Map<String, Object> attributes) {
        String path = request.getURI().getPath();
        log.info("WebSocket 连接路径: {}", path);

        // 使用正则表达式提取请求唯一id requestId,并且放入属性 attributes 中
        Matcher matcher = PATH_PATTERN.matcher(path);
        if (matcher.matches()) {
            String requestId = matcher.group(1);
            attributes.put("requestId", requestId);
            log.info("提取到 requestId: {}", requestId);
        } else {
            log.warn("WebSocket路径格式不匹配: {}", path);
        }

        return true;
    }

    @Override
    public void afterHandshake(@NonNull ServerHttpRequest request,
                               @NonNull ServerHttpResponse response,
                               @NonNull WebSocketHandler wsHandler,
                               Exception exception) {

    }
}
5、服务层

src/main/java/com/weiyu/service/impl/PrintServiceImpl.java

/**
 * 打印 Servive 接口实现
 */
@Service
@Slf4j
public class PrintServiceImpl implements PrintService {

    @Autowired
    private TestReportMapper testReportMapper;

    @Autowired
    private WspjReporMapper wspjReporMapper;

    @Autowired
    private SessionWebSocketHandler sessionWebSocketHandler;

    /**
     * 获取报告打印数据
     */
    @Override
    public List<ReportPrintDataVO> printReport(List<Report> reports, String requestId) {
        List<ReportPrintDataVO> printDatas = new ArrayList<>();
        int progress = 0;
        
        for (Report report : reports) {
            // 获取评价报告打印数据
            List<WspjReport> wspjReports = wspjReporMapper.selectByOuterApplyId(report.getOuterApplyId());
            List<WspjReportPrintDataVO> wspjReportPrintDatas = getWspjReportPrintData(wspjReports, requestId, false);

            // 获取检验报告打印数据
            List<TestReport> testReports = testReportMapper.selectByOuterApplyId(report.getOuterApplyId());
            List<TestReportPrintDataVO> testReportPrintDatas = getTestReportPrintData(testReports, requestId, false);

            printDatas.add(new ReportPrintDataVO(wspjReportPrintDatas, testReportPrintDatas));

            ++progress;

            // 发送进度更新(适用于 WebSocket)
            try {
                sessionWebSocketHandler.sendProgressUpdate(requestId, progress);
            }catch (Exception ignored){
            }
        }

        return printDatas;
    }
}
3.1.3、应用效果

3.1.4、优缺点

优点:

  • 进度条显示与实际数据处理进度一致,前后端实时双向通信

缺点:

  • 实现复杂

3.2、使用托管 WebSocket 类 ManagedWebSocket(被封装的WebSocket)

3.2.1、使用 ManagedWebSocket
1、前端

1、定义接口、类

src\types\WebSocket.ts

/**
 * WebSocket 消息接口
 * 定义 WebSocket 通信的消息格式
 * @template T 消息数据的类型,默认为 any
 */
export interface WebSocketMessage<T = any> {
  /** 消息类型,用于区分不同的业务场景 */
  type: string;
  /** 消息数据,泛型T允许灵活定义数据结构 */
  data: T;
  /** 消息时间戳,用于记录消息创建时间 */
  timestamp: number;
}

/**
 * 托管 WebSocket 配置选项接口
 * 用于配置 ManagedWebSocket 的行为
 */
export interface ManagedWebSocketOptions {
  /** WebSocket连接地址,如:ws://localhost:8080 */
  url: string;
  /** 收到消息时的回调函数 */
  onMessage?: <T>(message: WebSocketMessage<T>) => void;
}

/**
 * 托管 WebSocket 类
 */
export class ManagedWebSocket {
  /** WebSocket实例 */
  private ws: WebSocket | null = null;

  /**
   * 构造函数
   * @param options 配置选项
   */
  constructor(private options: ManagedWebSocketOptions) {
    // 创建实例时立即建立连接
    this.connect();
  }

  /**
   * 建立 WebSocket 连接
   * 创建 WebSocket 实例并设置事件处理器
   */
  private connect(): void {
    try {
      // 创建 WebSocket 实例
      this.ws = new WebSocket(this.options.url);
      // 设置事件处理器
      this.setupEventHandlers();
    } catch (error) {
      console.error('WebSocket connection failed:', error);
    }
  }

  /**
   * 设置 WebSocket 事件处理器
   */
  private setupEventHandlers(): void {
    if (!this.ws) return;

    // 消息接收事件
    this.ws.onmessage = (event: MessageEvent) => {
      try {
        // 解析 JSON 格式的消息
        const message: WebSocketMessage = JSON.parse(event.data);
        // 调用用户提供的 onMessage 回调
        this.options.onMessage?.(message);
      } catch (error) {
        console.error('Failed to parse WebSocket message:', error);
      }
    };
  }

  /**
   * 主动关闭 WebSocket 连接
   * @param code 关闭代码,默认 1000 (正常关闭)
   * @param reason 关闭原因
   */
  public close(code?: number, reason?: string): void {
    // 关闭 WebSocket 连接
    if (this.ws) {
      this.ws.close(code || 1000, reason);
    }
  }
}

2、导出资源

src\types\index.ts

// 导出 WebSocket
export * from "./WebSocket";

3、打印抽屉

src\components\common\PrintDrawer.vue

1、增加进度条组件

同上

2、增加进度值、格式化进度显示的内容

同上

3、增加进度更新逻辑

// 更新进度
const updateProgress = () => {
  // WebSocket 双向通信计算进度
  // 1、实时计算进度
  // 2、使用 WebSocket 双向通信,后端主动发送进度信息,前端实时接收并更新进度
  progressValue.value = 1;
  ws = new ManagedWebSocket({
    url: `/ws/print/progress/${encodeURI(requestId.value)}`, // 设置代理配置相对路径,Nginx代理(无需后端解决跨域),Vite代理(需要后端解决跨域)
    onMessage: (message) => {
      // 如果是进度信息,则更新进度
      if (message.type === "UPDATE_PROGRESS") {
        // 计算进度
        const progress = Math.trunc(((message.data as number) * 100) / props.printSelection.length);
        // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
        progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
      }
    }
  });
};

4、清理前端资源

同上

5、使用进度更新

同上

6、追加渲染完成 - 后续逻辑

同上

7、对比代码

更改7处,实现进度条

8、完整代码

<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printService } from "@api";
import { PrintContainer } from "@components";
import {
  JJDTemplate,
  OperateType,
  PrintDirection,
  PrintType,
  TestReportTemplate,
  WspjReportTemplate,
  ManagedWebSocket
} from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");
// 进度值,进度从1开始,增强使用体验,感觉一开始就工作了
const progressValue = ref(1);
// WebSocket
let ws: ManagedWebSocket | null = null;

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }

  // 关闭 WebSocket 连接
  if (ws) {
    ws.close();
    ws = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 所有工作全部完成,进度值设置为100,触发隐藏进度条
  progressValue.value = 100;

  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

// 更新进度
const updateProgress = () => {
  // WebSocket 双向通信计算进度
  // 1、实时计算进度
  // 2、使用 WebSocket 双向通信,后端主动发送进度信息,前端实时接收并更新进度
  progressValue.value = 1;
  ws = new ManagedWebSocket({
    url: `/ws/print/progress/${encodeURI(requestId.value)}`, // 设置代理配置相对路径,Nginx代理(无需后端解决跨域),Vite代理(需要后端解决跨域)
    onMessage: (message) => {
      // 如果是进度信息,则更新进度
      if (message.type === "UPDATE_PROGRESS") {
        // 计算进度
        const progress = Math.trunc(((message.data as number) * 100) / props.printSelection.length);
        // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
        progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
      }
    }
  });
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 更新进度
      updateProgress();

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
        <el-progress
          v-show="progressValue !== 100"
          :text-inside="true"
          :stroke-width="20"
          :percentage="progressValue"
          status="success"
          :format="progressFormat">
        </el-progress>
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>

9、Vite代理设置

vite.config.ts

    // 代理设置
    proxy: {
      // 【代理1】获取路径中包含有 /api 的请求
      "/api": {
        // 后台服务所在的源
        target: "http://localhost:8080",
        // 修改源
        changeOrigin: true,
        // 将 api 替换为 '',如:/api/account/login -> /account/login,最终代理访问的 URL 为 http://localhost:8080/account/login
        rewrite: (path) => path.replace(/^\/api/, "")
      },
      // 【代理2】获取路径中包含有 /apu 的请求,【上传数据】用于前端网面对外请求发送数据
      "/apu": {
        // 后台服务所在的源
        target: "http://localhost:8081",
        // 修改源
        changeOrigin: true,
        // 将 apu 替换为 '',如:/apu/sampleItemResult/upload -> /sampleItemResult/upload,最终代理访问的 URL 为 http://localhost:8081/sampleItemResult/upload
        rewrite: (path) => path.replace(/^\/apu/, "")
      },
      // 【代理3】获取路径中包含有 /ws 的请求,WebSocket双向通信专用路径
      "/ws": {
        // 后台服务所在的源
        target: "http://localhost:8080",
        // 修改源
        changeOrigin: true,
        ws: true // 重要:启用 WebSocket 代理
      }
    }
2、后端

1、安装依赖

pom.xml

    <!--websocket依赖-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>

    <!--fastjson-->
    <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>1.2.83</version>
    </dependency>

2、WebSocket 配置类

src/main/java/com/weiyu/config/WebSocketConfig.java

同上

3、WebSocket 会话处理器

src/main/java/com/weiyu/handler/SessionWebSocketHandler.java

package com.weiyu.handler;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.weiyu.pojo.WebSocketMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.lang.NonNull;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * WebSocket 会话处理器
 */
@Component
@Slf4j
public class SessionWebSocketHandler extends TextWebSocketHandler {

    @Autowired
    private RequestContextHandler requestContextHandler;

    /**
     * 会话列表,使用线程安全的Map来管理多个会话,key为requestId
     */
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    // 连接建立后
    @Override
    public void afterConnectionEstablished(@NonNull WebSocketSession session) throws IOException {
        // 从 session 属性中获取请求唯一id requestId
        String requestId = (String) session.getAttributes().get("requestId");

        if (requestId == null || requestId.trim().isEmpty()) {
            log.warn("WebSocket 连接建立失败:未获取到 requestId,session = {}", session);
            session.close();
            return;
        }

        // 创建连接成功消息
        WebSocketMessage<String> message = new WebSocketMessage<>("CONNECTED",
                "WebSocket 连接成功,requestId = " + requestId + ",session = " + session);

        // java对象 转换成 json字符串
        String jsonMessage = JSON.toJSONString(message, SerializerFeature.WriteMapNullValue);

        // 发送连接成功消息
        session.sendMessage(new TextMessage(jsonMessage));
        log.info("WebSocket 连接成功,requestId = {}, session = {}", requestId, session.getId());

        // 保存会话
        sessions.put(requestId, session);
    }

    // 收到消息时
    @Override
    public void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {

    }

    // 发送进度更新通知 - 在任务执行过程中调用
    public void sendProgressUpdate(String requestId, int progress) throws IOException {
        WebSocketSession currentSession = sessions.get(requestId);
        if (currentSession == null || !currentSession.isOpen()) {
            return;
        }

        // 创建进度消息
        WebSocketMessage<Integer> progressMessage = new WebSocketMessage<>("UPDATE_PROGRESS", progress);

        // java对象 转换成 json字符串
        String jsonMessage = JSON.toJSONString(progressMessage, SerializerFeature.WriteMapNullValue);

        // 发送进度数据(json数据)
        currentSession.sendMessage(new TextMessage(jsonMessage));
    }
}

4、增加 WebSocket 消息(DTO)

src/main/java/com/weiyu/pojo/WebSocketMessage.java

package com.weiyu.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * WebSocket 消息
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessage<T> {
    private String type;
    private T data;
    private long timestamp;

    public WebSocketMessage(String type, T data) {
        this.type = type;
        this.data = data;
        this.timestamp = System.currentTimeMillis();
    }
}

5、WebSocket 路径参数握手拦截器

src/main/java/com/weiyu/interceptors/PathParamWebSocketHandshakeInterceptor.java

同上

6、服务层

src/main/java/com/weiyu/service/impl/PrintServiceImpl.java

同上

3、应用效果

4、优缺点

优点:

  • 进度条显示与实际数据处理进度一致,前后端实时双向通信,通过封装可以自定义更多业务逻辑

缺点:

  • 实现复杂
3.2.2、使用 Hook(WebSocket 进度 Hook)
1、前端

1、定义 WebSocket 进度 Hook

src\hooks\useWebSocketProgress.ts

import { useTokenStore } from "@/stores";
import { ManagedWebSocket } from "@/types";
import { onUnmounted, ref, watch } from "vue";

/**
 * WebSocket 进度 Hook
 */
export function useWebSocketProgress() {
  // 进度从1开始,增强使用体验,感觉一开始就工作了
  const progressValue = ref(1);
  const { token } = useTokenStore();
  // 连接状态
  const isConnecting = ref(false);
  const isConnected = ref(false);

  let ws: ManagedWebSocket | null = null;

  /**
   * 开始进度
   * 初始化WebSocket连接,并设置接收消息时的处理逻辑(计算进度
   * @param url 连接路径
   * @param taskCount 任务数,默认为1(默认值本身就表示该参数是可选的)
   */
  const startProgress = async (url: string, taskCount: number = 1) => {
    // 数据校验
    if (!url) {
      throw new Error("无效的URL");
    }
    if (!taskCount || taskCount <= 0) {
      throw new Error("无效的任务数");
    }

    // 重置状态
    // 如果已经在连接中,先停止之前的连接
    if (isConnecting.value || isConnected.value) {
      stopProgress();
    }
    isConnecting.value = true;
    progressValue.value = 1;

    // 构建最终的 URL(包含 token 参数)
    let finalUrl = encodeURI(url);
    if (token) {
      const separator = finalUrl.includes("?") ? "&" : "?";
      finalUrl += `${separator}token=${encodeURIComponent(token)}`;
    }

    return new Promise((resolve, reject) => {
      try {
        // 实例化 ManagedWebSocket(被封装的WebSocket)
        ws = new ManagedWebSocket({
          url: finalUrl,

          // 收到消息时的回调
          onMessage: (message) => {
            if (message.type === "UPDATE_PROGRESS" && taskCount) {
              // 计算进度
              const progress = Math.trunc(((message.data as number) * 100) / taskCount);
              // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
              progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
            }
          }
        });
      } catch (error) {
        console.error("创建WebSocket进度失败:", error);
        isConnecting.value = false;
        reject(error);
      }
    });
  };

  /**
   * 完成进度
   */
  const completeProgress = (): void => {
    progressValue.value = 100;
  };

  /**
   * 停止进度
   */
  const stopProgress = () => {
    // 断开连接
    if (ws) {
      ws.close();
      ws = null;
    }
    isConnecting.value = false;
    isConnected.value = false;
  };

  // 监听进度值变化,当进度100时停止进度
  watch(
    () => progressValue.value,
    () => {
      if (progressValue.value === 100) {
        console.log("进度完成,自动停止WebSocket进度");
        stopProgress();
      }
    }
  );

  // 组件卸载时停止进度
  onUnmounted(() => {
    console.log("组件卸载,触发停止WebSocket进度");
    stopProgress();
  });

  return {
    progressValue,
    isConnecting,
    isConnected,
    startProgress,
    stopProgress,
    completeProgress
  };
}

export default useWebSocketProgress;

2、导出资源

src\hooks\index.ts

export { default as useWebSocketProgress } from "./useWebSocketProgress";

3、打印抽屉

src\components\common\PrintDrawer.vue

1、增加进度条组件(同上)

2、增加格式化进度显示的内容

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

3、导入及使用 WebSocket 进度 Hook

import { useWebSocketProgress } from "@hooks";

// 进度值、开始进度、完成进度
const { progressValue, startProgress, completeProgress } = useWebSocketProgress();

4、使用进度更新

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 开始进度
      startProgress(`/ws/print/progress/${encodeURI(requestId.value)}`, props.printSelection.length);

      // 获取打印数据

      await

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

5、追加渲染完成 - 后续逻辑

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 完成进度
  completeProgress();

  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

6、对比代码

更改6处,实现进度条

7、完整代码

<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printService } from "@api";
import { PrintContainer } from "@components";
import { useWebSocketProgress } from "@hooks";
import { JJDTemplate, OperateType, PrintDirection, PrintType, TestReportTemplate, WspjReportTemplate } from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");
// 进度值、开始进度、完成进度
const { progressValue, startProgress, completeProgress } = useWebSocketProgress();

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 完成进度
  completeProgress();

  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 开始进度
      startProgress(`/ws/print/progress/${encodeURI(requestId.value)}`, props.printSelection.length);

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
        <el-progress
          v-show="progressValue !== 100"
          :text-inside="true"
          :stroke-width="20"
          :percentage="progressValue"
          status="success"
          :format="progressFormat">
        </el-progress>
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>
2、后端

同上

3、应用效果

4、优缺点

优点:

  • 进度条显示与实际数据处理进度一致,前后端实时双向通信,通过封装可以自定义更多业务逻辑

缺点:

  • 实现复杂
3.2.2、使用 组件(WebSocket 进度条组件)
1、前端

1、定义 WebSocket 进度条组件

src\components\base\BaseWebSocketProgress.vue

<script setup lang="ts">
/**
 * WebSocket 进度条组件
 *
 * 使用示例:
 * <BaseWebSocketProgress :url="url" :count="taskCount" />
 */
defineOptions({
  name: "BaseWebSocketProgress"
});
import { useWebSocketProgress } from "@/hooks";
import { onMounted } from "vue";

interface Props {
  /**
   * WebSocket 连接地址
   */
  url: string;
  /**
   * 任务数量
   */
  count: number;
}
const props = withDefaults(defineProps<Props>(), {
  count: 1
});
const { progressValue, startProgress } = useWebSocketProgress();

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

onMounted(async () => {
  await startProgress(props.url, props.count);
});
</script>

<template>
  <el-progress
    v-show="progressValue !== 100"
    :text-inside="true"
    :stroke-width="20"
    :percentage="progressValue"
    status="success"
    :format="progressFormat">
  </el-progress>
</template>

2、导出资源

src\components\index.ts

export { default as BaseWebSocketProgress } from "./base/BaseWebSocketProgress.vue";

3、打印抽屉

src\components\common\PrintDrawer.vue

1、增加 WebSocket 进度条组件

        <!-- 进度条 -->
        <BaseWebSocketProgress
          v-if="drawerVisible && !isDataLoaded"
          :url="`/ws/print/progress/${requestId}`"
          :count="props.printSelection.length" />

4、对比代码

更改2处,实现进度条

5、完整代码

<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printService } from "@api";
import { BaseWebSocketProgress, PrintContainer } from "@components";
import { JJDTemplate, OperateType, PrintDirection, PrintType, TestReportTemplate, WspjReportTemplate } from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
        <!-- 进度条 -->
        <BaseWebSocketProgress
          v-if="drawerVisible && !isDataLoaded"
          :url="`/ws/print/progress/${requestId}`"
          :count="props.printSelection.length" />
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>
2、后端

同上

3、应用效果

4、优缺点

优点:

  • 应用极简,组件化可重可

缺点:

  • 实现复杂

二、高阶优化一(完整代码)

- 增加 WebSocket 认证握手拦截器

- 增加心跳机制

- 增加取消

- 增加定时调度

- 增加 Nginx 代理 WebSocket 设置

前端

打印抽屉

src\components\common\PrintDrawer.vue

<script setup lang="ts">
/**
 * 打印抽屉
 *
 * 使用示例:
 * <PrintDrawer :print-visible="drawerVisible" :print-type="PrintType.REPORT" :print-selection="printData" @close-print-drawer="closePrintDrawer" />
 */
defineOptions({
  name: "PrintDrawer"
});
import { printCancelService, printService } from "@api";
import { BaseWebSocketProgress, PrintContainer } from "@components";
import { JJDTemplate, OperateType, PrintDirection, PrintType, TestReportTemplate, WspjReportTemplate } from "@types";
import { v4 as uuidv4 } from "uuid";
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";

interface Props {
  /** 打印显隐 */
  printVisible: boolean;
  /** 打印类型 */
  printType: PrintType;
  /** 打印选集 */
  printSelection: any[];
  /** 是否打印数据 */
  isPrintData?: boolean;
  /** 打印模板 */
  printTemplate?: JJDTemplate | TestReportTemplate | WspjReportTemplate | null;
  /** 打印方向 */
  printDirection?: PrintDirection;
  /** 操作类型 */
  operateType?: OperateType;
}
const props = withDefaults(defineProps<Props>(), {
  printVisible: false,
  printSelection: () => [],
  isPrintData: false,
  printTemplate: null,
  printDirection: PrintDirection.PORTRAIT,
  operateType: OperateType.PRINT
});

interface Emits {
  /**
   * 关闭打印抽屉的回调
   */
  (e: "close-print-drawer"): void;
}
const emit = defineEmits<Emits>();

// 抽屉显示标识
const drawerVisible = ref(false);
// 数据已加载标识
const isDataLoaded = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 打印数据,这里使用泛型数组 any[] ,可以灵活接收后端返回的各类打印数据,进行批量打印,从而实现打印组件通用
const printData = ref<any[]>([]);
// 先定义响应式数据,用于接收父组件传递过来的数据,再向后代组件传递数据,这样就会保持响应式
// 打印类型、模板、方向、类名
const printType = ref<PrintType>();
const printTemplate = ref<JJDTemplate | TestReportTemplate | WspjReportTemplate | null>(null);
const printDirection = ref<PrintDirection>(PrintDirection.PORTRAIT);
const printClassName = ref("");
// 打印按钮的宽度(也是打印页面的宽度)
// 纵向打印的宽度为 210mm(793.698px)
// 横向打印的宽度为 297mm(1122.520px)
const printBtnWidth = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1122.520px" : "793.698px";
});
// 抽屉尺寸大小
// 纵向打印抽屉尺寸大小为 210mm(793.698px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度) = 811.698px
// 横向打印抽屉尺寸大小为 297mm(1122.520px) + 18(0左边距 + 0右边距 + 18右边滚动条的宽度)= 1140.520px
const drawerSize = computed(() => {
  return props.printDirection === PrintDirection.LANDSCAPE ? "1140.520px" : "811.698px";
});
// 创建请求控制器 AbortController,用于取消请求
let controller: AbortController | null = null;
// 请求唯一ID,用于发送请求让后端取消获取打印数据
const requestId = ref("");

// 向后代组件提供数据
provide("printData", printData);
provide("printType", printType);
provide("printTemplate", printTemplate);
provide("printDirection", printDirection);

// 关闭打印抽屉
const onClosePrintDrawerClick = () => {
  // 清理前端资源
  cleanupFrontendResources();

  // 抽屉已关闭则直接退出
  if (!drawerVisible.value) {
    return;
  }

  // 数据尚未加载
  if (!isDataLoaded.value) {
    // 取消后端任务 - 取消打印
    cancelBackendTask();
  }

  // 通知父组件关闭打印抽屉
  emit("close-print-drawer");
  isDataLoaded.value = false;
};

// 取消后端任务 - 取消打印
const cancelBackendTask = () => {
  // 取消打印
  printCancelService(requestId.value);
};

// 清理前端资源
const cleanupFrontendResources = () => {
  // 前端取消请求,取消后不会接收响应,避免资源占用
  if (controller) {
    controller.abort();
    controller = null;
  }
};

// 渲染完成 - 后续逻辑
const RenderCompleted = () => {
  // 打印按钮解除禁用
  buttonPrintDisabled.value = false;
};

// 打印对象,vue3-print-nb,v-print="printObj"
const printObj = ref({
  id: "print-content",
  closeCallback() {
    // 关闭打印抽屉
    onClosePrintDrawerClick();
  }
});

// 添加页面卸载前的处理
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  if (requestId.value && !isDataLoaded.value) {
    cancelBackendTask();
  }
};

// 监听父组件传递过来的showDrawer值,控制抽屉的显示与隐藏
watch(
  () => props.printVisible,
  async (newValue) => {
    drawerVisible.value = newValue;
    if (drawerVisible.value) {
      printType.value = props.printType;
      if (props.printType === PrintType.JJD_BY_APPLY_ID_LIST) {
        printTemplate.value = props.printTemplate as JJDTemplate;
      } else {
        printTemplate.value = null;
      }
      printTemplate.value = props.printTemplate;
      printDirection.value = props.printDirection ? props.printDirection : PrintDirection.PORTRAIT;
      if (printTemplate.value === JJDTemplate.LANDSCAPE_SAMPLE) {
        printDirection.value = PrintDirection.LANDSCAPE;
      }
      printClassName.value = `${printType.value} ${printDirection.value}`;
      buttonPrintDisabled.value = true;

      // 生成请求唯一ID
      requestId.value = uuidv4();

      // 获取打印数据
      printData.value = [];
      // 父组件传递过来的 printSelection,如果就是打印数据,不需要处理,直接使用即可打印
      if (props.isPrintData) {
        printData.value = props.printSelection;
      } else {
        try {
          // 为当前新请求创建新的控制器
          controller = new AbortController();
          const result = await printService(
            props.printType,
            props.printSelection,
            props.printTemplate,
            controller.signal,
            requestId.value
          );

          // 检查请求是否已被取消,如果已取消则直接返回,不继续执行后续逻辑
          if (controller.signal.aborted) {
            return;
          }

          printData.value = result.data;
        } catch (error) {
          // 检查是否是由于请求取消导致的错误,如果是则直接返回
          if (error instanceof Error && error.name === "AbortError") {
            console.log("Request was cancelled");
            return;
          }
          console.log("printService error: ", error);
        }
      }

      // 检查抽屉是否仍然打开,如果已关闭则不继续执行后续逻辑
      if (!drawerVisible.value) {
        return;
      }

      // 数据加载完成,开始渲染DOM
      isDataLoaded.value = true;

      // 清理前端资源
      cleanupFrontendResources();

      // 确保DOM渲染完成
      await nextTick();

      // 渲染完成 - 后续逻辑
      RenderCompleted();
    }
  },
  { immediate: true }
);

onMounted(() => {
  window.addEventListener("beforeunload", handleBeforeUnload);
});

onUnmounted(() => {
  window.removeEventListener("beforeunload", handleBeforeUnload);
  cleanupFrontendResources();
});
</script>

<template>
  <div>
    <el-drawer
      v-model="drawerVisible"
      :with-header="true"
      :size="drawerSize"
      :close-on-click-modal="false"
      @close="onClosePrintDrawerClick">
      <div class="drawer-div">
        <el-button
          v-if="props.operateType === OperateType.PRINT"
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          v-print="printObj"
          >打印</el-button
        >
        <el-button
          v-else
          class="print-btn"
          type="primary"
          :disabled="buttonPrintDisabled"
          @click="onClosePrintDrawerClick"
          >关闭</el-button
        >
        <!-- 进度条 -->
        <BaseWebSocketProgress
          v-if="drawerVisible && !isDataLoaded"
          :url="`/ws/print/progress/${requestId}`"
          :count="props.printSelection.length" />
      </div>
      <!-- 打印,这里的 class 是动态值 -->
      <div id="print-content" :class="printDirection">
        <!-- 这里同时使用:父传子(props) :print-data="printData" 和 祖先传后代 provide("printData", printData) 这两种数据传递方式可以共存,但是props优先级高于provide -->
        <PrintContainer :print-data="printData" v-if="isDataLoaded" />
      </div>
    </el-drawer>
  </div>
</template>

<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
  margin-bottom: 0;
  padding: 0;
  height: 32px;
}
// 抽屉内容
:deep(.el-drawer__body) {
  padding: 0;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
  padding: 0;
}
.drawer-div {
  // 固定定位,固定在顶部
  position: fixed;
  // 与【浏览器可视区】顶部的垂直距离
  top: 0;
  .print-btn {
    width: v-bind("printBtnWidth");
  }
}

@media print {
  /* 打印通用样式 */
  @page {
    margin: 10mm;
  }

  /* 设定打印方向 */
  /* 纵向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 portrait 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .portrait {
    page: portrait-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page portrait-page 这样就不是影响全局了,而是指定的范围 portrait-page */
  @page portrait-page {
    /* 仅影响绑定了portrait-page 的元素 */
    size: A4 portrait;
  }
  /* 横向打印 */
  /* 通过 CSS 的 命名页面(Named Pages) 技术实现作用域隔离,实现 @page 样式仅影响当前组件 */
  /* 这里的 landscape 对应的是 <div id="print-content" :class="printClassName"> 的 class,是动态值 */
  .landscape {
    page: landscape-page; /* 绑定页面名称 */
  }
  /* 通过媒体样式 @media print 的 @page 设置打印方向, @page landscape-page 这样就不是影响全局了,而是指定的范围 landscape-page */
  @page landscape-page {
    /* 仅影响绑定了landscape-page 的元素 */
    size: A4 landscape;
  }
}
</style>
定义 WebSocket 相关的接口、枚举、类

src\types\WebSocket.ts

/**
 * WebSocket 消息接口
 * 定义 WebSocket 通信的消息格式
 * @template T 消息数据的类型,默认为any
 */
export interface WebSocketMessage<T = any> {
  /** 消息类型,用于区分不同的业务场景 */
  type: string;
  /** 消息数据,泛型T允许灵活定义数据结构 */
  data: T;
  /** 消息时间戳,用于记录消息创建时间 */
  timestamp: number;
}

/**
 * WebSocket 就绪状态枚举
 */
export enum WebSocketReadyState {
  /** 连接中 - WebSocket 正在建立连接 */
  CONNECTING = 0,
  /** 打开/已连接 - 连接已建立,可以通信 */
  OPEN = 1,
  /** 关闭中/断开中 - 连接正在关闭 */
  CLOSING = 2,
  /** 已关闭/已断开 - 连接已关闭或未能建立 */
  CLOSED = 3
}

/**
 * 托管 WebSocket 配置选项接口
 * 用于配置 ManagedWebSocket 的行为
 */
export interface ManagedWebSocketOptions {
  /** WebSocket 连接地址,如:ws://localhost:8080 */
  url: string;
  /** 可选的 WebSocket 子协议 */
  protocols?: string | string[];
  /** 是否自动重连,默认false */
  autoReconnect?: boolean;
  /** 重连间隔时间(毫秒),默认3000 */
  reconnectInterval?: number;
  /** 最大重连尝试次数,默认5次 */
  maxReconnectAttempts?: number;
  /** 心跳间隔时间(毫秒),不设置则不开启心跳 */
  heartbeatInterval?: number;
  /** 连接成功时的回调函数 */
  onOpen?: (event: Event) => void;
  /** 收到消息时的回调函数 */
  onMessage?: <T>(message: WebSocketMessage<T>) => void;
  /** 发生错误时的回调函数 */
  onError?: (event: Event) => void;
  /** 连接关闭时的回调函数 */
  onClose?: (event: CloseEvent) => void;
}

/**
 * 托管 WebSocket 类
 * 具备自动重连、心跳检测等管理功能的 WebSocket 封装
 * 
 * 功能特性:
 * - 自动重连机制(支持指数退避算法)
 * - 心跳保活机制
 * - 连接状态管理
 * - 错误处理和恢复
 * - 手动重连控制
 */
export class ManagedWebSocket {
  /** WebSocket实例 */
  private ws: WebSocket | null = null;
  /** 当前重连尝试次数 */
  private reconnectAttempts = 0;
  /** 心跳定时器ID */
  private heartbeatTimer: number | null = null;
  /** 是否手动关闭的标志,手动关闭时不自动重连 */
  private isManualClose = false;

  /**
   * 构造函数
   * @param options 配置选项
   */
  constructor(private options: ManagedWebSocketOptions) {
    // 创建实例时立即建立连接
    this.connect();
  }

  /**
   * 建立 WebSocket 连接
   * 创建 WebSocket 实例并设置事件处理器
   */
  private connect(): void {
    try {
      // 创建 WebSocket 实例
      this.ws = new WebSocket(this.options.url, this.options.protocols);
      // 设置事件处理器
      this.setupEventHandlers();
    } catch (error) {
      console.error('WebSocket connection failed:', error);
      // 连接失败时尝试重连
      this.handleReconnect();
    }
  }

  /**
   * 设置 WebSocket 事件处理器
   * 处理连接打开、消息接收、错误和关闭事件
   */
  private setupEventHandlers(): void {
    if (!this.ws) return;

    // 连接成功事件
    this.ws.onopen = (event: Event) => {
      console.log('WebSocket connected');
      // 重置重连计数器
      this.reconnectAttempts = 0;
      // 启动心跳检测
      this.startHeartbeat();
      // 调用用户提供的onOpen回调
      this.options.onOpen?.(event);
    };

    // 消息接收事件
    this.ws.onmessage = (event: MessageEvent) => {
      try {
        // 解析JSON字符串的消息
        const message: WebSocketMessage = JSON.parse(event.data);
        // 调用用户提供的onMessage回调
        this.options.onMessage?.(message);
        
        // 特殊处理:如果是心跳响应消息
        if (message.type === 'PONG') {
          this.handlePong();
        }
      } catch (error) {
        console.error('Failed to parse WebSocket message:', error);
      }
    };

    // 错误事件
    this.ws.onerror = (event: Event) => {
      console.error('WebSocket error:', event);
      // 调用用户提供的onError回调
      this.options.onError?.(event);
    };

    // 连接关闭事件
    this.ws.onclose = (event: CloseEvent) => {
      console.log('WebSocket closed:', event.code, event.reason);
      // 停止心跳检测
      this.stopHeartbeat();
      // 调用用户提供的onClose回调
      this.options.onClose?.(event);

      // 如果不是手动关闭且启用了自动重连,则尝试重连
      if (!this.isManualClose && this.options.autoReconnect) {
        this.handleReconnect();
      }
    };
  }

  /**
   * 处理重连逻辑
   * 使用指数退避算法避免频繁重连
   */
  private handleReconnect(): void {
    // 1. 检查是否超过最大重连次数
    const maxAttempts = this.options.maxReconnectAttempts || 5;
    if (this.reconnectAttempts >= maxAttempts) {
      console.error('Max reconnection attempts reached');
      return;
    }

    // 2. 计算重连间隔(指数退避算法)
    const baseInterval = this.options.reconnectInterval || 3000;
    // 重连间隔 = 基础间隔 × 1.5^重连次数,让重连间隔越来越长
    const backoffInterval = baseInterval * Math.pow(1.5, this.reconnectAttempts);

    console.log(`Attempting to reconnect in ${backoffInterval}ms (attempt ${this.reconnectAttempts + 1})`);
    
    // 3. 设置定时器进行重连
    setTimeout(() => {
      this.reconnectAttempts++;
      this.connect();
    }, backoffInterval);
  }

  /**
   * 启动心跳检测
   * 定期发送PING消息检测连接是否存活
   */
  private startHeartbeat(): void {
    // 如果没有设置心跳间隔,则不启动心跳
    if (!this.options.heartbeatInterval) return;

    // 设置定时器,定时发送心跳
    this.heartbeatTimer = window.setInterval(() => {
      if (this.isConnected()) {
        // 发送PING消息
        this.send({ 
          type: "PING", 
          data: null, 
          timestamp: Date.now() 
        });
      }
    }, this.options.heartbeatInterval);
  }

  /**
   * 停止心跳检测
   * 清理心跳定时器
   */
  private stopHeartbeat(): void {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  /**
   * 处理心跳响应
   * 接收到PONG消息时的处理,可用于更新最后活跃时间
   */
  private handlePong(): void {
    // 这里可以记录最后收到PONG的时间
    // 用于检测连接是否真的存活,如果长时间没收到PONG可以主动重连
    // 当前实现为空,可根据需要扩展
  }

  /**
   * 发送消息
   * @param message 要发送的消息
   * @returns 发送是否成功
   */
  public send<T>(message: WebSocketMessage<T>): boolean {
    // 检查连接状态
    if (!this.isConnected()) {
      console.error('WebSocket is not connected');
      return false;
    }

    try {
      // 将消息对象转换为JSON字符串并发送
      this.ws!.send(JSON.stringify(message));
      return true;
    } catch (error) {
      console.error('Failed to send WebSocket message:', error);
      return false;
    }
  }

  /**
   * 主动关闭 WebSocket 连接
   * @param code 关闭代码,默认1000(正常关闭)
   * @param reason 关闭原因
   */
  public close(code?: number, reason?: string): void {
    // 设置手动关闭标志,避免自动重连
    this.isManualClose = true;
    // 停止心跳检测
    this.stopHeartbeat();
    
    // 关闭 WebSocket 连接
    if (this.ws) {
      this.ws.close(code || 1000, reason);
    }
  }

  /**
   * 检查连接是否处于打开状态
   * @returns 是否已连接
   */
  public isConnected(): boolean {
    return this.ws?.readyState === WebSocketReadyState.OPEN;
  }

  /**
   * 获取当前 WebSocket 状态
   * @returns WebSocket 状态枚举值
   */
  public getReadyState(): WebSocketReadyState {
    return this.ws?.readyState ?? WebSocketReadyState.CLOSED;
  }

  /**
   * 手动触发重连
   * 用于网络恢复等场景下的主动重连
   */
  public reconnect(): void {
    // 重置手动关闭标志,允许重连
    this.isManualClose = false;
    // 重置重连计数器
    this.reconnectAttempts = 0;
    
    // 如果存在现有连接,先关闭
    if (this.ws) {
      this.ws.close();
    }
    
    // 建立新连接
    this.connect();
  }
}

src\types\index.ts

export * from "./WebSocket";
WebSocket 进度 Hook

src\hooks\useWebSocketProgress.ts

import { useTokenStore } from "@/stores";
import { ManagedWebSocket } from "@/types";
import { onUnmounted, ref, watch } from "vue";

/**
 * WebSocket 进度 Hook
 */
export function useWebSocketProgress() {
  // 进度从1开始,增强使用体验,感觉一开始就工作了
  const progressValue = ref(1);
  const { token } = useTokenStore();
  // 连接状态
  const isConnecting = ref(false);
  const isConnected = ref(false);

  let ws: ManagedWebSocket | null = null;

  /**
   * 开始进度
   * 初始化 WebSocket 连接,并设置接收消息时的处理逻辑(计算进度)
   * @param url 连接路径
   * @param taskCount 任务数,默认为1(默认值本身就表示该参数是可选的)
   */
  const startProgress = async (url: string, taskCount: number = 1) => {
    // 数据校验
    if (!url) {
      throw new Error("无效的URL");
    }
    if (!taskCount || taskCount <= 0) {
      throw new Error("无效的任务数");
    }

    // 重置状态
    // 如果已经在连接中,先停止之前的连接
    if (isConnecting.value || isConnected.value) {
      stopProgress();
    }
    isConnecting.value = true;
    progressValue.value = 1;

    // 构建最终的 URL(包含 token 参数)
    let finalUrl = encodeURI(url);
    if (token) {
      const separator = finalUrl.includes("?") ? "&" : "?";
      finalUrl += `${separator}token=${encodeURIComponent(token)}`;
    }

    return new Promise((resolve, reject) => {
      try {
        // 实例化 ManagedWebSocket(被封装的WebSocket)
        ws = new ManagedWebSocket({
          url: finalUrl,
          heartbeatInterval: 30000, // 心跳间隔30秒,在ManagedWebSocket类中已经封装心跳逻辑【启动心跳,每 30 秒发送一次心跳】,这里设置后就会生效,自动启动心跳

          // 收到消息时的回调
          onMessage: (message) => {
            if (message.type === "UPDATE_PROGRESS" && taskCount) {
              // 计算进度
              const progress = Math.trunc(((message.data as number) * 100) / taskCount);
              // 确保进度不倒退,确保进度大于1并且不超过99(确保进度条不满100%,保持显示,直到数据加载完毕页面渲染完成,进度才设置为100,从而触发隐藏)
              progressValue.value = Math.min(Math.max(progressValue.value, Math.max(progress, 1)), 99);
            }
          },

          // 连接成功时的回调
          onOpen: () => {
            console.log("WebSocket进度已连接");
            isConnecting.value = false;
            isConnected.value = true;
            resolve(null);
          },

          // 发出错误时的回调
          onError: (error) => {
            console.error("WebSocket进度出错了:", error);
            isConnecting.value = false;
            isConnected.value = false;
            reject(error);
          },

          // 连接关闭时的回调
          onClose: () => {
            console.log("WebSocket进度已关闭");
            isConnecting.value = false;
            isConnected.value = false;
          }
        });
      } catch (error) {
        console.error("创建WebSocket进度失败:", error);
        isConnecting.value = false;
        reject(error);
      }
    });
  };

  /**
   * 完成进度
   */
  const completeProgress = (): void => {
    progressValue.value = 100;
  };

  /**
   * 停止进度
   */
  const stopProgress = () => {
    // 断开连接
    if (ws) {
      ws.close();
      ws = null;
    }
    isConnecting.value = false;
    isConnected.value = false;
  };

  // 监听进度值变化,当进度100时停止进度
  watch(
    () => progressValue.value,
    () => {
      if (progressValue.value === 100) {
        console.log("进度完成,自动停止WebSocket进度");
        stopProgress();
      }
    }
  );

  // 组件卸载时停止进度
  onUnmounted(() => {
    console.log("组件卸载,触发停止WebSocket进度");
    stopProgress();
  });

  return {
    progressValue,
    isConnecting,
    isConnected,
    startProgress,
    stopProgress,
    completeProgress
  };
}

export default useWebSocketProgress;

src\hooks\index.ts

export { default as useWebSocketProgress } from "./useWebSocketProgress";
WebSocket 进度条组件

src\components\base\BaseWebSocketProgress.vue

<script setup lang="ts">
/**
 * WebSocket 进度条组件
 *
 * 使用示例:
 * <BaseWebSocketProgress :url="url" :count="taskCount" />
 */
defineOptions({
  name: "BaseWebSocketProgress"
});
import { useWebSocketProgress } from "@/hooks";
import { onMounted } from "vue";

interface Props {
  /**
   * WebSocket 连接地址
   */
  url: string;
  /**
   * 任务数量
   */
  count: number;
}
const props = withDefaults(defineProps<Props>(), {
  count: 1
});
const { progressValue, startProgress } = useWebSocketProgress();

// 格式化进度显示的内容
const progressFormat = (val: number) => {
  return `正在加载数据 ${val.toFixed(0)}%`;
};

onMounted(async () => {
  await startProgress(props.url, props.count);
});
</script>

<template>
  <el-progress
    v-show="progressValue !== 100"
    :text-inside="true"
    :stroke-width="20"
    :percentage="progressValue"
    status="success"
    :format="progressFormat">
  </el-progress>
</template>

src\components\index.ts

export { default as BaseWebSocketProgress } from "./base/BaseWebSocketProgress.vue";

后端

安装依赖

pom.xml

    <!--websocket依赖-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>

    <!--fastjson-->
    <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>1.2.83</version>
    </dependency>
配置类

src/main/java/com/weiyu/config/WebSocketConfig.java

package com.weiyu.config;

import com.weiyu.handler.SessionWebSocketHandler;
import com.weiyu.interceptors.AuthWebSocketHandshakeInterceptor;
import com.weiyu.interceptors.PathParamWebSocketHandshakeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;

/**
 * WebSocket 配置类
 */
@Configuration
@EnableWebSocket // 开启 WebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private SessionWebSocketHandler sessionWebSocketHandler;

    @Autowired
    private PathParamWebSocketHandshakeInterceptor pathParamWebSocketHandshakeInterceptor;

    @Autowired
    private AuthWebSocketHandshakeInterceptor authWebSocketHandshakeInterceptor;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 这个方法的调用是由 @EnableWebSocket 触发的
        // 登录拦截器(LoginInterceptor/HandlerInterceptor)com/weiyu/interceptors/LoginInterceptor.java
        // 当前这个WebSocket请求路径:/ws/print/progress,有注册处理器,这个路径不会被登录拦截器拦截
        // 例如有个WebSocket请求路径:/ws/check/progress,无注册处理器,这个路径就会被登录拦截器拦截
        registry.addHandler(sessionWebSocketHandler, "/ws/print/progress/**")
                .addInterceptors(authWebSocketHandshakeInterceptor)         // 添加用户认证握手拦截器
                .addInterceptors(pathParamWebSocketHandshakeInterceptor)    // 添加路径参数握手拦截器
                // 解决跨域,如果是 Vite 代理这里必须要设置,如果是 Nginx 代理这里不用设置
                .setAllowedOrigins(
                        "http://localhost:5173",    // Vite 默认端口
                        "http://127.0.0.1:5173"     // 或者用 IP
                );
    }

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        // 这个 Bean 的创建是由 @EnableWebSocket 触发的
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(8192);    // 文本消息流量控制
        container.setMaxBinaryMessageBufferSize(8192);  // 流消息流量控制
        container.setMaxSessionIdleTimeout(300000L);    // 5分钟超时
        return container;
    }
}
处理器

src/main/java/com/weiyu/handler/RequestContextHandler.java

package com.weiyu.handler;

import com.weiyu.exception.RequestCancelledException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * 请求上下文处理器
 */
@Component
@Slf4j
public class RequestContextHandler {

    /**
     * 请求取消标识
     */
    private final Map<String, AtomicBoolean> requestCancelFlags = new ConcurrentHashMap<>();

    /**
     * 请求线程
     */
    private final Map<String, Thread> requestThreads = new ConcurrentHashMap<>();

    /**
     * 请求进度
     */
    private final Map<String, Integer> requestProgresses = new ConcurrentHashMap<>();

    /**
     * 请求开始时间,用于超时检查
     */
    private final Map<String, LocalDateTime> requestStartTimes = new ConcurrentHashMap<>();

    /**
     * 默认超时时间(秒)
     */
    private static final long DEFAULT_TIMEOUT_SECONDS = 60;

    /**
     * 注册请求,增加 Map 记录
     *
     * @param requestId 请求唯一id
     */
    public void registerRequest(String requestId) {
        requestCancelFlags.put(requestId, new AtomicBoolean(false));
        requestThreads.put(requestId, Thread.currentThread());
        requestProgresses.put(requestId, 0);
        requestStartTimes.put(requestId, LocalDateTime.now());
        log.info("已注册请求 {}", requestId);
    }

    /**
     * 清理请求,移除 Map 记录
     *
     * @param requestId 请求唯一id
     */
    public void cleanupRequest(String requestId) {
        requestCancelFlags.remove(requestId);
        requestThreads.remove(requestId);
        requestProgresses.remove(requestId);
        requestStartTimes.remove(requestId);
        log.info("已清理请求 {}", requestId);
    }

    /**
     * 取消请求
     *
     * @param requestId 请求唯一id
     */
    public void cancelRequest(String requestId) {
        // 通过请求唯一id获取取消标识
        AtomicBoolean flag = requestCancelFlags.get(requestId);
        if (flag != null) {
            // 设置为已被取消
            flag.set(true);

            // 通过请求唯一id获取线程
            Thread thread = requestThreads.get(requestId);
            if (thread != null && thread.isAlive()) {
                // 中断线程
                thread.interrupt();
                log.info("已中断请求线程 {}", requestId);
            }
        }
    }

    /**
     * 检查是否已被取消
     *
     * @param requestId 请求唯一id
     */
    public void checkCancellation(String requestId) {
        if (isCancelled(requestId)) {
            throw new RequestCancelledException("请求已被取消");
        }
    }

    /**
     * 更新请求进度
     *
     * @param requestId 请求唯一id
     * @param progress  进度
     */
    public void updateProgress(String requestId, Integer progress) {
        if (progress != null) {
            requestProgresses.put(requestId, progress);
        }
    }

    /**
     * 获取请求进度
     *
     * @param requestId 请求唯一id
     */
    public Optional<Integer> getProgress(String requestId) {
        return Optional.ofNullable(requestProgresses.get(requestId));
    }

    /**
     * 更新请求开始时间
     *
     * @param requestId 请求唯一id
     */
    public void updateRequestStartTime(String requestId) {
        requestStartTimes.put(requestId, LocalDateTime.now());
    }

    /**
     * 检查请求是否超时
     *
     * @param requestId      请求唯一id
     * @param timeoutSeconds 超时时间(秒)
     */
    public boolean isRequestTimeout(String requestId, long timeoutSeconds) {
        LocalDateTime startTime = requestStartTimes.get(requestId);
        if (startTime == null) {
            return false; // 请求不存在,不算超时
        }

        LocalDateTime timeoutTime = startTime.plusSeconds(timeoutSeconds);

        return LocalDateTime.now().isAfter(timeoutTime);
    }

    /**
     * 取消所有超时请求(自定义超时时间)
     *
     * @param timeoutSeconds 超时时间(秒)
     */
    public void cancelTimeoutRequests(long timeoutSeconds) {
        int cancelledCount = 0;

        for (String requestId : requestStartTimes.keySet()) {
            if (isRequestTimeout(requestId, timeoutSeconds)) {
                cancelRequest(requestId);
                cancelledCount++;
            }
        }

        if (cancelledCount > 0) {
            log.info("已自动取消超时请求数: {}", cancelledCount);
        }
    }

    /**
     * 定时任务:每 30 秒检查一次超时请求
     */
    @Scheduled(fixedRate = 30 * 1000)
    public void scheduledTimeoutCheck() {
        // 取消所有超时请求
        cancelTimeoutRequests(DEFAULT_TIMEOUT_SECONDS);
    }

    /**
     * 判断是否已被取消
     *
     * @param requestId 请求唯一id
     */
    private boolean isCancelled(String requestId) {
        AtomicBoolean flag = requestCancelFlags.get(requestId);
        return flag != null && flag.get();
    }
}

src/main/java/com/weiyu/handler/SessionWebSocketHandler.java

package com.weiyu.handler;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.weiyu.pojo.WebSocketMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.lang.NonNull;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * WebSocket 会话处理器
 */
@Component
@Slf4j
public class SessionWebSocketHandler extends TextWebSocketHandler {

    @Autowired
    private RequestContextHandler requestContextHandler;

    /**
     * 会话列表,使用线程安全的Map来管理多个会话,key为requestId
     */
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    // 连接建立后
    @Override
    public void afterConnectionEstablished(@NonNull WebSocketSession session) throws IOException {
        // 从 session 属性中获取请求唯一id requestId
        String requestId = (String) session.getAttributes().get("requestId");

        if (requestId == null || requestId.trim().isEmpty()) {
            log.warn("WebSocket 连接建立失败:未获取到 requestId,session = {}", session);
            session.close();
            return;
        }

        // 创建连接成功消息
        WebSocketMessage<String> message = new WebSocketMessage<>("CONNECTED",
                "WebSocket 连接成功,requestId = " + requestId + ",session = " + session);

        // java对象 转换成 json字符串
        String jsonMessage = JSON.toJSONString(message, SerializerFeature.WriteMapNullValue);

        // 发送连接成功消息
        session.sendMessage(new TextMessage(jsonMessage));
        log.info("WebSocket 连接成功,requestId = {}, session = {}", requestId, session.getId());

        // 保存会话
        sessions.put(requestId, session);
    }

    // 收到消息时
    @Override
    public void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {
        // 解析收到的消息(json字符串),获取内容
        String payload = message.getPayload();
        log.info("Received WebSocket message: {}", payload);

        // json字符串 转换成 java对象
        WebSocketMessage<?> wsMessage = JSON.parseObject(payload, WebSocketMessage.class);

        // 收到心跳消息
        if (wsMessage.getType().equals("PING")) {
            // 从 session 属性中获取 请求唯一id requestId
            String requestId = (String) session.getAttributes().get("requestId");
            // 更新请求开始时间,心跳一下,保持活跃
            requestContextHandler.updateRequestStartTime(requestId);
        }
    }

    // 发送进度更新通知 - 在任务执行过程中调用
    public void sendProgressUpdate(String requestId, int progress) throws IOException {
        WebSocketSession currentSession = sessions.get(requestId);
        if (currentSession == null || !currentSession.isOpen()) {
            return;
        }

        // 创建进度消息
        WebSocketMessage<Integer> progressMessage = new WebSocketMessage<>("UPDATE_PROGRESS", progress);

        // java对象 转换成 json字符串
        String jsonMessage = JSON.toJSONString(progressMessage, SerializerFeature.WriteMapNullValue);

        // 发送进度消息
        currentSession.sendMessage(new TextMessage(jsonMessage));
    }
}
拦截器

src/main/java/com/weiyu/interceptors/AuthWebSocketHandshakeInterceptor.java

package com.weiyu.interceptors;

import com.weiyu.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.Map;

/**
 * WebSocket 认证握手拦截器
 */
@Component
@Slf4j
public class AuthWebSocketHandshakeInterceptor implements HandshakeInterceptor {

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    public boolean beforeHandshake(@NonNull ServerHttpRequest request,
                                   @NonNull ServerHttpResponse response,
                                   @NonNull WebSocketHandler wsHandler,
                                   @NonNull Map<String, Object> attributes) {
        String token = null;

        if (request instanceof ServletServerHttpRequest servletRequest) {
            // 从查询参数获取 token
            token = servletRequest.getServletRequest().getParameter("token");
        }

        // 验证 token
        try {
            Map<String, Object> claims = jwtUtil.parseToken(token);
            String userName = (String) claims.get("userName");
            attributes.put("userName", userName);
            log.info("WebSocket 用户认证成功: userName = {}", userName);
            return true;
        } catch (Exception e) {
            log.error("Token validation failed: {}", e.getMessage());
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;
        }
    }

    @Override
    public void afterHandshake(@NonNull ServerHttpRequest request,
                               @NonNull ServerHttpResponse response,
                               @NonNull WebSocketHandler wsHandler,
                               Exception exception) {

    }
}

src/main/java/com/weiyu/interceptors/PathParamWebSocketHandshakeInterceptor.java

package com.weiyu.interceptors;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * WebSocket 路径参数握手拦截器
 */
@Component
@Slf4j
public class PathParamWebSocketHandshakeInterceptor implements HandshakeInterceptor {

    private static final Pattern PATH_PATTERN = Pattern.compile("/ws/print/progress/([^/]+)");

    @Override
    public boolean beforeHandshake(@NonNull ServerHttpRequest request,
                                   @NonNull ServerHttpResponse response,
                                   @NonNull WebSocketHandler wsHandler,
                                   @NonNull Map<String, Object> attributes) {
        String path = request.getURI().getPath();
        log.info("WebSocket 连接路径: {}", path);

        // 使用正则表达式提取请求唯一id requestId,并且放入属性 attributes 中
        Matcher matcher = PATH_PATTERN.matcher(path);
        if (matcher.matches()) {
            String requestId = matcher.group(1);
            attributes.put("requestId", requestId);
            log.info("提取到 requestId: {}", requestId);
        } else {
            log.warn("WebSocket路径格式不匹配: {}", path);
        }

        return true;
    }

    @Override
    public void afterHandshake(@NonNull ServerHttpRequest request,
                               @NonNull ServerHttpResponse response,
                               @NonNull WebSocketHandler wsHandler,
                               Exception exception) {

    }
}
控制器

src/main/java/com/weiyu/controller/PrintController.java

/**
 * 打印控制器
 */
@RestController
@RequestMapping("/print")
@Slf4j
public class PrintController {

    @Autowired
    private PrintService printService;

    @Autowired
    private RequestContextHandler requestContextHandler;

    /**
     * 获取报告打印数据
     *
     * @param reports 报告列表
     * @param request 请求对象,可以访问请求头属性,获取请求唯一id(X-Request-Id)
     * @return 报告打印数据列表 {@link Result}&lt;{@link List}&lt;{@link ReportPrintDataVO}&gt;&gt;
     */
    @PostMapping("/report")
    public Result<?> printReport(@RequestBody List<Report> reports, HttpServletRequest request) {
        String requestId = request.getHeader("X-Request-Id");
        log.info("【报告编制/打印发放/查询】,查看/打印报告,/print/report,reports = {}, requestId = {}", reports, requestId);

        try {
            if (requestId != null && !requestId.isEmpty()) {
                requestContextHandler.registerRequest(requestId);
            }

            // 执行长时间运行的报告打印任务
            List<ReportPrintDataVO> printDatas = printService.printReport(reports, requestId);
            log.info("报告打印数据 = {}", printDatas);
            return Result.success(printDatas);

        } finally {
            if (requestId != null && !requestId.isEmpty()) {
                requestContextHandler.cleanupRequest(requestId);
            }
        }
    }

     /**
     * 打印进度
     *
     * @param requestId 请求唯一id,直接通过 @RequestHeader("X-Request-Id") 获取
     * @return 进度值
     */
    @GetMapping("/progress")
    public Result<Optional<Integer>> printProgress(@RequestHeader("X-Request-Id") String requestId) {
        log.info("【打印进度】,进度,/print/progress,requestId = {}", requestId);
        return Result.success(requestContextHandler.getProgress(requestId));
    }
}
服务层

src/main/java/com/weiyu/service/impl/PrintServiceImpl.java

/**
 * 打印 Servive 接口实现
 */
@Service
@Slf4j
public class PrintServiceImpl implements PrintService {

    @Autowired
    private TestReportMapper testReportMapper;

    @Autowired
    private WspjReporMapper wspjReporMapper;

    @Autowired
    private RequestContextHandler requestContextHandler;

    @Autowired
    private SessionWebSocketHandler sessionWebSocketHandler;

    /**
     * 获取报告打印数据
     */
    @Override
    public List<ReportPrintDataVO> printReport(List<Report> reports, String requestId) {
        List<ReportPrintDataVO> printDatas = new ArrayList<>();
        int progress = 0;

        // 检查锚点1和2均匀分布,之前检查锚点1设在循环体最上面,基本上都是由检查锚点2抛出异常
        for (Report report : reports) {
            // 获取评价报告打印数据
            List<WspjReport> wspjReports = wspjReporMapper.selectByOuterApplyId(report.getOuterApplyId());
            List<WspjReportPrintDataVO> wspjReportPrintDatas = getWspjReportPrintData(wspjReports, requestId, false);

            // 检查锚点1:检查当前请求是否已被取消
            requestContextHandler.checkCancellation(requestId);

            // 获取检验报告打印数据
            List<TestReport> testReports = testReportMapper.selectByOuterApplyId(report.getOuterApplyId());
            List<TestReportPrintDataVO> testReportPrintDatas = getTestReportPrintData(testReports, requestId, false);

            printDatas.add(new ReportPrintDataVO(wspjReportPrintDatas, testReportPrintDatas));

            ++progress;

            // 轮询更新进度(适用于 http/https)
            requestContextHandler.updateProgress(requestId, progress);

            // 发送进度更新(适用于 WebSocket)
            try {
                sessionWebSocketHandler.sendProgressUpdate(requestId, progress);
            }catch (Exception ignored){
            }

            // 检查锚点2:检查当前请求线程是否已被中断,多加一层检查,双重检查机制,双重保障
            if (Thread.currentThread().isInterrupted()) {
                throw new RequestCancelledException("取消请求任务已被中断");
            }
        }

        return printDatas;
    }
}

应用代理

Vite 代理配置
import { fileURLToPath, URL } from "node:url";

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// 引入插件 vite-plugin-vue-setup-extend,.vue 文件中,<script> 标签中就可以使用 name 属性,如:<script setup lang="ts" name="Login">
import VueSetupExtend from "vite-plugin-vue-setup-extend";
import VueDevTools from "vite-plugin-vue-devtools";

// https://vitejs.dev/config/
export default defineConfig({
  // 插件配置
  plugins: [vue(), VueSetupExtend(), VueDevTools()],
  // 解析配置
  resolve: {
    // 设置路径别名
    alias: {
      // new URL('./src', import.meta.url) 创建从当前文件到 ./src 目录的绝对URL
      // fileURLToPath() 将 URL 转换为文件系统路径
      // @ 作为别名,指向 src 目录的绝对路径
      "@": fileURLToPath(new URL("./src", import.meta.url)),
      "@api": fileURLToPath(new URL("./src/api", import.meta.url)),
      "@assets": fileURLToPath(new URL("./src/assets", import.meta.url)),
      "@components": fileURLToPath(new URL("./src/components", import.meta.url)),
      "@hooks": fileURLToPath(new URL("./src/hooks", import.meta.url)),
      "@router": fileURLToPath(new URL("./src/router", import.meta.url)),
      "@stores": fileURLToPath(new URL("./src/stores", import.meta.url)),
      "@types": fileURLToPath(new URL("./src/types", import.meta.url)),
      "@utils": fileURLToPath(new URL("./src/utils", import.meta.url)),
      "@views": fileURLToPath(new URL("./src/views", import.meta.url))
    }
  },
  // 服务配置
  server: {
    // 指定端口,vite默认 5173
    port: 5173,
    // 指定服务器监听的 IP 地址,如果将此设置为 0.0.0.0 或者 true 将监听所有地址
    host: "0.0.0.0",
    // 代理设置
    proxy: {
      // 【代理1】获取路径中包含有 /api 的请求
      "/api": {
        // 后台服务所在的源
        target: "http://localhost:8080",
        // 修改源
        changeOrigin: true,
        // 将 api 替换为 '',如:/api/account/login -> /account/login,最终代理访问的 URL 为 http://localhost:8080/account/login
        rewrite: (path) => path.replace(/^\/api/, "")
      },
      // 【代理2】获取路径中包含有 /apu 的请求,【上传数据】用于前端网面对外请求发送数据
      "/apu": {
        // 后台服务所在的源
        target: "http://localhost:8081",
        // 修改源
        changeOrigin: true,
        // 将 apu 替换为 '',如:/apu/sampleItemResult/upload -> /sampleItemResult/upload,最终代理访问的 URL 为 http://localhost:8081/sampleItemResult/upload
        rewrite: (path) => path.replace(/^\/apu/, "")
      },
      // 【代理3】获取路径中包含有 /ws 的请求,WebSocket双向通信专用路径
      "/ws": {
        // 后台服务所在的源
        target: "http://localhost:8080",
        // 修改源
        changeOrigin: true,
        ws: true // 重要:启用 WebSocket 代理
      }
    }
  }
});
Nginx 代理配置

#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    server {
        listen       8025;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;


        location / {
            root   html;
            index  index.html index.htm;

            # 处理点击页面刷新,提示404的问题
            try_files $uri $uri/ /index.html;

            # 核心解决方案:禁用缓存
            add_header Cache-Control "no-cache, no-store, must-revalidate";
            add_header Pragma "no-cache";
            expires -1;
        }

        # 设置代理
        # 处理跨域问题,nginx 用的是 8025 端口,tomcat 用的是 8080 端口,这就存在跨域的情况
        # 将请求路径中包含有 /api/ 的,将其前面和其内容替换为 http://localhost:8080/
        # 比如:http://localhost/api/test
        # 替换:http://localhost:8080/test
        location /api/ {
		  proxy_pass http://localhost:8080/;

            # 设置用户客户端的请求头
            # $host:表示HTTP请求中的Host头字段(用户客户端请求的目标域名或IP,不包含端口号)
            # 如 http://example.com,$host 的值为 example.com
            # 如 http://example.com:5173,$host 的值为 example.com
            # 如 http://192.168.31.42,$host 的值为 192.168.31.42
            # 如 http://192.168.31.42:5173,$host 的值为 192.168.31.42
            proxy_set_header Host $host;

            # $remote_addr: 客户端的直接IP地址(与Nginx直接建立连接的地址)
            # 若请求经过代理(如CDN、负载均衡器),$remote_addr为最后一个代理的IP,而非用户客户端真实IP。
            proxy_set_header X-Real-IP $remote_addr;

            # $proxy_add_x_forwarded_for: 用户客户端真实IP地址(通过代理传递给服务器)
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            # $scheme: 请求协议(http或https)
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # WebSocket代理
        location /ws/ {
		  # 保留 /ws/ 路径
		  proxy_pass http://localhost:8080/ws/;
		  proxy_http_version 1.1;
		  proxy_set_header Upgrade $http_upgrade;
		  proxy_set_header Connection "upgrade";
		  proxy_set_header Host $host;


		  # 关键修正:移除Origin头,让后端认为是同源请求
		  proxy_set_header Origin "";


		  # 修正协议和端口设置
		  proxy_set_header X-Real-IP $remote_addr;
		  proxy_set_header X-Forwarded-Proto $scheme;
		  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		  proxy_set_header X-Forwarded-Port $server_port;

		  # 添加超时设置
		  proxy_read_timeout 60s;
		  proxy_send_timeout 60s;
		  proxy_connect_timeout 60s;

		  # 重要:禁用缓存
		  proxy_buffering off;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ~ \.php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ~ \.php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /\.ht {
        #    deny  all;
        #}
    }


    # another virtual host using mix of IP-, name-, and port-based configuration
    #
    #server {
    #    listen       8000;
    #    listen       somename:8080;
    #    server_name  somename  alias  another.alias;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}


    # HTTPS server
    #
    #server {
    #    listen       443 ssl;
    #    server_name  localhost;

    #    ssl_certificate      cert.pem;
    #    ssl_certificate_key  cert.key;

    #    ssl_session_cache    shared:SSL:1m;
    #    ssl_session_timeout  5m;

    #    ssl_ciphers  HIGH:!aNULL:!MD5;
    #    ssl_prefer_server_ciphers  on;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}

}

三、高阶优化二(完整代码)

请求上下文处理器 RequestContextHandler

src/main/java/com/weiyu/handler/RequestContextHandler.java

    /**
     * 定时任务:每 30 秒检查一次超时请求
     */
    @Scheduled(fixedRate = 30 * 1000)
    public void scheduledTimeoutCheck() {
        // 取消所有超时请求
        cancelTimeoutRequests(DEFAULT_TIMEOUT_SECONDS);
    }

需求目标:

  1. 定时任务的检查间隔由目前固定的30秒,升级为支持在配置文件中设置,也支持在数据库配置表中设置,优先数据库配置。
  2. 超时时间由目前固定的内部变量,升级为支持在配置文件中设置,也支持在数据库配置表中设置,优先数据库配置。

1、查看数据库原有的系统配置表(没有则新建)

2、系统配置实体类

src/main/java/com/weiyu/model/SysConfig.java

package com.weiyu.model;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

/**
 * 系统配置
 */
@Data
@TableName("SystemOptions")
public class SysConfig {

    @TableField("sop_OptionItemID")
    private String configKey;       // 配置项

    @TableField("sop_OptionValue")
    private String configValue;     // 配置值
}

3、系统配置 Mapper

src/main/java/com/weiyu/mapper/SysConfigMapper.java

package com.weiyu.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.weiyu.model.SysConfig;
import org.apache.ibatis.annotations.Select;

import java.util.List;
import java.util.Map;

/**
 * 系统配置
 */
public interface SysConfigMapper extends BaseMapper<SysConfig> {

    /**
     * 根据配置键查询配置值
     */
    @Select("select sop_OptionValue from SystemOptions where sop_OptionItemID = #{key}")
    String selectValueByKey(String key);

    /**
     * 查询所有启用的配置
     */
    @Select("select sop_OptionItemID, sop_OptionValue from SystemOptions where sop_OptionValue is not null")
    List<Map<String, String>> selectAllEnabledConfigs();
}

4、系统(数据库)配置服务

src/main/java/com/weiyu/service/SysConfigService.java

package com.weiyu.service;

import com.weiyu.mapper.SysConfigMapper;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 系统(数据库)配置服务
 */
@Service
@Slf4j
public class SysConfigService {

    @Autowired
    private SysConfigMapper sysConfigMapper;

    // 配置缓存
    private final Map<String, String> configCache = new ConcurrentHashMap<>();

    /**
     * 获取字符串配置
     */
    public String getString(String key, String defaultValue) {
        try {
            // 缓存没有配置,则从数据库配置表中查询获取
            return configCache.computeIfAbsent(key, k -> {
                String value = sysConfigMapper.selectValueByKey(k);
                return value != null ? value : defaultValue;
            });
        } catch (Exception e) {
            log.warn("获取数据库配置失败 key: {}, 使用默认值: {}", key, defaultValue, e);
            return defaultValue;
        }
    }

    /**
     * 获取整数配置
     */
    public Integer getInteger(String key, Integer defaultValue) {
        String value = getString(key, null);
        if (value == null) return defaultValue;
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            log.warn("数据库配置值格式错误 key: {}, value: {}", key, value);
            return defaultValue;
        }
    }

    /**
     * 获取长整型配置
     */
    public Long getLong(String key, Long defaultValue) {
        String value = getString(key, null);
        if (value == null) return defaultValue;
        try {
            return Long.parseLong(value);
        } catch (NumberFormatException e) {
            log.warn("数据库配置值格式有误 key: {}, value: {}", key, value);
            return defaultValue;
        }
    }

    /**
     * 获取布尔配置
     */
    public Boolean getBoolean(String key, Boolean defaultValue) {
        String value = getString(key, null);
        if (value == null) return defaultValue;
        return Boolean.parseBoolean(value);
    }

    /**
     * 刷新配置缓存
     */
    public void refreshCache() {
        log.info("刷新数据库配置缓存");
        configCache.clear();
        List<Map<String, String>> configs = sysConfigMapper.selectAllEnabledConfigs();
        for (Map<String, String> config : configs) {
            String key = config.get("sop_OptionItemID");
            String value = config.get("sop_OptionValue");

            // 添加空值检查
            if (key != null && value != null) {
                configCache.put(key, value);
            } else {
                log.warn("跳过无效配置项: key={}, value={}", key, value);
            }
        }
        log.info("数据库配置缓存刷新完成,共加载 {} 个配置项", configCache.size());
    }

    /**
     * 应用启动时初始化配置缓存
     */
    @PostConstruct
    public void init() {
        refreshCache();
    }
}

5、系统配置控制器(可选)

src/main/java/com/weiyu/controller/SysConfigController.java

package com.weiyu.controller;

import com.weiyu.service.CompositeConfigService;
import com.weiyu.service.SysConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * 系统配置控制器
 */
@RestController
@RequestMapping("/config")
@Slf4j
public class SysConfigController {

    @Autowired
    private CompositeConfigService compositeConfigService;

    @Autowired
    private SysConfigService sysConfigService;

    /**
     * 获取当前所有配置信息
     */
    @GetMapping("/current")
    public Map<String, Object> getCurrentConfig() {
        return compositeConfigService.getCurrentConfig();
    }

    /**
     * 刷新配置缓存
     */
    @PostMapping("/refresh")
    public String refreshConfig() {
        compositeConfigService.refreshAllConfig();
        return "配置刷新成功";
    }

    /**
     * 获取数据库配置缓存信息
     */
    @GetMapping("/db-cache")
    public Map<String, String> getDbConfigCache() {
        // 注意:这里返回的是缓存内容,实际项目中可能需要保护
        // 可以通过反射获取,或者修改SysConfigService提供获取缓存的方法
        return Map.of("message", "数据库配置缓存信息受保护");
    }
}

6、超时检查配置属性

src/main/java/com/weiyu/config/properties/TimeoutProperties.java

package com.weiyu.config.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 超时检查配置属性 - 从 application.yml 读取
 */
@Data
@Component
@ConfigurationProperties(prefix = "app.timeout")
public class TimeoutProperties {

    /**
     * 超时检查间隔(毫秒)
     */
    private Long checkIntervalMs = 1200000L; // 默认20分种

    /**
     * 请求超时时间(秒)
     */
    private Integer requestTimeoutSeconds = 1800; // 默认30分钟

    /**
     * 是否启用超时检查
     */
    private Boolean enabled = true;
}

7、综合配置服务 - 整合yml配置和数据库配置

src/main/java/com/weiyu/service/CompositeConfigService.java

package com.weiyu.service;

import com.weiyu.config.properties.TimeoutProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

/**
 * 综合配置服务 - 整合yml配置和数据库配置
 * 优先级:数据库配置 > 文件配置 > 代码默认值
 */
@Service
@Slf4j
public class CompositeConfigService {

    @Autowired
    private TimeoutProperties timeoutProperties;

    @Autowired
    private SysConfigService sysConfigService;

    // 配置键常量
    private static final String KEY_CHECK_INTERVAL_MS = "app.timeout.check-interval-ms";
    private static final String KEY_REQUEST_TIMEOUT_SECONDS = "app.timeout.request-timeout-seconds";
    private static final String KEY_CHECK_ENABLED = "app.timeout.enabled";

    /**
     * 获取检查间隔(毫秒)
     * 优先级:数据库 > 文件 > 默认值
     */
    public Long getCheckIntervalMs() {
        // 先从数据库获取,如果没有则用文件配置,再没有则用默认值
        Long dbValue = sysConfigService.getLong(KEY_CHECK_INTERVAL_MS, null);
        if (dbValue != null) {
            return dbValue;
        }
        return timeoutProperties.getCheckIntervalMs();
    }

    /**
     * 获取请求超时时间(秒)
     * 优先级:数据库 > 文件 > 默认值
     */
    public Integer getRequestTimeoutSeconds() {
        Integer dbValue = sysConfigService.getInteger(KEY_REQUEST_TIMEOUT_SECONDS, null);
        if (dbValue != null) {
            return dbValue;
        }
        return timeoutProperties.getRequestTimeoutSeconds();
    }

    /**
     * 是否启用超时检查
     * 优先级:数据库 > 文件 > 默认值
     */
    public Boolean isTimeoutCheckEnabled() {
        Boolean dbValue = sysConfigService.getBoolean(KEY_CHECK_ENABLED, null);
        if (dbValue != null) {
            return dbValue;
        }
        return timeoutProperties.getEnabled();
    }

    /**
     * 刷新所有配置(数据库配置 + 文件配置)
     */
    public void refreshAllConfig() {
        sysConfigService.refreshCache();
        log.info("所有配置已刷新");
        logCurrentConfig();
    }

    /**
     * 获取当前所有配置信息(用于监控)
     */
    public Map<String, Object> getCurrentConfig() {
        Map<String, Object> config = new HashMap<>();

        // 数据库配置
        config.put("dbCheckIntervalMs", sysConfigService.getLong(KEY_CHECK_INTERVAL_MS, null));
        config.put("dbRequestTimeoutSeconds", sysConfigService.getInteger(KEY_REQUEST_TIMEOUT_SECONDS, null));
        config.put("dbCheckEnabled", sysConfigService.getBoolean(KEY_CHECK_ENABLED, null));

        // 文件配置
        config.put("fileCheckIntervalMs", timeoutProperties.getCheckIntervalMs());
        config.put("fileRequestTimeoutSeconds", timeoutProperties.getRequestTimeoutSeconds());
        config.put("fileCheckEnabled", timeoutProperties.getEnabled());

        // 最终生效配置
        config.put("finalCheckIntervalMs", getCheckIntervalMs());
        config.put("finalRequestTimeoutSeconds", getRequestTimeoutSeconds());
        config.put("finalCheckEnabled", isTimeoutCheckEnabled());

        return config;
    }

    /**
     * 应用启动时记录配置信息
     */
    @PostConstruct
    public void logConfigOnStartup() {
        log.info("=== 应用配置初始化 ===");
        logCurrentConfig();
        log.info("====================");
    }

    private void logCurrentConfig() {
        Map<String, Object> config = getCurrentConfig();
        log.info("最终生效配置 - 检查间隔: {}ms, 超时时间: {}s, 启用状态: {}",
                config.get("finalCheckIntervalMs"),
                config.get("finalRequestTimeoutSeconds"),
                config.get("finalCheckEnabled"));

        log.debug("详细配置信息: {}", config);
    }
}

8、定时任务配置类

src/main/java/com/weiyu/config/ScheduleConfig.java

package com.weiyu.config;

import com.weiyu.service.CompositeConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

/**
 * 定时任务配置类
 */
@Configuration
@EnableScheduling
@Slf4j
public class ScheduleConfig implements SchedulingConfigurer {

    @Autowired
    private CompositeConfigService compositeConfigService;

    /**
     * 配置定时任务线程池
     */
    @Bean(destroyMethod = "shutdown")
    public Executor taskExecutor() {
        return Executors.newScheduledThreadPool(5, r -> {
            Thread t = new Thread(r);
            t.setDaemon(true);
            t.setName("schedule-task-" + t.getId());
            return t;
        });
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(taskExecutor());

        Long interval = compositeConfigService.getCheckIntervalMs();
        log.info("定时任务配置完成,超时检查间隔: {}ms", interval);
    }

    /**
     * 获取超时检查间隔(用于@Scheduled注解)
     * 该方法通过SpEL在@Scheduled注解中调用,IDEA会误报未使用警告
     */
    @SuppressWarnings("unused")
    public Long getTimeoutCheckInterval() {
        return compositeConfigService.getCheckIntervalMs();
    }

    /**
     * 获取请求超时时间(秒)
     */
    public Integer getRequestTimeoutSeconds() {
        return compositeConfigService.getRequestTimeoutSeconds();
    }

    /**
     * 是否启用超时检查
     */
    public Boolean isTimeoutCheckEnabled() {
        return compositeConfigService.isTimeoutCheckEnabled();
    }
}

9、请求上下文处理器

src/main/java/com/weiyu/handler/RequestContextHandler.java

关键修改

    @Autowired
    private ScheduleConfig scheduleConfig;

    /**
     * 定时任务:超时检查
     */
    @Scheduled(fixedRateString = "#{@scheduleConfig.getTimeoutCheckInterval()}")
    public void scheduledTimeoutCheck() {
        try {
            // 检查是否启用超时检查
            if (!scheduleConfig.isTimeoutCheckEnabled()) {
                return;
            }

            // 获取超时时间配置
            int timeoutSeconds = scheduleConfig.getRequestTimeoutSeconds();
            log.debug("执行超时检查,超时阈值: {}秒", timeoutSeconds);
            // 取消所有超时请求
            cancelTimeoutRequests(timeoutSeconds);

        } catch (Exception e) {
            log.error("超时检查任务执行失败", e);
        }
    }

全部代码

package com.weiyu.handler;

import com.weiyu.config.ScheduleConfig;
import com.weiyu.exception.RequestCancelledException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * 请求上下文处理器
 */
@Component
@Slf4j
public class RequestContextHandler {

    @Autowired
    private ScheduleConfig scheduleConfig;

    /**
     * 请求取消标识
     */
    private final Map<String, AtomicBoolean> requestCancelFlags = new ConcurrentHashMap<>();

    /**
     * 请求线程
     */
    private final Map<String, Thread> requestThreads = new ConcurrentHashMap<>();

    /**
     * 请求进度
     */
    private final Map<String, Integer> requestProgresses = new ConcurrentHashMap<>();

    /**
     * 请求开始时间,用于超时检查
     */
    private final Map<String, LocalDateTime> requestStartTimes = new ConcurrentHashMap<>();

    /**
     * 注册请求,增加 Map 记录
     *
     * @param requestId 请求唯一id
     */
    public void registerRequest(String requestId) {
        requestCancelFlags.put(requestId, new AtomicBoolean(false));
        requestThreads.put(requestId, Thread.currentThread());
        requestProgresses.put(requestId, 0);
        requestStartTimes.put(requestId, LocalDateTime.now());
        log.info("已注册请求: requestId={}", requestId);
    }

    /**
     * 清理请求,移除 Map 记录
     *
     * @param requestId 请求唯一id
     */
    public void cleanupRequest(String requestId) {
        requestCancelFlags.remove(requestId);
        requestThreads.remove(requestId);
        requestProgresses.remove(requestId);
        requestStartTimes.remove(requestId);
        log.info("已清理请求: requestId={}", requestId);
    }

    /**
     * 取消请求
     *
     * @param requestId 请求唯一id
     */
    public void cancelRequest(String requestId) {
        // 通过请求唯一id获取取消标识
        AtomicBoolean flag = requestCancelFlags.get(requestId);
        if (flag != null) {
            // 设置为已被取消
            flag.set(true);

            // 通过请求唯一id获取线程
            Thread thread = requestThreads.get(requestId);
            if (thread != null && thread.isAlive()) {
                // 中断线程
                thread.interrupt();
                log.info("已中断请求线程: requestId={}", requestId);
            }
        }
    }

    /**
     * 检查是否已被取消
     *
     * @param requestId 请求唯一id
     */
    public void checkCancellation(String requestId) {
        if (isCancelled(requestId)) {
            throw new RequestCancelledException("请求已被取消");
        }
    }

    /**
     * 更新请求进度
     *
     * @param requestId 请求唯一id
     * @param progress  进度
     */
    public void updateProgress(String requestId, Integer progress) {
        if (progress != null) {
            requestProgresses.put(requestId, progress);
        }
    }

    /**
     * 获取请求进度
     *
     * @param requestId 请求唯一id
     */
    public Optional<Integer> getProgress(String requestId) {
        return Optional.ofNullable(requestProgresses.get(requestId));
    }

    /**
     * 更新请求开始时间
     *
     * @param requestId 请求唯一id
     */
    public void updateRequestStartTime(String requestId) {
        requestStartTimes.put(requestId, LocalDateTime.now());
    }

    /**
     * 检查请求是否超时
     *
     * @param requestId      请求唯一id
     * @param timeoutSeconds 超时时间(秒)
     */
    public boolean isRequestTimeout(String requestId, long timeoutSeconds) {
        LocalDateTime startTime = requestStartTimes.get(requestId);
        if (startTime == null) {
            return false; // 请求不存在,不算超时
        }

        LocalDateTime timeoutTime = startTime.plusSeconds(timeoutSeconds);

        return LocalDateTime.now().isAfter(timeoutTime);
    }

    /**
     * 取消所有超时请求(自定义超时时间)
     *
     * @param timeoutSeconds 超时时间(秒)
     */
    public void cancelTimeoutRequests(long timeoutSeconds) {
        int cancelledCount = 0;

        for (String requestId : requestStartTimes.keySet()) {
            if (isRequestTimeout(requestId, timeoutSeconds)) {
                cancelRequest(requestId);
                cancelledCount++;
            }
        }

        if (cancelledCount > 0) {
            log.info("已自动取消超时请求数: {}", cancelledCount);
        }
    }

    /**
     * 定时任务:超时检查
     */
    @Scheduled(fixedRateString = "#{@scheduleConfig.getTimeoutCheckInterval()}")
    public void scheduledTimeoutCheck() {
        try {
            // 检查是否启用超时检查
            if (!scheduleConfig.isTimeoutCheckEnabled()) {
                return;
            }

            // 获取超时时间配置
            int timeoutSeconds = scheduleConfig.getRequestTimeoutSeconds();
            log.debug("执行超时检查,超时阈值: {}秒", timeoutSeconds);
            // 取消所有超时请求
            cancelTimeoutRequests(timeoutSeconds);

        } catch (Exception e) {
            log.error("超时检查任务执行失败", e);
        }
    }

    /**
     * 判断是否已被取消
     *
     * @param requestId 请求唯一id
     */
    private boolean isCancelled(String requestId) {
        AtomicBoolean flag = requestCancelFlags.get(requestId);
        return flag != null && flag.get();
    }

//    /**
//     * 获取当前统计信息(用于监控)
//     */
//    public Map<String, Object> getStats() {
//        Map<String, Object> stats = new ConcurrentHashMap<>();
//        stats.put("activeRequests", requestCancelFlags.size());
//        stats.put("checkIntervalMs", scheduleConfig.getTimeoutCheckInterval());
//        stats.put("timeoutSeconds", scheduleConfig.getRequestTimeoutSeconds());
//        stats.put("enabled", scheduleConfig.isTimeoutCheckEnabled());
//        return stats;
//    }
}

10、yml文件配置

10.1、开发环境配置

src/main/resources/application-dev.yml

# 系统配置
app:
  # 应用超时配置
  timeout:
    # 超时检查间隔(毫秒)
    check-interval-ms: 30000
    # 请求超时时间(秒)
    request-timeout-seconds: 60
    # 启用超时检查
    enabled: true

10.2、生产环境配置

src/main/resources/application-prod.yml

# 系统配置
app:
  # 应用超时配置
  timeout:
    # 超时检查间隔(毫秒)
    check-interval-ms: 1200000
    # 请求超时时间(秒)
    request-timeout-seconds: 1800
    # 启用超时检查
    enabled: true

11、数据库配置

-- 系统配置
-- 应用超时配置 - 超时检查间隔(毫秒)
if not exists(select * from SystemOptions where sop_OptionItemID = 'app.timeout.check-interval-ms')
insert into SystemOptions(sop_OptionItemID, sop_OptionItemName, sop_Checked, sop_OptionValue,sop_Remark,sop_Type)  
values('app.timeout.check-interval-ms', '超时检查间隔(毫秒)',null,'30000',null,null)
go

-- 应用超时配置 - 请求超时时间(秒)
if not exists(select * from SystemOptions where sop_OptionItemID = 'app.timeout.request-timeout-seconds')
insert into SystemOptions(sop_OptionItemID, sop_OptionItemName, sop_Checked, sop_OptionValue,sop_Remark,sop_Type)  
values('app.timeout.request-timeout-seconds', '请求超时时间(秒)',null,'60',null,null)
go

-- 应用超时配置 - 启用超时检查
if not exists(select * from SystemOptions where sop_OptionItemID = 'app.timeout.enabled')
insert into SystemOptions(sop_OptionItemID, sop_OptionItemName, sop_Checked, sop_OptionValue,sop_Remark,sop_Type)  
values('app.timeout.enabled', '启用超时检查',null,'true',null,null)
go

12、应用效果

服务启动情况

D:\Develop\jdk\bin\java.exe "-javaagent:D:\Develop\IntelliJ IDEA Community Edition\lib\idea_rt.jar=58370:D:\Develop\IntelliJ IDEA Community Edition\bin" -Dfile.encoding=UTF-8 -classpath D:\MyCode\wylims-jm\lims-server\target\classes;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-web\3.2.12\spring-boot-starter-web-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter\3.2.12\spring-boot-starter-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot\3.2.12\spring-boot-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-logging\3.2.12\spring-boot-starter-logging-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\ch\qos\logback\logback-classic\1.4.14\logback-classic-1.4.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\ch\qos\logback\logback-core\1.4.14\logback-core-1.4.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\logging\log4j\log4j-to-slf4j\2.21.1\log4j-to-slf4j-2.21.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\slf4j\jul-to-slf4j\2.0.16\jul-to-slf4j-2.0.16.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\jakarta\annotation\jakarta.annotation-api\2.1.1\jakarta.annotation-api-2.1.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-core\6.1.15\spring-core-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-jcl\6.1.15\spring-jcl-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\yaml\snakeyaml\2.2\snakeyaml-2.2.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-json\3.2.12\spring-boot-starter-json-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\fasterxml\jackson\datatype\jackson-datatype-jdk8\2.15.4\jackson-datatype-jdk8-2.15.4.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\fasterxml\jackson\datatype\jackson-datatype-jsr310\2.15.4\jackson-datatype-jsr310-2.15.4.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\fasterxml\jackson\module\jackson-module-parameter-names\2.15.4\jackson-module-parameter-names-2.15.4.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-tomcat\3.2.12\spring-boot-starter-tomcat-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\tomcat\embed\tomcat-embed-core\10.1.33\tomcat-embed-core-10.1.33.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\tomcat\embed\tomcat-embed-websocket\10.1.33\tomcat-embed-websocket-10.1.33.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-web\6.1.15\spring-web-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-beans\6.1.15\spring-beans-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\io\micrometer\micrometer-observation\1.12.13\micrometer-observation-1.12.13.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\io\micrometer\micrometer-commons\1.12.13\micrometer-commons-1.12.13.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-webmvc\6.1.15\spring-webmvc-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-context\6.1.15\spring-context-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-expression\6.1.15\spring-expression-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\baomidou\mybatis-plus-spring-boot3-starter\3.5.14\mybatis-plus-spring-boot3-starter-3.5.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\baomidou\mybatis-plus\3.5.14\mybatis-plus-3.5.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\baomidou\mybatis-plus-core\3.5.14\mybatis-plus-core-3.5.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\baomidou\mybatis-plus-annotation\3.5.14\mybatis-plus-annotation-3.5.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\baomidou\mybatis-plus-spring\3.5.14\mybatis-plus-spring-3.5.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\baomidou\mybatis-plus-extension\3.5.14\mybatis-plus-extension-3.5.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\mybatis\mybatis\3.5.19\mybatis-3.5.19.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\mybatis\mybatis-spring\3.0.5\mybatis-spring-3.0.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\baomidou\mybatis-plus-spring-boot-autoconfigure\3.5.14\mybatis-plus-spring-boot-autoconfigure-3.5.14.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-autoconfigure\3.2.12\spring-boot-autoconfigure-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-jdbc\3.2.12\spring-boot-starter-jdbc-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\zaxxer\HikariCP\5.0.1\HikariCP-5.0.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-jdbc\6.1.15\spring-jdbc-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-tx\6.1.15\spring-tx-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\microsoft\sqlserver\mssql-jdbc\8.2.2.jre8\mssql-jdbc-8.2.2.jre8.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\projectlombok\lombok\1.18.36\lombok-1.18.36.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-validation\3.2.12\spring-boot-starter-validation-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\tomcat\embed\tomcat-embed-el\10.1.33\tomcat-embed-el-10.1.33.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\hibernate\validator\hibernate-validator\8.0.1.Final\hibernate-validator-8.0.1.Final.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\jakarta\validation\jakarta.validation-api\3.0.2\jakarta.validation-api-3.0.2.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\jboss\logging\jboss-logging\3.5.3.Final\jboss-logging-3.5.3.Final.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\fasterxml\classmate\1.6.0\classmate-1.6.0.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\auth0\java-jwt\4.4.0\java-jwt-4.4.0.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\fasterxml\jackson\core\jackson-databind\2.15.4\jackson-databind-2.15.4.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\fasterxml\jackson\core\jackson-annotations\2.15.4\jackson-annotations-2.15.4.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\fasterxml\jackson\core\jackson-core\2.15.4\jackson-core-2.15.4.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\github\pagehelper\pagehelper-spring-boot-starter\1.4.6\pagehelper-spring-boot-starter-1.4.6.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\mybatis\spring\boot\mybatis-spring-boot-starter\2.2.2\mybatis-spring-boot-starter-2.2.2.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\mybatis\spring\boot\mybatis-spring-boot-autoconfigure\2.2.2\mybatis-spring-boot-autoconfigure-2.2.2.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\github\pagehelper\pagehelper-spring-boot-autoconfigure\1.4.6\pagehelper-spring-boot-autoconfigure-1.4.6.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\github\pagehelper\pagehelper\5.3.2\pagehelper-5.3.2.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\github\jsqlparser\jsqlparser\4.5\jsqlparser-4.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\poi\poi\5.3.0\poi-5.3.0.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\commons-codec\commons-codec\1.16.1\commons-codec-1.16.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\commons\commons-collections4\4.4\commons-collections4-4.4.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\commons\commons-math3\3.6.1\commons-math3-3.6.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\commons-io\commons-io\2.16.1\commons-io-2.16.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\zaxxer\SparseBitSet\1.3\SparseBitSet-1.3.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\logging\log4j\log4j-api\2.21.1\log4j-api-2.21.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\poi\poi-ooxml\5.3.0\poi-ooxml-5.3.0.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\poi\poi-ooxml-lite\5.3.0\poi-ooxml-lite-5.3.0.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\xmlbeans\xmlbeans\5.2.1\xmlbeans-5.2.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\commons\commons-compress\1.26.2\commons-compress-1.26.2.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\apache\commons\commons-lang3\3.13.0\commons-lang3-3.13.0.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\github\virtuald\curvesapi\1.08\curvesapi-1.08.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\alibaba\fastjson\1.2.83\fastjson-1.2.83.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\barcodes\7.2.5\barcodes-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\slf4j\slf4j-api\2.0.16\slf4j-api-2.0.16.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\font-asian\7.2.5\font-asian-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\forms\7.2.5\forms-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\hyph\7.2.5\hyph-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\io\7.2.5\io-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\commons\7.2.5\commons-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\kernel\7.2.5\kernel-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\bouncycastle\bcpkix-jdk15on\1.70\bcpkix-jdk15on-1.70.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\bouncycastle\bcutil-jdk15on\1.70\bcutil-jdk15on-1.70.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\bouncycastle\bcprov-jdk15on\1.70\bcprov-jdk15on-1.70.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\layout\7.2.5\layout-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\pdfa\7.2.5\pdfa-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\sign\7.2.5\sign-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\styled-xml-parser\7.2.5\styled-xml-parser-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\itextpdf\svg\7.2.5\svg-7.2.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-aop\3.2.12\spring-boot-starter-aop-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-aop\6.1.15\spring-aop-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\aspectj\aspectjweaver\1.9.22.1\aspectjweaver-1.9.22.1.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\github\ulisesbocchio\jasypt-spring-boot-starter\3.0.5\jasypt-spring-boot-starter-3.0.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\com\github\ulisesbocchio\jasypt-spring-boot\3.0.5\jasypt-spring-boot-3.0.5.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\jasypt\jasypt\1.9.3\jasypt-1.9.3.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\boot\spring-boot-starter-websocket\3.2.12\spring-boot-starter-websocket-3.2.12.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-messaging\6.1.15\spring-messaging-6.1.15.jar;D:\Develop\apache-maven-3.9.6\mvn_repo\org\springframework\spring-websocket\6.1.15\spring-websocket-6.1.15.jar com.weiyu.LimsServerApplication

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::               (v3.2.12)

2025-11-21T20:37:36.802+08:00  INFO 34652 --- [           main] com.weiyu.LimsServerApplication          : Starting LimsServerApplication using Java 17.0.10 with PID 34652 (D:\MyCode\wylims-jm\lims-server\target\classes started by 27973 in D:\MyCode\wylims-jm\lims-server)
2025-11-21T20:37:36.805+08:00  INFO 34652 --- [           main] com.weiyu.LimsServerApplication          : The following 1 profile is active: "dev"
2025-11-21T20:37:38.870+08:00  INFO 34652 --- [           main] ptablePropertiesBeanFactoryPostProcessor : Post-processing PropertySource instances
2025-11-21T20:37:38.874+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource configurationProperties [class org.springframework.boot.context.properties.source.ConfigurationPropertySourcesPropertySource
2025-11-21T20:37:38.882+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource servletConfigInitParams [class org.springframework.core.env.PropertySource$StubPropertySource
2025-11-21T20:37:38.882+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource servletContextInitParams [class org.springframework.core.env.PropertySource$StubPropertySource
2025-11-21T20:37:38.884+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource systemProperties [org.springframework.core.env.PropertiesPropertySource] to EncryptableMapPropertySourceWrapper
2025-11-21T20:37:38.885+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource systemEnvironment [org.springframework.boot.env.SystemEnvironmentPropertySourceEnvironmentPostProcessor$OriginAwareSystemEnvironmentPropertySource] to EncryptableSystemEnvironmentPropertySourceWrapper
2025-11-21T20:37:38.885+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource random [org.springframework.boot.env.RandomValuePropertySource] to EncryptablePropertySourceWrapper
2025-11-21T20:37:38.886+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource Config resource 'class path resource [application-dev.yml]' via location 'optional:classpath:/' [org.springframework.boot.env.OriginTrackedMapPropertySource] to EncryptableMapPropertySourceWrapper
2025-11-21T20:37:38.886+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource Config resource 'class path resource [application.yml]' via location 'optional:classpath:/' [org.springframework.boot.env.OriginTrackedMapPropertySource] to EncryptableMapPropertySourceWrapper
2025-11-21T20:37:39.539+08:00  INFO 34652 --- [           main] c.u.j.filter.DefaultLazyPropertyFilter   : Property Filter custom Bean not found with name 'encryptablePropertyFilter'. Initializing Default Property Filter
2025-11-21T20:37:39.562+08:00  INFO 34652 --- [           main] c.u.j.r.DefaultLazyPropertyResolver      : Property Resolver custom Bean not found with name 'encryptablePropertyResolver'. Initializing Default Property Resolver
2025-11-21T20:37:39.568+08:00  INFO 34652 --- [           main] c.u.j.d.DefaultLazyPropertyDetector      : Property Detector custom Bean not found with name 'encryptablePropertyDetector'. Initializing Default Property Detector
2025-11-21T20:37:40.254+08:00  INFO 34652 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
2025-11-21T20:37:40.317+08:00  INFO 34652 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2025-11-21T20:37:40.318+08:00  INFO 34652 --- [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.33]
2025-11-21T20:37:40.444+08:00  INFO 34652 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2025-11-21T20:37:40.445+08:00  INFO 34652 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 3550 ms
Logging initialized using 'class org.apache.ibatis.logging.stdout.StdOutImpl' adapter.
Get /192.168.188.1 network interface 
Get network interface info: name:eth5 (VMware Virtual Ethernet Adapter for VMnet1)
Initialization Sequence datacenterId:4 workerId:30
 _ _   |_  _ _|_. ___ _ |    _ 
| | |\/|_)(_| | |_\  |_)||_|_\ 
     /               |         
                        3.5.14 
This "id" is the table primary key by @TableId annotation in Class: "com.weiyu.model.RecordLog",So @TableField annotation will not work!
Can not find table primary key in Class: "com.weiyu.model.SysConfig".
2025-11-21T20:37:42.733+08:00  WARN 34652 --- [           main] c.b.m.core.injector.DefaultSqlInjector   : class com.weiyu.model.SysConfig ,Not found @TableId annotation, Cannot use Mybatis-Plus 'xxById' Method.
2025-11-21T20:37:42.770+08:00  INFO 34652 --- [           main] com.weiyu.service.SysConfigService       : 刷新数据库配置缓存
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@782fd504] was not registered for synchronization because synchronization is not active
2025-11-21T20:37:42.921+08:00  INFO 34652 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2025-11-21T20:37:43.611+08:00  INFO 34652 --- [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection ConnectionID:1 ClientConnectionId: 2add60f4-9b9f-40f3-8dd2-56b8a86e6616
2025-11-21T20:37:43.613+08:00  INFO 34652 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
JDBC Connection [HikariProxyConnection@165255249 wrapping ConnectionID:1 ClientConnectionId: 2add60f4-9b9f-40f3-8dd2-56b8a86e6616] will not be managed by Spring
==>  Preparing: select sop_OptionItemID, sop_OptionValue from SystemOptions where sop_OptionValue is not null
==> Parameters: 
<==    Columns: sop_OptionItemID, sop_OptionValue
<==        Row: AcceptMode, 1
<==        Row: app.timeout.check-interval-ms, 30000
<==        Row: app.timeout.enabled, true
<==        Row: app.timeout.request-timeout-seconds, 60
<==        Row: ApplicationTimeOut, 3600


<==      Total: 154
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@782fd504]
2025-11-21T20:37:44.099+08:00  INFO 34652 --- [           main] com.weiyu.aop.TimeAspect                 : List com.weiyu.mapper.SysConfigMapper.selectAllEnabledConfigs() 执行sql时间:1308 ms
2025-11-21T20:37:44.099+08:00  INFO 34652 --- [           main] com.weiyu.service.SysConfigService       : 数据库配置缓存刷新完成,共加载 154 个配置项
2025-11-21T20:37:44.102+08:00  INFO 34652 --- [           main] c.weiyu.service.CompositeConfigService   : === 应用配置初始化 ===
2025-11-21T20:37:44.105+08:00  INFO 34652 --- [           main] c.weiyu.service.CompositeConfigService   : 最终生效配置 - 检查间隔: 30000ms, 超时时间: 60s, 启用状态: true
2025-11-21T20:37:44.105+08:00  INFO 34652 --- [           main] c.weiyu.service.CompositeConfigService   : ====================
2025-11-21T20:37:46.762+08:00  INFO 34652 --- [           main] m.e.s.MybatisPlusApplicationContextAware : Register ApplicationContext instances org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@21fd5faa


,------.                           ,--.  ,--.         ,--.                         
|  .--. '  ,--,--.  ,---.   ,---.  |  '--'  |  ,---.  |  |  ,---.   ,---.  ,--.--. 
|  '--' | ' ,-.  | | .-. | | .-. : |  .--.  | | .-. : |  | | .-. | | .-. : |  .--' 
|  | --'  \ '-'  | ' '-' ' \   --. |  |  |  | \   --. |  | | '-' ' \   --. |  |    
`--'       `--`--' .`-  /   `----' `--'  `--'  `----' `--' |  |-'   `----' `--'    
                   `---'                                   `--'                        is intercepting.

2025-11-21T20:37:47.319+08:00  INFO 34652 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path ''
2025-11-21T20:37:47.323+08:00  INFO 34652 --- [           main] u.j.c.RefreshScopeRefreshedEventListener : Refreshing cached encryptable property sources on ServletWebServerInitializedEvent
2025-11-21T20:37:47.326+08:00  INFO 34652 --- [           main] CachingDelegateEncryptablePropertySource : Property Source systemProperties refreshed
2025-11-21T20:37:47.326+08:00  INFO 34652 --- [           main] CachingDelegateEncryptablePropertySource : Property Source systemEnvironment refreshed
2025-11-21T20:37:47.326+08:00  INFO 34652 --- [           main] CachingDelegateEncryptablePropertySource : Property Source random refreshed
2025-11-21T20:37:47.326+08:00  INFO 34652 --- [           main] CachingDelegateEncryptablePropertySource : Property Source Config resource 'class path resource [application-dev.yml]' via location 'optional:classpath:/' refreshed
2025-11-21T20:37:47.326+08:00  INFO 34652 --- [           main] CachingDelegateEncryptablePropertySource : Property Source Config resource 'class path resource [application.yml]' via location 'optional:classpath:/' refreshed
2025-11-21T20:37:47.326+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource configurationProperties [class org.springframework.boot.context.properties.source.ConfigurationPropertySourcesPropertySource
2025-11-21T20:37:47.326+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Skipping PropertySource servletConfigInitParams [class org.springframework.core.env.PropertySource$StubPropertySource
2025-11-21T20:37:47.327+08:00  INFO 34652 --- [           main] c.u.j.EncryptablePropertySourceConverter : Converting PropertySource servletContextInitParams [org.springframework.web.context.support.ServletContextPropertySource] to EncryptableEnumerablePropertySourceWrapper
2025-11-21T20:37:47.358+08:00  INFO 34652 --- [           main] com.weiyu.config.ScheduleConfig          : 定时任务配置完成,超时检查间隔: 30000ms
2025-11-21T20:37:47.379+08:00  INFO 34652 --- [           main] com.weiyu.LimsServerApplication          : Started LimsServerApplication in 11.143 seconds (process running for 11.547)

应用情况


13、指导资料

配置属性 + 配置类 + 数据库配置 完整实现方案

这种组合方案可以实现配置的优先级管理:数据库配置 > 配置文件 > 代码默认值,非常适合需要动态调整配置的生产环境。

1. 整体架构设计

text

应用启动
    ↓
加载 application.yml 默认配置
    ↓
初始化数据库配置服务
    ↓  
加载数据库中的配置(覆盖文件配置)
    ↓
提供统一的配置访问接口

2. 项目结构

text

src/main/java/com/weiyu/
├── config/
│   ├── properties/
│   │   └── TimeoutProperties.java
│   └── CompositeConfig.java
├── handler/
│   └── RequestContextHandler.java
├── service/
│   ├── SysConfigService.java
│   └── CompositeConfigService.java
├── mapper/
│   └── SysConfigMapper.java
└── model/
    └── SysConfig.java

3. 完整代码实现

3.1 数据库表结构

sql

CREATE TABLE sys_config (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    config_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键',
    config_value TEXT COMMENT '配置值',
    config_desc VARCHAR(200) COMMENT '配置描述',
    config_type VARCHAR(20) DEFAULT 'STRING' COMMENT '配置类型:STRING,NUMBER,BOOLEAN,JSON',
    is_enabled TINYINT DEFAULT 1 COMMENT '是否启用',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 插入默认配置数据
INSERT INTO sys_config (config_key, config_value, config_desc) VALUES
('timeout.check.interval.ms', '30000', '超时检查间隔(毫秒)'),
('timeout.request.timeout.seconds', '300', '请求超时时间(秒)'),
('timeout.check.enabled', 'true', '是否启用超时检查');

3.2 配置属性类

java

package com.weiyu.config.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 超时检查配置属性 - 从application.yml读取
 */
@Data
@Component
@ConfigurationProperties(prefix = "app.timeout")
public class TimeoutProperties {
    
    // 文件配置中的默认值
    private Long checkIntervalMs = 30000L;
    private Integer requestTimeoutSeconds = 300;
    private Boolean enabled = true;
}

3.3 数据库配置实体类

java

package com.weiyu.model;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@TableName("sys_config")
public class SysConfig {
    
    @TableId
    private Long id;
    
    @TableField("config_key")
    private String configKey;
    
    @TableField("config_value")
    private String configValue;
    
    @TableField("config_desc")
    private String configDesc;
    
    @TableField("config_type")
    private String configType;
    
    @TableField("is_enabled")
    private Boolean enabled;
    
    @TableField("create_time")
    private LocalDateTime createTime;
    
    @TableField("update_time")
    private LocalDateTime updateTime;
}

3.4 数据库配置Mapper

java

package com.weiyu.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.weiyu.model.SysConfig;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import java.util.List;
import java.util.Map;

@Mapper
public interface SysConfigMapper extends BaseMapper<SysConfig> {
    
    /**
     * 根据配置键查询配置值
     */
    @Select("SELECT config_value FROM sys_config WHERE config_key = #{key} AND is_enabled = 1")
    String selectValueByKey(String key);
    
    /**
     * 查询所有启用的配置
     */
    @Select("SELECT config_key, config_value FROM sys_config WHERE is_enabled = 1")
    List<Map<String, String>> selectAllEnabledConfigs();
}

3.5 数据库配置服务

java

package com.weiyu.service;

import com.weiyu.mapper.SysConfigMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 数据库配置服务
 */
@Service
@Slf4j
public class SysConfigService {
    
    @Autowired
    private SysConfigMapper sysConfigMapper;
    
    // 配置缓存
    private final Map<String, String> configCache = new ConcurrentHashMap<>();
    
    /**
     * 获取字符串配置
     */
    public String getString(String key, String defaultValue) {
        try {
            return configCache.computeIfAbsent(key, k -> {
                String value = sysConfigMapper.selectValueByKey(k);
                return value != null ? value : defaultValue;
            });
        } catch (Exception e) {
            log.warn("获取数据库配置失败 key: {}, 使用默认值: {}", key, defaultValue, e);
            return defaultValue;
        }
    }
    
    /**
     * 获取整数配置
     */
    public Integer getInteger(String key, Integer defaultValue) {
        String value = getString(key, null);
        if (value == null) return defaultValue;
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            log.warn("数据库配置值格式错误 key: {}, value: {}", key, value);
            return defaultValue;
        }
    }
    
    /**
     * 获取长整型配置
     */
    public Long getLong(String key, Long defaultValue) {
        String value = getString(key, null);
        if (value == null) return defaultValue;
        try {
            return Long.parseLong(value);
        } catch (NumberFormatException e) {
            log.warn("数据库配置值格式错误 key: {}, value: {}", key, value);
            return defaultValue;
        }
    }
    
    /**
     * 获取布尔配置
     */
    public Boolean getBoolean(String key, Boolean defaultValue) {
        String value = getString(key, null);
        if (value == null) return defaultValue;
        return Boolean.parseBoolean(value);
    }
    
    /**
     * 刷新配置缓存
     */
    public void refreshCache() {
        log.info("刷新数据库配置缓存");
        configCache.clear();
        List<Map<String, String>> configs = sysConfigMapper.selectAllEnabledConfigs();
        for (Map<String, String> config : configs) {
            configCache.put(config.get("config_key"), config.get("config_value"));
        }
        log.info("数据库配置缓存刷新完成,共加载 {} 个配置项", configCache.size());
    }
    
    /**
     * 应用启动时初始化配置缓存
     */
    @javax.annotation.PostConstruct
    public void init() {
        refreshCache();
    }
}

3.6 综合配置服务(核心)

java

package com.weiyu.service;

import com.weiyu.config.properties.TimeoutProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

/**
 * 综合配置服务 - 整合文件配置和数据库配置
 * 优先级:数据库配置 > 文件配置 > 代码默认值
 */
@Service
@Slf4j
public class CompositeConfigService {
    
    @Autowired
    private TimeoutProperties timeoutProperties;
    
    @Autowired
    private SysConfigService sysConfigService;
    
    // 配置键常量
    private static final String KEY_CHECK_INTERVAL_MS = "timeout.check.interval.ms";
    private static final String KEY_REQUEST_TIMEOUT_SECONDS = "timeout.request.timeout.seconds";
    private static final String KEY_CHECK_ENABLED = "timeout.check.enabled";
    
    /**
     * 获取检查间隔(毫秒)
     * 优先级:数据库 > 文件 > 默认值
     */
    public Long getCheckIntervalMs() {
        // 先从数据库获取,如果没有则用文件配置,再没有则用默认值
        Long dbValue = sysConfigService.getLong(KEY_CHECK_INTERVAL_MS, null);
        if (dbValue != null) {
            return dbValue;
        }
        return timeoutProperties.getCheckIntervalMs();
    }
    
    /**
     * 获取请求超时时间(秒)
     * 优先级:数据库 > 文件 > 默认值
     */
    public Integer getRequestTimeoutSeconds() {
        Integer dbValue = sysConfigService.getInteger(KEY_REQUEST_TIMEOUT_SECONDS, null);
        if (dbValue != null) {
            return dbValue;
        }
        return timeoutProperties.getRequestTimeoutSeconds();
    }
    
    /**
     * 是否启用超时检查
     * 优先级:数据库 > 文件 > 默认值
     */
    public Boolean isTimeoutCheckEnabled() {
        Boolean dbValue = sysConfigService.getBoolean(KEY_CHECK_ENABLED, null);
        if (dbValue != null) {
            return dbValue;
        }
        return timeoutProperties.getEnabled();
    }
    
    /**
     * 刷新所有配置(数据库配置 + 文件配置)
     */
    public void refreshAllConfig() {
        sysConfigService.refreshCache();
        log.info("所有配置已刷新");
        logCurrentConfig();
    }
    
    /**
     * 获取当前所有配置信息(用于监控)
     */
    public Map<String, Object> getCurrentConfig() {
        Map<String, Object> config = new HashMap<>();
        
        // 数据库配置
        config.put("dbCheckIntervalMs", sysConfigService.getLong(KEY_CHECK_INTERVAL_MS, null));
        config.put("dbRequestTimeoutSeconds", sysConfigService.getInteger(KEY_REQUEST_TIMEOUT_SECONDS, null));
        config.put("dbCheckEnabled", sysConfigService.getBoolean(KEY_CHECK_ENABLED, null));
        
        // 文件配置
        config.put("fileCheckIntervalMs", timeoutProperties.getCheckIntervalMs());
        config.put("fileRequestTimeoutSeconds", timeoutProperties.getRequestTimeoutSeconds());
        config.put("fileCheckEnabled", timeoutProperties.getEnabled());
        
        // 最终生效配置
        config.put("finalCheckIntervalMs", getCheckIntervalMs());
        config.put("finalRequestTimeoutSeconds", getRequestTimeoutSeconds());
        config.put("finalCheckEnabled", isTimeoutCheckEnabled());
        
        return config;
    }
    
    /**
     * 应用启动时记录配置信息
     */
    @PostConstruct
    public void logConfigOnStartup() {
        log.info("=== 应用配置初始化 ===");
        logCurrentConfig();
        log.info("====================");
    }
    
    private void logCurrentConfig() {
        Map<String, Object> config = getCurrentConfig();
        log.info("最终生效配置 - 检查间隔: {}ms, 超时时间: {}s, 启用状态: {}",
                config.get("finalCheckIntervalMs"),
                config.get("finalRequestTimeoutSeconds"),
                config.get("finalCheckEnabled"));
        
        log.debug("详细配置信息: {}", config);
    }
}

3.7 配置类

java

package com.weiyu.config;

import com.weiyu.service.CompositeConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

/**
 * 定时任务配置类
 */
@Configuration
@EnableScheduling
@Slf4j
public class ScheduleConfig implements SchedulingConfigurer {

    @Autowired
    private CompositeConfigService compositeConfigService;

    /**
     * 配置定时任务线程池
     */
    @Bean(destroyMethod = "shutdown")
    public Executor taskExecutor() {
        return Executors.newScheduledThreadPool(5, r -> {
            Thread t = new Thread(r);
            t.setDaemon(true);
            t.setName("schedule-task-" + t.getId());
            return t;
        });
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(taskExecutor());
        
        Long interval = compositeConfigService.getCheckIntervalMs();
        log.info("定时任务配置完成,超时检查间隔: {}ms", interval);
    }

    /**
     * 获取超时检查间隔(用于@Scheduled注解)
     */
    public Long getTimeoutCheckInterval() {
        return compositeConfigService.getCheckIntervalMs();
    }

    /**
     * 获取请求超时时间(秒)
     */
    public Integer getRequestTimeoutSeconds() {
        return compositeConfigService.getRequestTimeoutSeconds();
    }

    /**
     * 是否启用超时检查
     */
    public Boolean isTimeoutCheckEnabled() {
        return compositeConfigService.isTimeoutCheckEnabled();
    }
}

3.8 修改RequestContextHandler

java

package com.weiyu.handler;

import com.weiyu.config.ScheduleConfig;
import com.weiyu.exception.RequestCancelledException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;

@Component
@Slf4j
public class RequestContextHandler {

    @Autowired
    private ScheduleConfig scheduleConfig;

    // 请求管理相关字段
    private final Map<String, AtomicBoolean> requestCancelFlags = new ConcurrentHashMap<>();
    private final Map<String, Thread> requestThreads = new ConcurrentHashMap<>();
    private final Map<String, Integer> requestProgresses = new ConcurrentHashMap<>();
    private final Map<String, LocalDateTime> requestStartTimes = new ConcurrentHashMap<>();

    /**
     * 定时任务:超时检查
     */
    @Scheduled(fixedRateString = "#{@scheduleConfig.getTimeoutCheckInterval()}")
    public void scheduledTimeoutCheck() {
        try {
            // 检查是否启用超时检查
            if (!scheduleConfig.isTimeoutCheckEnabled()) {
                return;
            }
            
            // 获取超时时间配置
            int timeoutSeconds = scheduleConfig.getRequestTimeoutSeconds();
            
            log.debug("执行超时检查,超时阈值: {}秒", timeoutSeconds);
            cancelTimeoutRequests(timeoutSeconds);
            
        } catch (Exception e) {
            log.error("超时检查任务执行失败", e);
        }
    }

    // 其他方法保持不变...
    public void registerRequest(String requestId) {
        requestCancelFlags.put(requestId, new AtomicBoolean(false));
        requestThreads.put(requestId, Thread.currentThread());
        requestProgresses.put(requestId, 0);
        requestStartTimes.put(requestId, LocalDateTime.now());
        log.info("已注册请求 {}", requestId);
    }

    public void cleanupRequest(String requestId) {
        requestCancelFlags.remove(requestId);
        requestThreads.remove(requestId);
        requestProgresses.remove(requestId);
        requestStartTimes.remove(requestId);
        log.info("已清理请求 {}", requestId);
    }

    public void cancelRequest(String requestId) {
        AtomicBoolean flag = requestCancelFlags.get(requestId);
        if (flag != null) {
            flag.set(true);
            Thread thread = requestThreads.get(requestId);
            if (thread != null && thread.isAlive()) {
                thread.interrupt();
                log.info("已中断请求线程 {}", requestId);
            }
        }
    }

    public void checkCancellation(String requestId) {
        if (isCancelled(requestId)) {
            throw new RequestCancelledException("请求已被取消");
        }
    }

    public void updateProgress(String requestId, Integer progress) {
        if (progress != null) {
            requestProgresses.put(requestId, progress);
        }
    }

    public Optional<Integer> getProgress(String requestId) {
        return Optional.ofNullable(requestProgresses.get(requestId));
    }

    public boolean isRequestTimeout(String requestId, long timeoutSeconds) {
        LocalDateTime startTime = requestStartTimes.get(requestId);
        if (startTime == null) {
            return false;
        }
        LocalDateTime timeoutTime = startTime.plusSeconds(timeoutSeconds);
        return LocalDateTime.now().isAfter(timeoutTime);
    }

    public void cancelTimeoutRequests(long timeoutSeconds) {
        int cancelledCount = 0;
        for (String requestId : requestStartTimes.keySet()) {
            if (isRequestTimeout(requestId, timeoutSeconds)) {
                cancelRequest(requestId);
                cancelledCount++;
                log.info("已取消超时请求: {}", requestId);
            }
        }
        if (cancelledCount > 0) {
            log.info("已自动取消超时请求数: {}", cancelledCount);
        }
    }

    private boolean isCancelled(String requestId) {
        AtomicBoolean flag = requestCancelFlags.get(requestId);
        return flag != null && flag.get();
    }

    /**
     * 获取当前统计信息(用于监控)
     */
    public Map<String, Object> getStats() {
        Map<String, Object> stats = new ConcurrentHashMap<>();
        stats.put("activeRequests", requestCancelFlags.size());
        stats.put("checkIntervalMs", scheduleConfig.getTimeoutCheckInterval());
        stats.put("timeoutSeconds", scheduleConfig.getRequestTimeoutSeconds());
        stats.put("enabled", scheduleConfig.isTimeoutCheckEnabled());
        return stats;
    }
}

3.9 配置文件

application.yml

yaml

# 应用超时配置 - 默认值(会被数据库配置覆盖)
app:
  timeout:
    check-interval-ms: 30000    # 默认30秒
    request-timeout-seconds: 300 # 默认5分钟
    enabled: true               # 默认启用

# 数据库配置
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/your_database
    username: your_username
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver

# MyBatis-Plus配置
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
  mapper-locations: classpath*:mapper/**/*.xml

# 日志配置
logging:
  level:
    com.weiyu: DEBUG

3.10 配置管理控制器

java

package com.weiyu.controller;

import com.weiyu.service.CompositeConfigService;
import com.weiyu.service.SysConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * 配置管理控制器
 */
@RestController
@RequestMapping("/api/config")
@Slf4j
public class ConfigManagerController {

    @Autowired
    private CompositeConfigService compositeConfigService;

    @Autowired
    private SysConfigService sysConfigService;

    /**
     * 获取当前所有配置信息
     */
    @GetMapping("/current")
    public Map<String, Object> getCurrentConfig() {
        return compositeConfigService.getCurrentConfig();
    }

    /**
     * 刷新配置缓存
     */
    @PostMapping("/refresh")
    public String refreshConfig() {
        compositeConfigService.refreshAllConfig();
        return "配置刷新成功";
    }

    /**
     * 获取数据库配置缓存信息
     */
    @GetMapping("/db-cache")
    public Map<String, String> getDbConfigCache() {
        // 注意:这里返回的是缓存内容,实际项目中可能需要保护
        // 可以通过反射获取,或者修改SysConfigService提供获取缓存的方法
        return Map.of("message", "数据库配置缓存信息受保护");
    }
}

4. 配置优先级验证

启动应用后,访问 http://localhost:8080/api/config/current 可以看到:

json

{
  "dbCheckIntervalMs": 30000,
  "dbRequestTimeoutSeconds": 300,
  "dbCheckEnabled": true,
  "fileCheckIntervalMs": 30000,
  "fileRequestTimeoutSeconds": 300,
  "fileCheckEnabled": true,
  "finalCheckIntervalMs": 30000,
  "finalRequestTimeoutSeconds": 300,
  "finalCheckEnabled": true
}

5. 动态更新配置

方式一:直接修改数据库

sql

UPDATE sys_config SET config_value = '60000' WHERE config_key = 'timeout.check.interval.ms';

然后调用刷新接口:

bash

curl -X POST http://localhost:8080/api/config/refresh

方式二:通过管理界面(可以扩展)

可以开发一个配置管理界面,直接修改数据库中的配置值。

6. 方案优势

  1. 配置优先级:数据库配置 > 文件配置 > 代码默认值

  2. 动态更新:修改数据库配置后无需重启应用

  3. 配置回退:如果数据库配置有问题,可以删除或禁用,自动回退到文件配置

  4. 环境隔离:不同环境可以使用不同的数据库

  5. 监控完善:提供完整的配置查看和刷新功能

  6. 性能优化:配置缓存避免频繁查询数据库

这种方案特别适合生产环境,可以在不影响服务的情况下动态调整系统参数。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐