Repository: SwanHubX/SwanLab (language: python)

Issue:
[ADVICE] 可以下载swanlab绘制的数据折线图
希望能在控制面板可以直接把绘制的折线图保存到本地


Discussion / hints:
谢谢你的建议，我们会关注并尽快安排这个功能。
你希望保存本地的格式是什么，我猜比如jpg、png、csv？
我希望是png，这样写报告就不用再做一遍图了
> 我希望是png，这样写报告就不用再做一遍图了

🎉Get！我们会尽快安排此功能，也欢迎你提出更多建议

Fix patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 287f6d70..0e426304 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -6,7 +6,7 @@
   "configurations": [
     // 启动前端开发服务
     {
-      "name": "前端开发:dev",
+      "name": "前端开发",
       "request": "launch",
       "cwd": "${workspaceRoot}",
       "type": "node",
diff --git a/package.json b/package.json
index 3e29918a..bf9ab382 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
     "@antv/g2plot": "^2.4.31",
     "@headlessui/vue": "^1.7.16",
     "axios": "^1.6.2",
+    "html2canvas": "^1.4.1",
     "moment": "^2.29.4",
     "pinia": "^2.1.7",
     "sass": "^1.69.5",
diff --git a/vue/src/charts/components/ChartContainer.vue b/vue/src/charts/components/ChartContainer.vue
index 09b9e66f..12d7268f 100644
--- a/vue/src/charts/components/ChartContainer.vue
+++ b/vue/src/charts/components/ChartContainer.vue
@@ -12,9 +12,9 @@
     </div>
     <template v-else>
       <!-- 图表相关控制按钮 -->
-      <div class="chart-pannel">
+      <div class="chart-panel">
         <!-- 取消置顶的按钮 -->
-        <PannelButton
+        <PanelButton
           class="!text-positive-default"
           icon="pinned"
           click-icon="loading"
@@ -25,7 +25,7 @@
         />
         <template v-if="hover && !unknown && !isAllError">
           <!-- 置顶 -->
-          <PannelButton
+          <PanelButton
             icon="pin"
             :tip="$t('chart.pin')"
             @click="pin"
@@ -34,10 +34,11 @@
             v-if="!isPinned"
           />
           <!-- 放大功能 -->
-          <PannelButton icon="zoom" :tip="$t('chart.zoom')" @click="zoom" />
+          <PanelButton icon="zoom" :tip="$t('chart.zoom')" @click="zoom" />
           <!-- 更多功能 -->
+          <ExportImage :index="index" :chart="chart" :smoothMethod="smoothMethod" v-model="downloadModal" />
           <SLMenu menu-class="flex-shrink-0" class="right-0 top-4">
-            <PannelButton icon="more" :tip="$t('chart.more')" />
+            <PanelButton icon="more" :tip="$t('chart.more')" />
             <template #pop>
               <SLMenuItems>
                 <SLMenuItem class="py-1" @click="unhide" v-if="isHidden">
@@ -46,6 +47,9 @@
                 <SLMenuItem class="py-1" @click="hide" v-else>
                   {{ $t('chart.hide') }}
                 </SLMenuItem>
+                <SLMenuItem class="py-1" v-if="isLineChart" @click="downloadModal = true">
+                  {{ $t('chart.charts.share.download.button') }}
+                </SLMenuItem>
               </SLMenuItems>
             </template>
           </SLMenu>
@@ -76,13 +80,15 @@ import LineChart from '../package/LineChart.vue'
 import AudioChart from '../package/AudioChart.vue'
 import TextChart from '../package/TextChart.vue'
 import UnknownChart from '../package/UnknownChart.vue'
-import PannelButton from './PannelButton.vue'
+import PanelButton from './PanelButton.vue'
 import { debounce } from '@swanlab-vue/utils/common'
 import ImageChart from '../package/ImageChart.vue'
 import SLLoading from '@swanlab-vue/components/SLLoading.vue'
 import SLMenu from '@swanlab-vue/components/menu/SLMenu.vue'
 import SLMenuItems from '@swanlab-vue/components/menu/SLMenuItems.vue'
 import SLMenuItem from '@swanlab-vue/components/menu/SLMenuItem.vue'
+import ExportImage from '../components/ExportImage.vue'
+
 const props = defineProps({
   chart: {
     type: Object,
@@ -221,8 +227,12 @@ onMounted(() => {
     })
 })
 
+const isChartError = computed(() => {
+  return props.chart.error && Object.keys(props.chart.error).length > 0
+})
+
 // 卸载时取消订阅
-props.chart.error ||
+isChartError.value ||
   onUnmounted(() => {
     $off(source, cid)
   })
@@ -269,7 +279,9 @@ const thin = (...args) => {
 }
 
 // ---------------------------------- 图表平滑方法 ----------------------------------
+const smoothMethod = ref(null)
 const smooth = (method) => {
+  smoothMethod.value = method
   chartRef.value.smooth && chartRef.value.smooth(method)
 }
 
@@ -291,6 +303,15 @@ const unhide = () => {
   emit('unhide', props.chart)
 }
 
+// ---------------------------------- 下载折线图 ----------------------------------
+
+const downloadModal = ref(false)
+
+const isLineChart = computed(() => {
+  if (isChartError.value) return false
+  return props.chart?.type === 'default' || props.chart?.type === 'line'
+})
+
 // ---------------------------------- 暴露组件对象 ----------------------------------
 defineExpose({
   chartRef,
@@ -309,7 +330,7 @@ defineExpose({
   @apply flex-col flex justify-between;
 }
 
-.chart-pannel {
+.chart-panel {
   @apply absolute top-1 right-2 h-4;
   @apply flex justify-end items-center gap-2;
 }
diff --git a/vue/src/charts/components/ExportImage.vue b/vue/src/charts/components/ExportImage.vue
new file mode 100644
index 00000000..a1a2aadd
--- /dev/null
+++ b/vue/src/charts/components/ExportImage.vue
@@ -0,0 +1,151 @@
+<template>
+  <SLModal max-w="890" class="overflow-hidden" v-model="downloadModal" escExit>
+    <h1 class="text-lg px-5 font-semibold py-4 border-b">
+      {{ $t('chart.charts.share.download.png.title') }}
+    </h1>
+    <div class="flex gap-5 px-5 py-6 md:flex-row flex-col">
+      <div class="param">
+        <span>{{ $t('chart.charts.share.download.png.name') }}:</span>
+        <input
+          type="text"
+          class="w-40 text-sm border px-2 py-1 rounded ml-2"
+          :placeholder="placeholder"
+          v-model="name"
+        />
+      </div>
+      <div class="param">
+        <span>{{ $t('chart.charts.share.download.png.width') }}:</span>
+        <InputSlideBar min="200" max="1200" v-model="width" />
+      </div>
+      <div class="param">
+        <span>{{ $t('chart.charts.share.download.png.height') }}:</span>
+        <InputSlideBar min="200" max="1200" v-model="height" />
+      </div>
+    </div>
+    <!-- 图像主体 -->
+    <div class="mx-auto mb-6 py-4" :style="{ width: `${width}px` }">
+      <div class="relative w-full h-full" ref="chartRef">
+        <LineChart :index="index" ref="lineChartRef" :title="chart.name" :chart="chart" />
+      </div>
+    </div>
+    <div class="flex justify-end p-5 pt-0 gap-5">
+      <SLButton
+        theme="primary"
+        class="py-1.5 px-3 rounded"
+        @click="download"
+        :text="$t('chart.charts.share.download.png.download')"
+      />
+    </div>
+  </SLModal>
+</template>
+
+<script setup>
+/**
+ * @description:
+ * @file: ExportImage.vue
+ * @since: 2024-05-14 16:22:09
+ **/
+import SLModal from '@swanlab-vue/components/SLModal.vue'
+import SLButton from '@swanlab-vue/components/SLButton.vue'
+import LineChart from '../package/LineChart.vue'
+import { generateFileNameWithTime } from '@swanlab-vue/utils/common'
+import html2canvas from 'html2canvas'
+import { addTaskToBrowserMainThread } from '@swanlab-vue/utils/browser'
+import InputSlideBar from './InputSlideBar.vue'
+
+const props = defineProps({
+  chart: {
+    type: Object,
+    required: true
+  },
+  index: {
+    type: Number,
+    required: true
+  },
+  modelValue: {
+    type: Boolean,
+    default: false
+  },
+  smoothMethod: {
+    type: Object,
+    default: null
+  }
+})
+const emit = defineEmits(['update:modelValue'])
+const data = inject('data')
+/**
+ * @type {import('vue').Ref} lineChartRef
+ */
+const lineChartRef = ref(null)
+/**
+ * @type {import('vue').Ref<HTMLDivElement>} chartRef
+ */
+const chartRef = ref(null)
+
+const downloadModal = computed({
+  get() {
+    // 显示时渲染图表
+    if (props.modelValue) {
+      lineChartRef.value?.render(data)
+      props.smoothMethod && lineChartRef.value?.smooth(props.smoothMethod)
+    }
+    return props.modelValue
+  },
+  set(val) {
+    emit('update:modelValue', val)
+  }
+})
+
+// ---------------------------------- 名称、长宽等属性 ----------------------------------
+
+const placeholder = generateFileNameWithTime('SwanLab-Chart').split('.')[0]
+const name = ref('')
+const width = ref(600)
+const height = ref(400)
+
+// ---------------------------------- 设置动态拦截高度设置 ----------------------------------
+
+watch([height, downloadModal, lineChartRef], (newVal) => {
+  // 设置高度
+  if (!lineChartRef.value) return
+  if (!newVal[1]) return
+  const h = newVal[0]
+  console.log(h)
+  /**
+   * @type {setOriginalChartHeight} lineChartRef.value.setHeight
+   */
+  addTaskToBrowserMainThread(() => {
+    lineChartRef.value.setHeight(h)
+  })
+})
+
+// ---------------------------------- 下载按钮 ----------------------------------
+
+const download = () => {
+  html2canvas(chartRef.value).then(async (canvas) => {
+    // 新建canvas，为canvas本身增加一圈白色边框
+    const borderWidth = 32
+    const newCanvas = document.createElement('canvas')
+    const ctx = newCanvas.getContext('2d')
+    newCanvas.width = canvas.width + borderWidth * 2
+    newCanvas.height = canvas.height + borderWidth * 2
+    ctx.fillStyle = '#fff'
+    ctx.fillRect(0, 0, newCanvas.width, newCanvas.height)
+    ctx.drawImage(canvas, borderWidth, borderWidth)
+    // 创建一个临时链接元素
+    const link = document.createElement('a')
+    link.href = newCanvas.toDataURL('image/png')
+    link.download = `${name.value || placeholder}.png`
+    link.click()
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.param {
+  @apply flex items-center text-dimmer;
+  span {
+    @apply w-20 md:w-auto block;
+  }
+}
+</style>
diff --git a/vue/src/charts/components/InputSlideBar.vue b/vue/src/charts/components/InputSlideBar.vue
new file mode 100644
index 00000000..89e11208
--- /dev/null
+++ b/vue/src/charts/components/InputSlideBar.vue
@@ -0,0 +1,68 @@
+<template>
+  <SLMenu corner-class="left-3" menu-class="ml-2" menu-min-width="200">
+    <template #default="{ open }">
+      <input type="number" class="w-16 text-sm" v-model="value" @click="(e) => handleClickInput(e, open)" />
+    </template>
+    <template #pop>
+      <div class="px-4">
+        <SLSlideBar is-int v-model="value" :max="Number(max)" :min="Number(min)" />
+      </div>
+    </template>
+  </SLMenu>
+</template>
+
+<script setup>
+import SLSlideBar from '@swanlab-vue/components/SLSlideBar.vue'
+import SLMenu from '@swanlab-vue/components/menu/SLMenu.vue'
+
+/**
+ * @description: 数字输入滑动条
+ * @file: InputSlideBar.vue
+ * @since: 2024-05-17 18:06:08
+ **/
+
+const props = defineProps({
+  modelValue: {
+    type: Number,
+    required: true
+  },
+  min: {
+    type: String,
+    required: true
+  },
+  max: {
+    type: String,
+    required: true
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const value = computed({
+  get: () => props.modelValue,
+  set: (val) => {
+    if (val < Number(props.min)) {
+      emit('update:modelValue', Number(props.min))
+    } else if (val > Number(props.max)) {
+      emit('update:modelValue', Number(props.max))
+    } else {
+      emit('update:modelValue', val)
+    }
+  }
+})
+
+// ---------------------------------- handleClickInput ----------------------------------
+const handleClickInput = (e, open) => {
+  // console.log('handleClickInput', e, open)
+  if (open) {
+    // console.log('阻止关闭')
+    e.stopPropagation()
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+input {
+  @apply border px-2 py-1 rounded;
+}
+</style>
diff --git a/vue/src/charts/components/LineChartLegend.vue b/vue/src/charts/components/LineChartLegend.vue
index 45df88f2..600f1a54 100644
--- a/vue/src/charts/components/LineChartLegend.vue
+++ b/vue/src/charts/components/LineChartLegend.vue
@@ -64,6 +64,7 @@ const handleClick = (item) => {
       display: inline-block;
       width: 8px;
       height: 2px;
+      margin-top: 2px;
       margin-right: 4px;
       background-color: currentColor;
     }
diff --git a/vue/src/charts/components/PannelButton.vue b/vue/src/charts/components/PanelButton.vue
similarity index 98%
rename from vue/src/charts/components/PannelButton.vue
rename to vue/src/charts/components/PanelButton.vue
index c723fa49..4212de16 100644
--- a/vue/src/charts/components/PannelButton.vue
+++ b/vue/src/charts/components/PanelButton.vue
@@ -9,7 +9,7 @@ import SLIcon from '@swanlab-vue/components/SLIcon.vue'
 
 /**
  * @description: chart的按钮样式
- * @file: PannelButton.vue
+ * @file: PanelButton.vue
  * @since: 2023-12-12 21:43:02
  **/
 
diff --git a/vue/src/charts/package/LineChart.vue b/vue/src/charts/package/LineChart.vue
index 28964be0..762fa6de 100644
--- a/vue/src/charts/package/LineChart.vue
+++ b/vue/src/charts/package/LineChart.vue
@@ -1,42 +1,44 @@
 <template>
-  <!-- 图表标题 -->
-  <p class="text-center font-semibold select-none">{{ title }}</p>
-  <div class="flex flex-col justify-center grow text-dimmer gap-2" v-if="error">
-    <SLIcon class="mx-auto h-5 w-5" icon="error" />
-    <p class="text-center text-sm">
-      {{ $t('chart.charts.line.error', { type: error['data_class'], tag: source[0] }) }}
-    </p>
-  </div>
-  <template v-else>
-    <!-- x轴坐标单位 -->
-    <p class="absolute right-5 bottom-10 text-xs text-dimmer scale-90 select-none">{{ xTitle }}</p>
-    <!-- 图表主体 -->
-    <LineChartLegend
-      :items="legend"
-      :key="legend.length"
-      @hoverin="(item) => legendHoverin(item, false)"
-      @hoverout="(item) => legendHoverout(item, false)"
-      v-if="legend && multi"
-    />
-    <div class="relative">
-      <LineChartTooltip ref="tooltipRef" />
-      <div class="overflow-hidden" ref="g2Ref"></div>
+  <div ref="lineChartRef">
+    <!-- 图表标题 -->
+    <p class="text-center font-semibold select-none">{{ title }}</p>
+    <div class="flex flex-col justify-center grow text-dimmer gap-2" v-if="error">
+      <SLIcon class="mx-auto h-5 w-5" icon="error" />
+      <p class="text-center text-sm">
+        {{ $t('chart.charts.line.error', { type: error['data_class'], tag: source[0] }) }}
+      </p>
     </div>
-    <!-- 放大效果 -->
-    <SLModal class="p-10 pt-0 overflow-hidden" max-w="-1" v-model="isZoom" esc-exit>
-      <p class="text-center mt-4 mb-10 text-2xl font-semibold">{{ title }}</p>
+    <template v-else>
+      <!-- x轴坐标单位 -->
+      <p class="absolute right-5 bottom-10 text-xs text-dimmer scale-90 select-none">{{ xTitle }}</p>
+      <!-- 图表主体 -->
       <LineChartLegend
         :items="legend"
-        @hoverin="(item) => legendHoverin(item, true)"
-        @hoverout="(item) => legendHoverout(item, true)"
+        :key="legend.length"
+        @hoverin="(item) => legendHoverin(item, false)"
+        @hoverout="(item) => legendHoverout(item, false)"
         v-if="legend && multi"
       />
-      <div class="relative" ref="g2ZoomRef">
-        <LineChartTooltip ref="tooltipZoomRef" />
+      <div class="relative">
+        <LineChartTooltip ref="tooltipRef" />
+        <div class="overflow-hidden" ref="g2Ref"></div>
       </div>
-      <p class="absolute right-12 bottom-16 text-xs text-dimmer scale-90 select-none">{{ xTitle }}</p>
-    </SLModal>
-  </template>
+      <!-- 放大效果 -->
+      <SLModal class="p-10 pt-0 overflow-hidden" max-w="-1" v-model="isZoom" esc-exit>
+        <p class="text-center mt-4 mb-10 text-2xl font-semibold">{{ title }}</p>
+        <LineChartLegend
+          :items="legend"
+          @hoverin="(item) => legendHoverin(item, true)"
+          @hoverout="(item) => legendHoverout(item, true)"
+          v-if="legend && multi"
+        />
+        <div class="relative" ref="g2ZoomRef">
+          <LineChartTooltip ref="tooltipZoomRef" />
+        </div>
+        <p class="absolute right-12 bottom-16 text-xs text-dimmer scale-90 select-none">{{ xTitle }}</p>
+      </SLModal>
+    </template>
+  </div>
 </template>
 
 <script setup>
@@ -131,6 +133,7 @@ G2.registerShape('point', 'last-point', {
 
 // ---------------------------------- 组件渲染逻辑 ----------------------------------
 // 组件对象
+const lineChartRef = ref()
 const g2Ref = ref()
 const g2ZoomRef = ref()
 const tooltipRef = ref()
@@ -260,6 +263,7 @@ const createChart = (dom, data, config = {}, zoom = false) => {
       showMarkers: true,
       customContent: () => '',
       domStyles: {
+        // 自己写tooltip的样式
         'g2-tooltip': {
           boxShadow: 'none',
           borderWidth: 'none',
@@ -277,7 +281,7 @@ const createChart = (dom, data, config = {}, zoom = false) => {
       }
     },
     // 大小相关
-    height: 220,
+    height: originalHeight,
     width: undefined,
     autoFit: true,
     // 开启一些交互
@@ -678,6 +682,26 @@ const smooth = (method) => {
   render(chartData)
 }
 
+// ---------------------------------- 动态设置原始高度 ----------------------------------
+
+let originalHeight = 220
+/**
+ * @type {setOriginalChartHeight} setLineChartHeight
+ */
+const setLineChartHeight = (height, maxHeight = 800, minHeight = 200) => {
+  if (height > maxHeight) return
+  if (height < minHeight) return
+  const nowLineChartHeight = lineChartRef.value.clientHeight
+  const nowLineChartCanvas = lineChartRef.value.getElementsByTagName('canvas')[0]
+  if (!nowLineChartCanvas) return (originalHeight = height - nowLineChartCanvasHeight)
+  const nowLineChartCanvasHeight = nowLineChartCanvas.clientHeight
+  console.log('nowLineChartHeight', nowLineChartHeight)
+  console.log('nowLineChartCanvasHeight', nowLineChartCanvasHeight)
+  const staticHeight = nowLineChartHeight - nowLineChartCanvasHeight
+  // 设置canvas高度，需要减去静态高度
+  nowLineChartCanvas.style.height = `${height - staticHeight}px`
+}
+
 // ---------------------------------- 暴露api ----------------------------------
 defineExpose({
   render,
@@ -687,7 +711,8 @@ defineExpose({
   showTooltip: lineShowTooltip,
   hideTooltip: lineHideTooltip,
   thicken: thickenByTagLinkage,
-  thin: restoreByTagLinkage
+  thin: restoreByTagLinkage,
+  setHeight: setLineChartHeight
 })
 </script>
 
diff --git a/vue/src/components/confirm/SLConfirm.vue b/vue/src/components/confirm/SLConfirm.vue
index 830b4e9f..05ad23f6 100644
--- a/vue/src/components/confirm/SLConfirm.vue
+++ b/vue/src/components/confirm/SLConfirm.vue
@@ -36,10 +36,8 @@ const text = reactive({
 })
 
 const callback = {
-  resolve: () => {
-  },
-  reject: () => {
-  }
+  resolve: () => {},
+  reject: () => {}
 }
 
 // ---------------------------------- 控制展示和隐藏 ----------------------------------
diff --git a/vue/src/docs/chart.js b/vue/src/docs/chart.js
index 83914f60..ed67f2be 100644
--- a/vue/src/docs/chart.js
+++ b/vue/src/docs/chart.js
@@ -47,5 +47,14 @@
  * @callback OpenChartSubscribeCallback 图表本身向父组件传递数据的 订阅成功/数据更改 的回调函数，父组件将在订阅成功后调用这个函数
  * @param {string} key 数据源名称
  * @param {string} data 数据源的数据
- * @param {Obejct} error 请求失败的错误信息——如果有的话，没有就是null
+ * @param {Object} error 请求失败的错误信息——如果有的话，没有就是null
+ */
+
+// ---------------------------------- 图表api ----------------------------------
+
+/**
+ * @callback setOriginalChartHeight 设置图表的原始高度，如果在执行前图表还没有被渲染，那么将在图表渲染后立即执行
+ * @param {number} height 图表的高度, 单位px
+ * @param {number} [maxHeight=800] 图表的最大高度, 单位px, 默认为800
+ * @param {number} [minHeight=200] 图表的最小高度, 单位px, 默认为200
  */
diff --git a/vue/src/i18n/en-US/chart.json5 b/vue/src/i18n/en-US/chart.json5
index f2cdfae9..94d5290d 100644
--- a/vue/src/i18n/en-US/chart.json5
+++ b/vue/src/i18n/en-US/chart.json5
@@ -1,7 +1,3 @@
-/**
- * @desc English - US: Chart
- * 这部分翻译用于charts组件
- */
 {
   charts: {
     audio: {
@@ -35,6 +31,16 @@
       smooth: {
         smoothing: 'Smoothing',
         method: 'Method'
+      },
+      download: {
+        button: 'Download',
+        png: {
+          download: 'Download image',
+          height: 'Height',
+          name: 'Name',
+          title: 'Export PNG',
+          width: 'Width'
+        }
       }
     }
   },
diff --git a/vue/src/i18n/zh-CN/chart.json5 b/vue/src/i18n/zh-CN/chart.json5
index c05b94c3..3d25ecb1 100644
--- a/vue/src/i18n/zh-CN/chart.json5
+++ b/vue/src/i18n/zh-CN/chart.json5
@@ -22,7 +22,17 @@
       step: '步数',
       tag: '标签',
       time: '时间',
-      value: '值'
+      value: '值',
+      download: {
+        button: '下载',
+        png: {
+          title: '导出为PNG',
+          name: '名称',
+          width: '宽度',
+          height: '高度',
+          download: '下载图像'
+        }
+      }
     },
     text: {
       titles: {
diff --git a/vue/src/style.scss b/vue/src/style.scss
index 0bfd32de..e961cb48 100644
--- a/vue/src/style.scss
+++ b/vue/src/style.scss
@@ -69,3 +69,8 @@ div {
     }
   }
 }
+
+// 解决 html2canvas 截图时文字下移的问题
+img {
+  @apply inline-block;
+}
diff --git a/vue/src/utils/chart.js b/vue/src/utils/chart.js
deleted file mode 100644
index 9459cc58..00000000
--- a/vue/src/utils/chart.js
+++ /dev/null
@@ -1,5 +0,0 @@
-/**
- * 图表会用到的一些工具函数，比如格式化数据等
- */
-
-import { t } from '@/i18n'
diff --git a/vue/src/utils/common.js b/vue/src/utils/common.js
index 661a877f..4e160a52 100644
--- a/vue/src/utils/common.js
+++ b/vue/src/utils/common.js
@@ -1,6 +1,7 @@
 /**
  * 数据、事件处理等公用函数
  */
+import { getTimes } from './time'
 
 /**
  * 基础防抖函数，用于减少函数的调用频率，接收两个参数，第一个是要防抖的函数，第二个是延迟的时间
@@ -132,3 +133,14 @@ export const formatNumber2SN = (value, max = 1e5, min = 1e-4, digits = 4) => {
   // 最后处理和返回的时候需要保证是字符串格式，不然，如果符合js自动转化的条件，会自动变成科学计数法
   return sign + value
 }
+
+/**
+ * 生成文件名，包含时间戳
+ * @param {string} prefix 文件前缀
+ * @param {string} suffix 文件后缀
+ * @returns {string} 文件名
+ */
+export const generateFileNameWithTime = (prefix, suffix) => {
+  const { year, month, day, hour, minute, second } = getTimes(new Date().getTime())
+  return `${prefix}_${year}-${month}-${day}_${hour}-${minute}-${second}.${suffix}`
+}
diff --git a/vue/src/views/experiment/components/StopButton.vue b/vue/src/views/experiment/components/StopButton.vue
index e7c76121..ddcd3df8 100644
--- a/vue/src/views/experiment/components/StopButton.vue
+++ b/vue/src/views/experiment/components/StopButton.vue
@@ -31,10 +31,9 @@ const showColor = computed(() => {
 // ---------------------------------- 确认停止 ----------------------------------
 
 const stop = () => {
-  confirm(
-    t('experiment.index.header.stop.modal.title'),
-    t('experiment.index.header.stop.modal.text')
-  ).then(stop_experiment)
+  confirm(t('experiment.index.header.stop.modal.title'), t('experiment.index.header.stop.modal.text')).then(
+    stop_experiment
+  )
 }
 
 const stop_experiment = async () => {


Test patch:
diff --git a/test/start_server.py b/test/start_server.py
index 137a5964..4175938b 100644
--- a/test/start_server.py
+++ b/test/start_server.py
@@ -10,8 +10,12 @@
     增加了实际开发中不会用到的热启动功能
 """
 import uvicorn
+from tutils.config import SWANLAB_LOG_DIR
 from swanlab.db import connect
 from swanlab.server.app import app
+import os
+
+os.environ["SWANLAB_LOG_DIR"] = SWANLAB_LOG_DIR
 
 connect()