From c06944b652798165322905500b0e1aa579e803d8 Mon Sep 17 00:00:00 2001
From: jiangweidong <1053570670@qq.com>
Date: Wed, 5 Feb 2025 09:52:41 +0800
Subject: [PATCH 1/5] feat: Support right-click to copy SQL

---
 .../framework/console/DataViewConsole.java    |   3 +-
 .../Main/Explore/DataView/DataView.vue        |  95 ++++++++++++-
 .../Main/Explore/DataView/RightMenu.vue       | 130 ++++++++++++++++++
 .../components/Main/Explore/DataView/const.js |  12 ++
 4 files changed, 233 insertions(+), 7 deletions(-)
 create mode 100644 frontend/src/components/Main/Explore/DataView/RightMenu.vue
 create mode 100644 frontend/src/components/Main/Explore/DataView/const.js

diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java
index 7e6bd3d..0f9c9eb 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java
@@ -104,7 +104,8 @@ public void onConnect(Connect connect) {
     public void createDataView(String schemaName, String tableName) {
 
         var viewTitle = this.getTitle() + "child";
-        this.getPacketIO().sendPacket("new_data_view", Map.of("title", viewTitle));
+        var data = Map.of("title", viewTitle, "schema", this.schema, "table", this.table);
+        this.getPacketIO().sendPacket("new_data_view", data);
         var dataView = new DataView(viewTitle, this.getPacketIO(), this.getConsoleLogger());
 
         var session = SessionManager.getCurrentSession();
diff --git a/frontend/src/components/Main/Explore/DataView/DataView.vue b/frontend/src/components/Main/Explore/DataView/DataView.vue
index 3383327..703e59e 100644
--- a/frontend/src/components/Main/Explore/DataView/DataView.vue
+++ b/frontend/src/components/Main/Explore/DataView/DataView.vue
@@ -1,7 +1,8 @@
 <template>
-  <div v-loading="state.loading" class="data-view" id="doc">
-    <ExportDataDialog :visible.sync="exportDataDialogVisible" @submit="onExportSubmit"/>
-    <Toolbar :items="iToolBarItems"/>
+  <div id="doc" v-loading="state.loading" class="data-view" @contextmenu.prevent="preventDefaultContextMenu">
+    <RightMenu ref="rightMenu" :menus="menus" />
+    <ExportDataDialog :visible.sync="exportDataDialogVisible" @submit="onExportSubmit" />
+    <Toolbar :items="iToolBarItems" />
     <AgGridVue
       :rowData="rowData"
       :columnDefs="colDefs"
@@ -9,22 +10,26 @@
       style="height: 100%"
       :defaultColDef="defaultColDef"
       :gridOptions="gridOptions"
+      @cell-context-menu="showContextMenu"
     />
   </div>
 </template>
 
 <script>
+import store from '@/store'
 import Toolbar from '@/framework/components/Toolbar/index.vue'
 import { Subject } from 'rxjs'
 import ExportDataDialog from '@/components/Main/Explore/DataView/ExportDataDialog.vue'
 import { AgGridVue } from 'ag-grid-vue'
+import RightMenu from '@/components/Main/Explore/DataView/RightMenu.vue'
+import { SpecialCharacters, GeneralInsertSQL, GeneralUpdateSQL } from './const'
 
 import 'ag-grid-community/styles/ag-grid.css'
 import 'ag-grid-community/styles/ag-theme-balham.css'
 
 export default {
   name: 'DataView',
-  components: { ExportDataDialog, Toolbar, AgGridVue },
+  components: { ExportDataDialog, Toolbar, AgGridVue, RightMenu },
   props: {
     meta: {
       type: Object,
@@ -164,7 +169,30 @@ export default {
         suppressMovableColumnsHints: true,
         suppressSortingHints: true
       },
-      init: false
+      init: false,
+      currentRow: null,
+      menus: [
+        {
+          name: 'copy',
+          title: this.$t('Copy'),
+          icon: 'el-icon-document-copy',
+          hidden: () => { return !store.getters.profile.canCopy },
+          children: [
+            {
+              name: 'copy-insert',
+              title: this.$t('InsertStatement'),
+              icon: 'el-icon-document-copy',
+              callback: () => this.handleCopy('insert')
+            },
+            {
+              name: 'copy-update',
+              title: this.$t('UpdateStatement'),
+              icon: 'el-icon-document-copy',
+              callback: () => this.handleCopy('update')
+            }
+          ]
+        }
+      ]
     }
   },
   computed: {
@@ -231,9 +259,64 @@ export default {
     onExportSubmit(scope) {
       this.exportDataDialogVisible = false
       this.$emit('action', { action: 'export', data: scope })
+    },
+    showContextMenu(params) {
+      this.currentRow = params.data
+      this.$refs.rightMenu.show(params.event)
+    },
+    preventDefaultContextMenu(event) {
+      event.preventDefault()
+    },
+    wrap(str, specChar) {
+      const result = str ? str.trim() : ''
+      return `${specChar}${result}${specChar}`
+    },
+    handleCopy(action) {
+      let sql = ''
+      let fields = ''
+      let values = ''
+      let updated_attrs = ''
+      let conditional_attrs = ''
+      let hasPrimary = false
+      const dbType = store.getters.profile.dbType
+      const { schema, table } = this.meta
+      const char = SpecialCharacters[dbType]
+      const tableName = `${this.wrap(schema, char)}.${this.wrap(table, char)}`
+      const primaryKeys = ['id']
+      for (let i = 0; i < this.colDefs.length; i++) {
+        const fieldName = this.colDefs[i].field
+        const fieldValue = `'${(this.currentRow[fieldName] || '')}'`
+        if (action === 'insert') {
+          fields += (i > 0 ? ', ' : '') + this.wrap(fieldName, char)
+          values += (i > 0 ? ', ' : '') + `${fieldValue}`
+          sql = GeneralInsertSQL
+            .replace('{table_name}', tableName)
+            .replace('{fields}', fields)
+            .replace('{values}', values)
+        } else {
+          if (primaryKeys.includes(fieldName)) {
+            hasPrimary = true
+          } else {
+            updated_attrs += (i > 0 ? ', ' : '') + `${this.wrap(fieldName, char)} = ${fieldValue}`
+          }
+          if (hasPrimary) {
+            conditional_attrs = `${this.wrap('id', char)} = '${this.currentRow['id']}'`
+          } else {
+            conditional_attrs = `${updated_attrs} LIMIT 1`
+          }
+          sql = GeneralUpdateSQL
+            .replace('{table_name}', tableName)
+            .replace('{updated_attrs}', updated_attrs)
+            .replace('{conditional_attrs}', conditional_attrs)
+        }
+      }
+      navigator.clipboard.writeText(sql).then(() => {
+        this.$message.success(this.$t('CopySucceeded'))
+      }).catch((error) => {
+        this.$message.error(`${this.$t('CopyFailed')}: ${error}`)
+      })
     }
   }
-
 }
 </script>
 
diff --git a/frontend/src/components/Main/Explore/DataView/RightMenu.vue b/frontend/src/components/Main/Explore/DataView/RightMenu.vue
new file mode 100644
index 0000000..8c6fb82
--- /dev/null
+++ b/frontend/src/components/Main/Explore/DataView/RightMenu.vue
@@ -0,0 +1,130 @@
+<template>
+  <ul
+    v-show="menuVisible"
+    class="menus"
+    :style="{ top: menuTop, left: menuLeft }"
+  >
+    <li
+      v-for="menu in iMenus"
+      :key="menu.name"
+      :class="['menu', menu.children? '' : 'menu-hover']"
+      @click.stop="clickMenu(menu)"
+    >
+      <div @mouseenter="showSubMenu(menu)">
+        <i :class="menu.icon" style="margin-right: 5px;" />
+        <span>{{ menu.title }}</span>
+        <i v-if="menu.children" class="el-icon-arrow-right" style="margin-left: 5px;" />
+      </div>
+      <ul v-show="subMenuVisible" class="submenu">
+        <li
+          v-for="subMenu in menu.children"
+          :key="subMenu.name"
+          class="submenu-item menu-hover"
+          @click.stop="clickMenu(subMenu)"
+        >
+          <div>
+            <i :class="subMenu.icon" style="margin-right: 5px;" />
+            <span>{{ subMenu.title }}</span>
+          </div>
+        </li>
+      </ul>
+    </li>
+  </ul>
+</template>
+
+<script>
+export default {
+  name: 'RightMenu',
+  props: {
+    menus: {
+      type: Array,
+      default: () => []
+    }
+  },
+  data() {
+    return {
+      menuTop: '0px',
+      menuLeft: '0px',
+      menuVisible: false,
+      subMenuVisible: false
+    }
+  },
+  computed: {
+    iMenus() {
+      return this.menus.filter((m) => {
+        if (typeof m.hidden === 'function') {
+          return !m.hidden()
+        }
+        return !m.hidden
+      })
+    }
+  },
+  methods: {
+    clickMenu(menu) {
+      if (typeof menu.callback === 'function') {
+        menu.callback()
+      }
+    },
+    show(event) {
+      if (this.iMenus.length === 0) {
+        return
+      }
+      this.menuVisible = true
+      const { clientX: x, clientY: y } = event
+      const { innerWidth: innerWidth, innerHeight: innerHeight } = window
+      const menuWidth = 180
+      const menuHeight = this.iMenus.length * 30
+      this.menuTop = (y + menuHeight > innerHeight ? innerHeight - menuHeight : y) + 'px'
+      this.menuLeft = (x + menuWidth > innerWidth ? innerWidth - menuWidth : x) + 'px'
+      document.addEventListener('mouseup', this.hide, false)
+    },
+    hide(e) {
+      if (e.button === 0) {
+        this.menuVisible = false
+        this.subMenuVisible = false
+        document.removeEventListener('mouseup', this.hide)
+      }
+    },
+    showSubMenu(menu) {
+      this.subMenuVisible = !!menu.children
+    }
+  }
+}
+</script>
+
+<style lang='scss' scoped>
+.menu-hover:hover {
+  color: #2f65ca;
+}
+
+.menus {
+  background: #fff;
+  border-radius: 4px;
+  list-style-type: none;
+  padding: 3px;
+  position: fixed;
+  z-index: 9999;
+  display: block;
+
+  .menu {
+    padding: 6px 12px;
+    cursor: pointer;
+
+    .submenu {
+      background: #fff;
+      list-style-type: none;
+      padding: 3px;
+      position: absolute;
+      top: 0;
+      left: 100%;
+    }
+
+    .submenu-item {
+      display: block;
+      white-space: nowrap;
+      padding: 6px 12px;
+      cursor: pointer;
+    }
+  }
+}
+</style>
diff --git a/frontend/src/components/Main/Explore/DataView/const.js b/frontend/src/components/Main/Explore/DataView/const.js
new file mode 100644
index 0000000..d155671
--- /dev/null
+++ b/frontend/src/components/Main/Explore/DataView/const.js
@@ -0,0 +1,12 @@
+export const GeneralUpdateSQL = 'UPDATE {table_name} SET {updated_attrs} WHERE {conditional_attrs};'
+export const GeneralInsertSQL = 'INSERT INTO {table_name} ({fields}) VALUES ({values});'
+
+export const SpecialCharacters = {
+  mysql: '`',
+  mariadb: '`',
+  postgresql: '"',
+  sqlserver: '',
+  oracle: '"',
+  dameng: '"',
+  db2: ''
+}

From 2e281a4c5a34d67535ab29aef98e3f10975a68fd Mon Sep 17 00:00:00 2001
From: jiangweidong <1053570670@qq.com>
Date: Wed, 5 Feb 2025 10:48:25 +0800
Subject: [PATCH 2/5] perf: Optimization Tips

---
 frontend/src/components/Main/Explore/DataView/DataView.vue | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/frontend/src/components/Main/Explore/DataView/DataView.vue b/frontend/src/components/Main/Explore/DataView/DataView.vue
index 703e59e..d147ece 100644
--- a/frontend/src/components/Main/Explore/DataView/DataView.vue
+++ b/frontend/src/components/Main/Explore/DataView/DataView.vue
@@ -310,6 +310,10 @@ export default {
             .replace('{conditional_attrs}', conditional_attrs)
         }
       }
+      if (!navigator.clipboard) {
+        this.$message.error(`${this.$t('NoPermissionError')}: clipboard`)
+        return
+      }
       navigator.clipboard.writeText(sql).then(() => {
         this.$message.success(this.$t('CopySucceeded'))
       }).catch((error) => {

From 366871b8de54ee11f0607ab19d64088b9f1ee647 Mon Sep 17 00:00:00 2001
From: Aaron3S <chenyang@fit2cloud.com>
Date: Thu, 6 Feb 2025 17:49:19 +0800
Subject: [PATCH 3/5] feat: export csv and excel

---
 backend/framework/pom.xml                     |   5 +
 .../framework/console/dataview/DataView.java  |  98 +++----------
 .../console/dataview/export/DataExport.java   | 137 ++++++++++++++++++
 .../dataview/export/DataExportInterface.java  |   7 +
 .../Explore/DataView/ExportDataDialog.vue     |  17 ++-
 5 files changed, 186 insertions(+), 78 deletions(-)
 create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
 create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExportInterface.java

diff --git a/backend/framework/pom.xml b/backend/framework/pom.xml
index 8d1b25f..aaedccc 100644
--- a/backend/framework/pom.xml
+++ b/backend/framework/pom.xml
@@ -52,6 +52,11 @@
             <artifactId>bcpkix-jdk15on</artifactId>
             <version>1.68</version>
         </dependency>
+        <dependency>
+            <groupId>org.dhatim</groupId>
+            <artifactId>fastexcel</artifactId>
+            <version>0.18.4</version>
+        </dependency>
     </dependencies>
 
 
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java
index f2d5e34..be4eb21 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java
@@ -4,26 +4,18 @@
 import lombok.EqualsAndHashCode;
 import org.jumpserver.chen.framework.console.action.DataViewAction;
 import org.jumpserver.chen.framework.console.component.Logger;
+import org.jumpserver.chen.framework.console.dataview.export.DataExport;
 import org.jumpserver.chen.framework.console.entity.response.SQLResult;
 import org.jumpserver.chen.framework.console.state.DataViewState;
 import org.jumpserver.chen.framework.console.state.StateManager;
-import org.jumpserver.chen.framework.datasource.entity.resource.Field;
 import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams;
 import org.jumpserver.chen.framework.datasource.sql.SQLQueryResult;
 import org.jumpserver.chen.framework.jms.entity.CommandRecord;
 import org.jumpserver.chen.framework.session.SessionManager;
-import org.jumpserver.chen.framework.utils.CodeUtils;
 import org.jumpserver.chen.framework.ws.io.PacketIO;
 
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.sql.Clob;
+import java.io.File;
 import java.sql.SQLException;
-import java.text.SimpleDateFormat;
-import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -74,7 +66,10 @@ public void doAction(DataViewAction action) throws SQLException {
                 this.changeLimit((int) action.getData());
             }
             case DataViewAction.ACTION_EXPORT -> {
-                this.export((String) action.getData());
+                var data = (Map<String, String>) action.getData();
+                var scope = data.get("scope");
+                var format = data.get("format");
+                this.export(scope, format);
             }
         }
     }
@@ -134,84 +129,39 @@ private void fullDataViewData(DataViewData viewData, SQLQueryResult result) {
     }
 
 
-    private static void writeString(BufferedWriter writer, Object object) throws IOException {
-        var str = object.toString();
-
-        if (str.contains(",")) {
-            str = "\"" + str + "\"";
-        }
-        writer.write(str);
-    }
-
-    private void writeCSVData(BufferedWriter writer, DataViewData viewData) throws IOException, SQLException {
-
-        for (Field field : viewData.getFields()) {
-            writeString(writer, field.getName());
-            writer.write(",");
-        }
-        for (Map<String, Object> row : viewData.getData()) {
-            for (Field field : viewData.getFields()) {
-                var obj = row.get(field.getName());
-                if (obj == null) {
-                    writer.write("NULL");
-                    writer.write(",");
-                } else if (obj instanceof Clob clob) {
-                    writer.write(CodeUtils.escapeCsvValue(clob.getSubString(1, (int) clob.length())));
-                    writer.write(",");
-                } else if (obj instanceof Date) {
-                    SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-                    writeString(writer, fmt.format(obj));
-                } else {
-                    writeString(writer, row.get(field.getName()));
-                    writer.write(",");
-                }
-            }
-            writer.newLine();
-        }
-
-        writer.newLine();
-    }
-
-    public void export(String scope) throws SQLException {
+    public void export(String scope, String format) throws SQLException {
         var session = SessionManager.getCurrentSession();
 
-
-        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
-        String timestamp = LocalDateTime.now().format(formatter);
-        var f = session.createFile(String.format("data_%s.csv", timestamp));
-
         CommandRecord command = new CommandRecord(String.format("Export data: %s", this.title));
 
         try {
             if (!SessionManager.getCurrentSession().canDownload()) {
-                session.getController().sendFile(f.getName());
                 return;
             }
-            var writer = Files.newBufferedWriter(f.toPath());
-
-            if (scope.equals("current")) {
-                this.writeCSVData(writer, this.data);
-                command.setOutput(String.format("%d rows exported", this.data.getData().size()));
-            }
-
-            if (scope.equals("all")) {
-                SQLQueryParams queryParams = new SQLQueryParams();
-                queryParams.setLimit(-1);
-                var result = this.loadDataInterface.loadData(queryParams);
-                var viewData = new DataViewData();
-                this.fullDataViewData(viewData, result);
-                command.setOutput(String.format("%d rows exported", result.getData().size()));
+            File f = null;
+            switch (scope) {
+                case "current":
+                    f = DataExport.export(format, this.data);
+                    command.setOutput(String.format("%d rows exported", this.data.getData().size()));
+                    break;
+                case "all":
+                    SQLQueryParams queryParams = new SQLQueryParams();
+                    queryParams.setLimit(-1);
+                    var result = this.loadDataInterface.loadData(queryParams);
+                    var viewData = new DataViewData();
+                    this.fullDataViewData(viewData, result);
+                    f = DataExport.export(format, viewData);
+                    command.setOutput(String.format("%d rows exported", result.getData().size()));
+                    break;
             }
-            writer.flush();
-            writer.close();
 
             this.consoleLogger.success(command.getOutput());
             session.recordCommand(command);
+            session.getController().sendFile(f.getName());
 
-        } catch (IOException e) {
+        } catch (Exception e) {
             throw new RuntimeException(e);
         }
-        session.getController().sendFile(f.getName());
     }
 
 
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
new file mode 100644
index 0000000..ef71526
--- /dev/null
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
@@ -0,0 +1,137 @@
+package org.jumpserver.chen.framework.console.dataview.export;
+
+import org.dhatim.fastexcel.Workbook;
+import org.dhatim.fastexcel.Worksheet;
+import org.jumpserver.chen.framework.console.dataview.DataViewData;
+import org.jumpserver.chen.framework.datasource.entity.resource.Field;
+import org.jumpserver.chen.framework.session.SessionManager;
+import org.jumpserver.chen.framework.utils.CodeUtils;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Clob;
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+
+public class DataExport {
+    public static File export(String format, DataViewData data) throws Exception {
+        var session = SessionManager.getCurrentSession();
+
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
+        String timestamp = LocalDateTime.now().format(formatter);
+
+        String filename = String.format("data_%s", timestamp);
+
+        File f;
+
+        switch (format) {
+            case "excel":
+                f = session.createFile(String.format("%s.xlsx", filename));
+                new DataExportExcel().exportData(f.toPath().toString(), data);
+                break;
+            case "csv":
+                f = session.createFile(String.format("%s.csv", filename));
+                new DataExportCSV().exportData(f.toPath().toString(), data);
+                break;
+            default:
+                throw new Exception("unsupported format: " + format);
+        }
+        return f;
+    }
+}
+
+
+class DataExportExcel implements DataExportInterface {
+    @Override
+    public void exportData(String path, DataViewData data) throws Exception {
+        try (FileOutputStream fos = new FileOutputStream(path);
+             Workbook workbook = new Workbook(fos, "JumpServer", "4.0")) {
+
+            Worksheet sheet = workbook.newWorksheet("Data");
+            List<Field> fields = data.getFields();
+            List<Map<String, Object>> rows = data.getData();
+
+            for (int col = 0; col < fields.size(); col++) {
+                sheet.value(0, col, fields.get(col).getName());
+            }
+
+            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+            for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
+                Map<String, Object> row = rows.get(rowIndex);
+
+                for (int col = 0; col < fields.size(); col++) {
+                    Field field = fields.get(col);
+                    Object obj = row.get(field.getName());
+
+                    if (obj == null) {
+                        sheet.value(rowIndex + 1, col, "NULL");
+                    } else if (obj instanceof Clob clob) {
+                        try {
+                            sheet.value(rowIndex + 1, col, clob.getSubString(1, (int) clob.length()));
+                        } catch (Exception e) {
+                            sheet.value(rowIndex + 1, col, "ERROR_CLOB");
+                        }
+                    } else if (obj instanceof Date) {
+                        sheet.value(rowIndex + 1, col, dateFormat.format(obj));
+                    } else {
+                        sheet.value(rowIndex + 1, col, obj.toString());
+                    }
+                }
+            }
+        }
+    }
+}
+
+class DataExportCSV implements DataExportInterface {
+    @Override
+    public void exportData(String path, DataViewData data) throws Exception {
+        var writer = Files.newBufferedWriter(Path.of(path));
+
+        for (Field field : data.getFields()) {
+            writeString(writer, field.getName());
+            writer.write(",");
+        }
+        for (Map<String, Object> row : data.getData()) {
+            for (Field field : data.getFields()) {
+                var obj = row.get(field.getName());
+                if (obj == null) {
+                    writer.write("NULL");
+                    writer.write(",");
+                } else if (obj instanceof Clob clob) {
+                    writer.write(CodeUtils.escapeCsvValue(clob.getSubString(1, (int) clob.length())));
+                    writer.write(",");
+                } else if (obj instanceof Date) {
+                    SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+                    writeString(writer, fmt.format(obj));
+                } else {
+                    writeString(writer, row.get(field.getName()));
+                    writer.write(",");
+                }
+            }
+            writer.newLine();
+        }
+
+        writer.newLine();
+        writer.flush();
+        writer.close();
+    }
+
+    private static void writeString(BufferedWriter writer, Object object) throws IOException {
+        var str = object.toString();
+
+        if (str.contains(",")) {
+            str = "\"" + str + "\"";
+        }
+        writer.write(str);
+    }
+}
\ No newline at end of file
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExportInterface.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExportInterface.java
new file mode 100644
index 0000000..2bfb075
--- /dev/null
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExportInterface.java
@@ -0,0 +1,7 @@
+package org.jumpserver.chen.framework.console.dataview.export;
+
+import org.jumpserver.chen.framework.console.dataview.DataViewData;
+
+public interface DataExportInterface {
+    void exportData(String path, DataViewData data) throws Exception;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Main/Explore/DataView/ExportDataDialog.vue b/frontend/src/components/Main/Explore/DataView/ExportDataDialog.vue
index c4029e4..0371bc7 100644
--- a/frontend/src/components/Main/Explore/DataView/ExportDataDialog.vue
+++ b/frontend/src/components/Main/Explore/DataView/ExportDataDialog.vue
@@ -6,8 +6,16 @@
     width="40%"
   >
     <el-form ref="form" :model="form" label-width="80px">
-      <el-radio v-model="form.scope" label="current">{{ $tc('ExportCurrent') }}</el-radio>
-      <el-radio v-model="form.scope" label="all">{{ $tc('ExportAll') }}</el-radio>
+
+      <el-form-item :label="$tc('Scope')">
+        <el-radio v-model="form.scope" label="current">{{ $tc('ExportCurrent') }}</el-radio>
+        <el-radio v-model="form.scope" label="all">{{ $tc('ExportAll') }}</el-radio>
+      </el-form-item>
+
+      <el-form-item :label="$tc('Format')">
+        <el-radio v-model="form.format" label="csv">CSV</el-radio>
+        <el-radio v-model="form.format" label="excel">Excel</el-radio>
+      </el-form-item>
     </el-form>
 
     <span slot="footer" class="dialog-footer">
@@ -35,7 +43,8 @@ export default {
   data() {
     return {
       form: {
-        scope: 'current'
+        scope: 'current',
+        format: 'csv'
       }
     }
   },
@@ -53,7 +62,7 @@ export default {
   },
   methods: {
     onSubmit() {
-      this.$emit('submit', this.form.scope)
+      this.$emit('submit', { scope: this.form.scope, format: this.form.format })
     }
   }
 }

From 16264962e9087b9a83cdbb840b4f9364d81dd47f Mon Sep 17 00:00:00 2001
From: Aaron3S <chenyang@fit2cloud.com>
Date: Tue, 18 Feb 2025 15:47:23 +0800
Subject: [PATCH 4/5] fix: export write new line

---
 .../chen/framework/console/dataview/export/DataExport.java       | 1 +
 1 file changed, 1 insertion(+)

diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
index ef71526..a1255c7 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
@@ -101,6 +101,7 @@ public void exportData(String path, DataViewData data) throws Exception {
             writeString(writer, field.getName());
             writer.write(",");
         }
+        writer.newLine();
         for (Map<String, Object> row : data.getData()) {
             for (Field field : data.getFields()) {
                 var obj = row.get(field.getName());

From 1267fb2fcc2cf86505a5e34096e31c50241d14ec Mon Sep 17 00:00:00 2001
From: Aaron3S <chenyang@fit2cloud.com>
Date: Wed, 19 Feb 2025 18:54:32 +0800
Subject: [PATCH 5/5] feat: add health check api

---
 .../web/controller/HealthCheckController.java     | 15 +++++++++++++++
 1 file changed, 15 insertions(+)
 create mode 100644 backend/web/src/main/java/org/jumpserver/chen/web/controller/HealthCheckController.java

diff --git a/backend/web/src/main/java/org/jumpserver/chen/web/controller/HealthCheckController.java b/backend/web/src/main/java/org/jumpserver/chen/web/controller/HealthCheckController.java
new file mode 100644
index 0000000..3da55a1
--- /dev/null
+++ b/backend/web/src/main/java/org/jumpserver/chen/web/controller/HealthCheckController.java
@@ -0,0 +1,15 @@
+package org.jumpserver.chen.web.controller;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/healthy")
+public class HealthCheckController {
+
+    @GetMapping("")
+    public String healthCheck() {
+        return "ok";
+    }
+}