Browse Source

feat:batchDownload增加查看和子文件下载功能

Jerry Liang 9 tháng trước cách đây
mục cha
commit
38eaca2600

+ 2 - 0
package.json

@@ -45,6 +45,8 @@
     "chroma-js": "^3.1.2",
     "dayjs": "^1.11.18",
     "es-toolkit": "^1.39.8",
+    "monaco-editor": "^0.55.1",
+    "monaco-editor-vue3": "^1.0.5",
     "naive-ui": "^2.42.0",
     "pinia": "^3.0.3",
     "pinia-plugin-persistedstate": "^4.4.1",

+ 77 - 32
pnpm-lock.yaml

@@ -80,6 +80,12 @@ importers:
       es-toolkit:
         specifier: ^1.39.8
         version: 1.40.0
+      monaco-editor:
+        specifier: ^0.55.1
+        version: 0.55.1
+      monaco-editor-vue3:
+        specifier: ^1.0.5
+        version: 1.0.5(monaco-editor@0.55.1)(vue@3.5.22(typescript@5.8.3))
       naive-ui:
         specifier: ^2.42.0
         version: 2.43.1(vue@3.5.22(typescript@5.8.3))
@@ -1494,6 +1500,9 @@ packages:
   '@types/tough-cookie@4.0.5':
     resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
 
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
   '@types/unist@3.0.3':
     resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
 
@@ -1559,43 +1568,43 @@ packages:
     resolution: {integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
-  '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-ICAIUBa9ecoHrKTIG9WgHBUMOpdGxwYarRW0x4PRHs34cKWz1VYy50Y+NG2Ivai2WC/T12QSWom0UBZDL1/Zkw==}
+  '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-rpy7WzXpETCIc+QBfE8J1RpNxvb/5pnKh98DbC4gXpxHKrkjkg0LX86CJSoPaCF6xGcfRsegi49q+bXrtH4LWQ==}
     cpu: [arm64]
     os: [darwin]
 
-  '@typescript/native-preview-darwin-x64@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-UtnRE4OPmlxAYE42HBtcqQH/TsDZcs/7ej6K11mCD9uzCLgxxAZveI+bW43VIyPLP9dZFmX/3Pvvv8arBBwGfw==}
+  '@typescript/native-preview-darwin-x64@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-s1Zn16TqesG2x4wx5cKciyfmffZRhJO2fBSN4fQ6bBWbrZHT8KnjJUpoRd8t+JAXsHfxDy4S/S0DcmX5eRfAHQ==}
     cpu: [x64]
     os: [darwin]
 
-  '@typescript/native-preview-linux-arm64@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-sAU6b0Tlz3QahPpuNA0HOibGKruHvbFCANuuns6aR4OYd+zNi+diBBvCTyLWu0Do3ABjpCphrj4hCvs2rWfoQQ==}
+  '@typescript/native-preview-linux-arm64@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-dMIOprNNvILRLKLlevs9fEE6Q6Bhikjv9OiQb3Sma3lcV/4lvh8DJ5hVKj2/aiGrK1000EC54UzwB6rLxnuw0w==}
     cpu: [arm64]
     os: [linux]
 
-  '@typescript/native-preview-linux-arm@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-/divCKJhdfq1s7UG3EO2NYHHJl1EqcEIylD/pl/GbAMeKRomaKGTwwgshxv9/PoTp4AVHEKrziPDdN6sIOnFQQ==}
+  '@typescript/native-preview-linux-arm@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-ybrJVEuB9TmLWwdNl1ryUHeAm6KV3QcOKZUdfKhp6TjmuesOd1/2rdnGHmEcZlQDQt5+RtsCaxHWadvqDF1h3Q==}
     cpu: [arm]
     os: [linux]
 
-  '@typescript/native-preview-linux-x64@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-KcjKJoUtqFh0VD2zGJJZ2PM+GLlK11bZFJvvdoJj/D9nf9WSRE4jWLGE+50gyy9boeaKVlZuRnTfxP6Rt3nnrw==}
+  '@typescript/native-preview-linux-x64@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-bbgb8a3dETxTTFUbSsCF7sSQ2oCsnyg7/HcFXi12TxdXscu1qod4/MxznBNOvwG/eYcrsLmI14k6BVRVSiGFoQ==}
     cpu: [x64]
     os: [linux]
 
-  '@typescript/native-preview-win32-arm64@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-0TolySdSZ8w1adKNl4eEGht4lyAMJRTwwdsntbdcZ2sjDWVc4ikyu+GlYTYtkJM2M0w1Q7+iFMpWm6+M6hPHzg==}
+  '@typescript/native-preview-win32-arm64@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-JxPwmsaV6wuijnOo4qg5aIMrzQarTNVHqUaVP5pijkon8Y6dCvJ+3lYNEe4E6baiiw1u3npUQY4l/cvx2RL1eQ==}
     cpu: [arm64]
     os: [win32]
 
-  '@typescript/native-preview-win32-x64@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-yhntwcToKN6aL4rcuslb60toB1SLArpzCwNmK9jnnwJ4JexViOldBu1caZxFhVXL5eQrUCvxfipGEU30MQxhtw==}
+  '@typescript/native-preview-win32-x64@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-TJH+sSn7vxcGvaSm+fhcpW//dfSGBZGHrxoy0edAbE7UpFf8ub/3cILL5V1pCsF7w3aLNNgVKRpUkowdUgYcPQ==}
     cpu: [x64]
     os: [win32]
 
-  '@typescript/native-preview@7.0.0-dev.20260115.1':
-    resolution: {integrity: sha512-wCW5A5Olb/Iyzr6azx1lrS42DEmkUvzLqrtL+0mzKDov4l3sdxKsO5q/m8Mdqcx9WBTY07e+WgHZ6IIfjSOWrQ==}
+  '@typescript/native-preview@7.0.0-dev.20260122.2':
+    resolution: {integrity: sha512-+bVirfVpvhDG/oJWzA1c9HX6fC5Wu2hPv3LdBcpf0UtOu0KoWBN+B4L0wYY9gvxvvoxGmupdRCqaM8hRNlXzaw==}
     hasBin: true
 
   '@umami/node@0.4.0':
@@ -2575,6 +2584,9 @@ packages:
     resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
     engines: {node: '>= 4'}
 
+  dompurify@3.2.7:
+    resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
+
   domutils@2.8.0:
     resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
 
@@ -3474,6 +3486,11 @@ packages:
   markdown-table@3.0.4:
     resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
 
+  marked@14.0.0:
+    resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
+    engines: {node: '>= 18'}
+    hasBin: true
+
   mdast-util-find-and-replace@3.0.2:
     resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
 
@@ -3651,6 +3668,15 @@ packages:
   mlly@1.8.0:
     resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
 
+  monaco-editor-vue3@1.0.5:
+    resolution: {integrity: sha512-1n4ayGcuYpZcQ6A4PUYZYqXuf6n+A/2pUNdLnb8I39WZV0zkhfvSRNau3hB0qzq6vp6wSNZ7MgcjQRCJCiGHWQ==}
+    peerDependencies:
+      monaco-editor: '>= 0.25.0 < 1'
+      vue: ^3
+
+  monaco-editor@0.55.1:
+    resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
+
   mri@1.2.0:
     resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
     engines: {node: '>=4'}
@@ -6265,6 +6291,9 @@ snapshots:
 
   '@types/tough-cookie@4.0.5': {}
 
+  '@types/trusted-types@2.0.7':
+    optional: true
+
   '@types/unist@3.0.3': {}
 
   '@types/web-bluetooth@0.0.21': {}
@@ -6362,36 +6391,36 @@ snapshots:
       '@typescript-eslint/types': 8.46.1
       eslint-visitor-keys: 4.2.1
 
-  '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260115.1':
+  '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260122.2':
     optional: true
 
-  '@typescript/native-preview-darwin-x64@7.0.0-dev.20260115.1':
+  '@typescript/native-preview-darwin-x64@7.0.0-dev.20260122.2':
     optional: true
 
-  '@typescript/native-preview-linux-arm64@7.0.0-dev.20260115.1':
+  '@typescript/native-preview-linux-arm64@7.0.0-dev.20260122.2':
     optional: true
 
-  '@typescript/native-preview-linux-arm@7.0.0-dev.20260115.1':
+  '@typescript/native-preview-linux-arm@7.0.0-dev.20260122.2':
     optional: true
 
-  '@typescript/native-preview-linux-x64@7.0.0-dev.20260115.1':
+  '@typescript/native-preview-linux-x64@7.0.0-dev.20260122.2':
     optional: true
 
-  '@typescript/native-preview-win32-arm64@7.0.0-dev.20260115.1':
+  '@typescript/native-preview-win32-arm64@7.0.0-dev.20260122.2':
     optional: true
 
-  '@typescript/native-preview-win32-x64@7.0.0-dev.20260115.1':
+  '@typescript/native-preview-win32-x64@7.0.0-dev.20260122.2':
     optional: true
 
-  '@typescript/native-preview@7.0.0-dev.20260115.1':
+  '@typescript/native-preview@7.0.0-dev.20260122.2':
     optionalDependencies:
-      '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260115.1
-      '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260115.1
-      '@typescript/native-preview-linux-arm': 7.0.0-dev.20260115.1
-      '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260115.1
-      '@typescript/native-preview-linux-x64': 7.0.0-dev.20260115.1
-      '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260115.1
-      '@typescript/native-preview-win32-x64': 7.0.0-dev.20260115.1
+      '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260122.2
+      '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260122.2
+      '@typescript/native-preview-linux-arm': 7.0.0-dev.20260122.2
+      '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260122.2
+      '@typescript/native-preview-linux-x64': 7.0.0-dev.20260122.2
+      '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260122.2
+      '@typescript/native-preview-win32-x64': 7.0.0-dev.20260122.2
 
   '@umami/node@0.4.0': {}
 
@@ -7565,6 +7594,10 @@ snapshots:
     dependencies:
       domelementtype: 2.3.0
 
+  dompurify@3.2.7:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+
   domutils@2.8.0:
     dependencies:
       dom-serializer: 1.4.1
@@ -8491,6 +8524,8 @@ snapshots:
 
   markdown-table@3.0.4: {}
 
+  marked@14.0.0: {}
+
   mdast-util-find-and-replace@3.0.2:
     dependencies:
       '@types/mdast': 4.0.4
@@ -8852,6 +8887,16 @@ snapshots:
       pkg-types: 1.3.1
       ufo: 1.6.1
 
+  monaco-editor-vue3@1.0.5(monaco-editor@0.55.1)(vue@3.5.22(typescript@5.8.3)):
+    dependencies:
+      monaco-editor: 0.55.1
+      vue: 3.5.22(typescript@5.8.3)
+
+  monaco-editor@0.55.1:
+    dependencies:
+      dompurify: 3.2.7
+      marked: 14.0.0
+
   mri@1.2.0: {}
 
   mrmime@2.0.1: {}
@@ -10109,7 +10154,7 @@ snapshots:
 
   vue-vine-tsc@1.7.13(typescript@5.8.3):
     dependencies:
-      '@typescript/native-preview': 7.0.0-dev.20260115.1
+      '@typescript/native-preview': 7.0.0-dev.20260122.2
       '@volar/language-core': 2.4.20
       '@volar/typescript': 2.4.20
       '@vue-vine/language-service': 1.7.13(typescript@5.8.3)

+ 80 - 0
service/controllers/batchDownloadControllers.go

@@ -5,6 +5,9 @@
  * /batchDownload/manage/description/{id} - POST 更新任务说明
  * /batchDownload/manage/config/{id} - DELETE 删除配置
  * /batchDownload/data/{id} - GET 获取指定ID的数据包下载地址
+ * /batchDownload/manage/path - GET 获取指定ID的文件目录结构
+ * /batchDownload/manage/fileTree - GET 获取指定文件的目录结构
+ * /batchDownload/manage/fileDownload - GET 下载指定文件
  */
 package controllers
 
@@ -13,7 +16,10 @@ import (
 	"fmt"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/services"
+	"fwh-toolbox-server/utils/mimetype"
+	"path"
 	"strconv"
+	"strings"
 
 	"github.com/gofiber/fiber/v2"
 	"go.mongodb.org/mongo-driver/bson/primitive"
@@ -139,3 +145,77 @@ func GetBatchDownloadData(c *fiber.Ctx) error {
 
 	return nil
 }
+
+// 获取指定ID的文件目录结构
+// /batchDownload/manage/path
+func GetBatchDownloadPath(c *fiber.Ctx) error {
+	id := c.Query("id", "")
+
+	objID, err := primitive.ObjectIDFromHex(id)
+	if err != nil {
+		zap.L().Warn("Invalid request para :id", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request para :id")
+	}
+
+	pathInfo, err := services.GetBatchDownloadPath(objID)
+	if err != nil {
+		zap.L().Warn("Path query failed.", zap.String("err", err.Error()))
+		return Error(c, "Path query failed.")
+	}
+	return Success(c, pathInfo)
+}
+
+// 获取指定文件的目录结构
+// /batchDownload/manage/fileTree
+func GetBatchDownloadFileTree(c *fiber.Ctx) error {
+	filePath := c.Query("file", "")
+
+	pathInfo, err := services.GetBatchDownloadFileTree(filePath)
+	if err != nil {
+		zap.L().Warn("File tree query failed.", zap.String("err", err.Error()))
+		return Error(c, "File tree query failed.")
+	}
+	return Success(c, pathInfo)
+}
+
+// 下载指定文件
+// /batchDownload/manage/fileDownload
+func GetBatchDownloadFileDownload(c *fiber.Ctx) error {
+	filePath := c.Query("file", "")
+	innerPath := c.Query("inner", "")
+
+	var fileName string
+	if innerPath == "" {
+		fileName = path.Base(filePath)
+	} else {
+		if !strings.HasSuffix(filePath, ".tar.gz") {
+			return Error(c, "File path suffix not is \".tar.gz\".")
+		}
+		fileName = path.Base(innerPath)
+	}
+
+	if fileName == "." {
+		return Error(c, "File path is empty.")
+	} else if fileName == "/" {
+		return Error(c, "File path consists entirely of slashes.")
+	}
+
+	c.Set("Content-Type", mimetype.GetContentType(fileName))
+	c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
+
+	// Fiber 原生 Context 的 BodyWriter,可直接写数据到 HTTP 输出
+	// writer := c.Context().Response.BodyWriter()
+
+	// 使用 SetBodyStreamWriter 流式写入
+	c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
+		err := services.GetBatchDownloadFileDownload(filePath, innerPath, w)
+		if err != nil {
+			zap.L().Warn("File read failed.", zap.String("err", err.Error()))
+			return
+		}
+		w.Flush()
+		fmt.Println("SetBodyStreamWriter complete.")
+	})
+
+	return nil
+}

+ 8 - 8
service/domain/batchDownloadDomain.go

@@ -55,14 +55,14 @@ type TaskBatchDownload struct {
 // MqttLogs和EmsLogs的二级map为日志类型名称
 // 存储的最终数据是文件服务器(minio)的存储路径
 type BatchDownloadData struct {
-	PowerFlow     map[string]string            `bson:"powerFlow" json:"powerFlow"`         // 功率流图
-	MqttLogs      map[string]map[string]string `bson:"mqttLogs" json:"mqttLogs"`           // MQTT日志
-	EventAlarm    map[string]string            `bson:"eventAlarm" json:"eventAlarm"`       // 事件和告警日志
-	AlarmSnapshot map[string]string            `bson:"alarmSnapshot" json:"alarmSnapshot"` // 告警快照
-	EmsLogs       map[string]map[string]string `bson:"emsLogs" json:"emsLogs"`             // EMS日志
-	FaultRecord   map[string]string            `bson:"faultRecord" json:"faultRecord"`     // 故障录波 map的key为文件名
-	UpdateLog     string                       `bson:"updateLog" json:"updateLog"`         // 升级记录
-	Settings      string                       `bson:"settings" json:"settings"`           // 站点设置
+	PowerFlow     *map[string]string            `bson:"powerFlow,omitempty" json:"powerFlow,omitempty"`         // 功率流图
+	MqttLogs      *map[string]map[string]string `bson:"mqttLogs,omitempty" json:"mqttLogs,omitempty"`           // MQTT日志
+	EventAlarm    *map[string]string            `bson:"eventAlarm,omitempty" json:"eventAlarm,omitempty"`       // 事件和告警日志
+	AlarmSnapshot *map[string]string            `bson:"alarmSnapshot,omitempty" json:"alarmSnapshot,omitempty"` // 告警快照
+	EmsLogs       *map[string]map[string]string `bson:"emsLogs,omitempty" json:"emsLogs,omitempty"`             // EMS日志
+	FaultRecord   *map[string]string            `bson:"faultRecord,omitempty" json:"faultRecord,omitempty"`     // 故障录波 map的key为文件名
+	UpdateLog     *string                       `bson:"updateLog,omitempty" json:"updateLog,omitempty"`         // 升级记录
+	Settings      *string                       `bson:"settings,omitempty" json:"settings,omitempty"`           // 站点设置
 }
 
 // 下载任务数据 (保存于redis数据库)

+ 14 - 0
service/dto/batchDownloadDTO.go

@@ -0,0 +1,14 @@
+package dto
+
+type BatchDownloadStructNode struct {
+	Name     string                     `json:"name"`     // 字段名
+	IsFile   bool                       `json:"isFile"`   // 是否是文件
+	Children []*BatchDownloadStructNode `json:"children"` // 子节点
+}
+
+type BatchDownloadTarNode struct {
+	Name     string                  `json:"name"`
+	Path     string                  `json:"path"`
+	IsDir    bool                    `json:"isDir"`
+	Children []*BatchDownloadTarNode `json:"children,omitempty"`
+}

+ 12 - 0
service/routes/batchDownloadRoutes.go

@@ -5,6 +5,9 @@
  * /batchDownload/manage/description/{id} - POST 更新任务说明
  * /batchDownload/manage/config/{id} - DELETE 删除配置
  * /batchDownload/data/{id} - GET 获取指定ID的数据包下载地址
+ * /batchDownload/manage/path - GET 获取指定ID的文件目录结构
+ * /batchDownload/manage/fileTree - GET 获取指定文件的目录结构
+ * /batchDownload/manage/fileDownload - GET 下载指定文件
  */
 package routes
 
@@ -29,4 +32,13 @@ func BatchDownloadSetup(api fiber.Router) {
 
 	// 获取指定ID的批量数据下载数据包地址
 	api.Get("/batchDownload/data/:id", controllers.GetBatchDownloadData)
+
+	// 获取指定ID的文件目录结构
+	api.Get("/batchDownload/manage/path", controllers.GetBatchDownloadPath)
+
+	// 获取指定文件的目录结构
+	api.Get("/batchDownload/manage/fileTree", controllers.GetBatchDownloadFileTree)
+
+	// 下载指定文件
+	api.Get("/batchDownload/manage/fileDownload", controllers.GetBatchDownloadFileDownload)
 }

+ 241 - 9
service/services/batchDownloadServices.go

@@ -1,8 +1,10 @@
 package services
 
 import (
+	"archive/tar"
 	"archive/zip"
 	"bufio"
+	"compress/gzip"
 	"fmt"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
@@ -10,6 +12,8 @@ import (
 	"fwh-toolbox-server/taskQueue"
 	"fwh-toolbox-server/utils/minio"
 	"io"
+	"reflect"
+	"strings"
 
 	"go.mongodb.org/mongo-driver/bson/primitive"
 	"go.uber.org/zap"
@@ -101,7 +105,7 @@ func GetBatchDownloadData(id primitive.ObjectID, writer *bufio.Writer) error {
 		for _, batchData := range dataPathMap {
 			// PowerFlow
 			// map[date]path
-			for _, path := range batchData.PowerFlow {
+			for _, path := range *batchData.PowerFlow {
 				fmt.Println("InsertZipFile: ", path)
 				if err := InsertZipFile(path); err != nil {
 					continue
@@ -110,7 +114,7 @@ func GetBatchDownloadData(id primitive.ObjectID, writer *bufio.Writer) error {
 
 			// MqttLogs
 			// map[date]logs
-			for _, logs := range batchData.MqttLogs {
+			for _, logs := range *batchData.MqttLogs {
 				// map[logType]path
 				for _, path := range logs {
 					fmt.Println("InsertZipFile: ", path)
@@ -122,7 +126,7 @@ func GetBatchDownloadData(id primitive.ObjectID, writer *bufio.Writer) error {
 
 			// EventAlarm
 			// map[date]path
-			for _, path := range batchData.EventAlarm {
+			for _, path := range *batchData.EventAlarm {
 				fmt.Println("InsertZipFile: ", path)
 				if err := InsertZipFile(path); err != nil {
 					continue
@@ -131,7 +135,7 @@ func GetBatchDownloadData(id primitive.ObjectID, writer *bufio.Writer) error {
 
 			// EmsLogs
 			// map[date]logs
-			for _, logs := range batchData.EmsLogs {
+			for _, logs := range *batchData.EmsLogs {
 				// map[logType]path
 				for _, path := range logs {
 					fmt.Println("InsertZipFile: ", path)
@@ -143,7 +147,7 @@ func GetBatchDownloadData(id primitive.ObjectID, writer *bufio.Writer) error {
 
 			// FaultRecord
 			// map[date]path
-			for _, path := range batchData.FaultRecord {
+			for _, path := range *batchData.FaultRecord {
 				fmt.Println("InsertZipFile: ", path)
 				if err := InsertZipFile(path); err != nil {
 					continue
@@ -151,17 +155,17 @@ func GetBatchDownloadData(id primitive.ObjectID, writer *bufio.Writer) error {
 			}
 
 			// UpdateLog
-			if batchData.UpdateLog != "" {
+			if batchData.UpdateLog != nil && *batchData.UpdateLog != "" {
 				fmt.Println("InsertZipFile: ", batchData.UpdateLog)
-				if err := InsertZipFile(batchData.UpdateLog); err != nil {
+				if err := InsertZipFile(*batchData.UpdateLog); err != nil {
 					continue
 				}
 			}
 
 			// Settings
-			if batchData.Settings != "" {
+			if batchData.Settings != nil && *batchData.Settings != "" {
 				fmt.Println("InsertZipFile: ", batchData.Settings)
-				if err := InsertZipFile(batchData.Settings); err != nil {
+				if err := InsertZipFile(*batchData.Settings); err != nil {
 					continue
 				}
 			}
@@ -177,3 +181,231 @@ func GetBatchDownloadData(id primitive.ObjectID, writer *bufio.Writer) error {
 
 	return nil
 }
+
+func createBatchDownloadTree(data map[string]domain.BatchDownloadData) []dto.BatchDownloadStructNode {
+	refVal := reflect.ValueOf(data)
+
+	node := []dto.BatchDownloadStructNode{}
+	if refVal.Kind() != reflect.Map {
+		return node
+	}
+
+	// map 一定是目录
+	for _, key := range refVal.MapKeys() {
+		val := refVal.MapIndex(key)
+
+		// 👉 value 是 BatchDownloadData Struct
+		if val.Kind() != reflect.Struct {
+			continue
+		}
+
+		child := createBatchDownloadTreeNode(key.String(), val)
+		if child != nil {
+			node = append(node, *child)
+		}
+	}
+
+	return node
+}
+
+func createBatchDownloadTreeNode(name string, v reflect.Value) *dto.BatchDownloadStructNode {
+	if !v.IsValid() {
+		return nil
+	}
+
+	// 解指针
+	for v.Kind() == reflect.Ptr {
+		if v.IsNil() {
+			return nil
+		}
+		v = v.Elem()
+	}
+
+	node := &dto.BatchDownloadStructNode{
+		Name: name,
+	}
+
+	switch v.Kind() {
+
+	case reflect.Struct:
+		// struct 一定是目录
+		for i := 0; i < v.NumField(); i++ {
+			field := v.Type().Field(i)
+			val := v.Field(i)
+
+			child := createBatchDownloadTreeNode(field.Name, val)
+			if child != nil {
+				node.Children = append(node.Children, child)
+			}
+		}
+
+	case reflect.Map:
+		// map 一定是目录
+		for _, key := range v.MapKeys() {
+			val := v.MapIndex(key)
+
+			// 👉 value 是 string:文件
+			if val.Kind() == reflect.String {
+				node.Children = append(node.Children, &dto.BatchDownloadStructNode{
+					Name:   key.String(),
+					IsFile: false,
+					Children: []*dto.BatchDownloadStructNode{
+						{
+							Name:   val.String(),
+							IsFile: true,
+						},
+					},
+				})
+				continue
+			}
+
+			// 👉 value 是 map[string]string:下一层 value 才是文件
+			child := createBatchDownloadTreeNode(key.String(), val)
+			if child != nil {
+				node.Children = append(node.Children, child)
+			}
+		}
+
+	case reflect.String:
+		// *string 或 string ⇒ 文件
+		node.IsFile = true
+		return node
+	}
+
+	// 目录节点
+	node.IsFile = false
+
+	// 空目录不展示
+	if len(node.Children) == 0 {
+		return nil
+	}
+
+	return node
+}
+
+// 获取指定ID的文件目录结构
+func GetBatchDownloadPath(id primitive.ObjectID) ([]dto.BatchDownloadStructNode, error) {
+	cfg, err := repositories.GetBatchDownloadData(id)
+	if err != nil {
+		return nil, err
+	}
+
+	tree := createBatchDownloadTree(cfg)
+	return tree, nil
+}
+
+func createTarTree(tr *tar.Reader) (*dto.BatchDownloadTarNode, error) {
+	root := &dto.BatchDownloadTarNode{Name: "/", IsDir: true}
+
+	for {
+		hdr, err := tr.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		parts := strings.Split(strings.Trim(hdr.Name, "/"), "/")
+		current := root
+
+		for i, p := range parts {
+			isLast := i == len(parts)-1
+			var child *dto.BatchDownloadTarNode
+
+			for _, c := range current.Children {
+				if c.Name == p {
+					child = c
+					break
+				}
+			}
+
+			if child == nil {
+				child = &dto.BatchDownloadTarNode{
+					Name:  p,
+					Path:  strings.Join(parts[:i+1], "/"),
+					IsDir: !isLast || hdr.Typeflag == tar.TypeDir,
+				}
+				current.Children = append(current.Children, child)
+			}
+
+			current = child
+		}
+	}
+
+	return root, nil
+}
+
+// 获取指定文件的目录结构
+func GetBatchDownloadFileTree(filePath string) (*dto.BatchDownloadTarNode, error) {
+	obj, err := minio.GetObject(filePath)
+	if err != nil {
+		return nil, err
+	}
+
+	gz, err := gzip.NewReader(obj)
+	if err != nil {
+		return nil, err
+	}
+
+	tr := tar.NewReader(gz)
+
+	return createTarTree(tr)
+}
+
+// 下载指定文件
+func GetBatchDownloadFileDownload(filePath, innerPath string, writer *bufio.Writer) error {
+	obj, err := minio.GetObject(filePath)
+	if err != nil {
+		zap.L().Error("Minio get object filed.", zap.String("filePath", filePath), zap.String("err", err.Error()))
+		return err
+	}
+	defer obj.Close()
+
+	if strings.HasSuffix(filePath, ".tar.gz") && innerPath != "" {
+		// 下载.tar.gz包中的文件
+		gz, err := gzip.NewReader(obj)
+		if err != nil {
+			zap.L().Error("gz file read filed.", zap.String("filePath", filePath), zap.String("err", err.Error()))
+			return err
+		}
+		defer gz.Close()
+
+		tr := tar.NewReader(gz)
+
+		for {
+			hdr, err := tr.Next()
+			if err == io.EOF {
+				break
+			}
+			if err != nil {
+				return err
+			}
+
+			if hdr.Typeflag != tar.TypeReg {
+				continue
+			}
+
+			if hdr.Name != innerPath {
+				continue
+			}
+
+			// ⭐ 核心:流式拷贝
+			_, err = io.Copy(writer, tr)
+			return err
+		}
+
+		zap.L().Error("inner file not found in tar.", zap.String("filePath", filePath), zap.String("innerPath", innerPath), zap.String("err", err.Error()))
+		return fmt.Errorf("inner file not found in tar: %s", innerPath)
+	}
+
+	// ⭐ 核心:流式转发
+	_, err = io.Copy(writer, obj)
+	if err != nil {
+		// 客户端中断时,这里很常见
+		zap.L().Error("Minio get object download interrupted.", zap.String("filePath", filePath), zap.String("err", err.Error()))
+		return err
+	}
+
+	return nil
+}

+ 43 - 1
src/service/batchDownload/index.ts

@@ -4,9 +4,18 @@
 // /batchDownload/manage/description/{id} - POST 更新任务说明
 // /batchDownload/manage/config/{id} - DELETE 删除配置
 // /batchDownload/data/{id} - GET 获取指定ID的数据包下载地址
+// /batchDownload/manage/path - GET 获取指定ID的文件目录结构
+// /batchDownload/manage/fileTree - GET 获取指定文件的目录结构
+// /batchDownload/manage/fileDownload - GET 下载指定文件
 
 import http from '@/service/serverReq'
-import type { BatchDownloadFilter, BatchDownloadItemPage, BatchDownloadConfig } from './interface'
+import type {
+  BatchDownloadFilter,
+  BatchDownloadItemPage,
+  BatchDownloadConfig,
+  BatchDownloadStructNode,
+  BatchDownloadTarNode,
+} from './interface'
 
 const API_List = {
   batchDownloadList: '/batchDownload/manage/list',
@@ -14,6 +23,9 @@ const API_List = {
   updateBatchDownloadDescription: '/batchDownload/manage/description/{id}',
   deleteBatchDownload: '/batchDownload/manage/config/{id}',
   getBatchDownloadPackage: '/batchDownload/data/{id}',
+  getBatchDownloadPath: '/batchDownload/manage/path',
+  getBatchDownloadFileTree: '/batchDownload/manage/fileTree',
+  getBatchDownloadFileDownload: '/batchDownload/manage/fileDownload',
 }
 
 // GET 批量数据下载管理表
@@ -56,3 +68,33 @@ export const getBatchDownloadPackage = async (id: string) => {
   const url = API_List.getBatchDownloadPackage.replace('{id}', encodeURIComponent(id))
   await http.download(url)
 }
+
+// GET 获取指定ID的文件目录结构
+export const getBatchDownloadPath = async (id: string): Promise<BatchDownloadStructNode[]> => {
+  const res = await http.get<BatchDownloadStructNode[]>(API_List.getBatchDownloadPath, {
+    params: {
+      id,
+    },
+  })
+  return res
+}
+
+// GET 获取指定文件的目录结构
+export const getBatchDownloadFileTree = async (file: string): Promise<BatchDownloadTarNode> => {
+  const res = await http.get<BatchDownloadTarNode>(API_List.getBatchDownloadFileTree, {
+    params: {
+      file,
+    },
+  })
+  return res
+}
+
+// GET 下载指定文件
+export const getBatchDownloadFilePath = async (filePath: string, innerPath?: string) => {
+  await http.download(API_List.getBatchDownloadFileDownload, undefined, {
+    params: {
+      file: filePath,
+      inner: innerPath ?? '',
+    },
+  })
+}

+ 13 - 0
src/service/batchDownload/interface.ts

@@ -36,6 +36,19 @@ export interface BatchDownloadInfo {
   failReasons: string[]
 }
 
+export interface BatchDownloadStructNode {
+  name: string // 字段名
+  isFile: boolean // 是否是文件
+  children: BatchDownloadStructNode[] // 子节点
+}
+
+export interface BatchDownloadTarNode {
+  name: string
+  path: string
+  isDir: boolean
+  children: BatchDownloadTarNode[]
+}
+
 // export type BatchDownloadItem = BatchDownloadRealtimeData | BatchDownloadHistoryData
 
 // export interface BatchDownloadData {

+ 103 - 0
src/views/batch-download/component/viewPathDialog.vue

@@ -0,0 +1,103 @@
+<template>
+  <NTree block-line :data="dataRef" :on-load="handleLoad" expand-on-click />
+</template>
+
+<script setup lang="ts">
+import { NTree, NButton } from 'naive-ui';
+import type { TreeOption } from 'naive-ui';
+import { getBatchDownloadFileTree, getBatchDownloadFilePath } from '@/service/batchDownload';
+import type { BatchDownloadStructNode, BatchDownloadTarNode } from '@/service/batchDownload/interface';
+
+const props = defineProps<{
+  data: BatchDownloadStructNode[]
+}>()
+
+const transformStructNode = (data: BatchDownloadStructNode[], baseKey?: string): TreeOption[] => {
+  return data.map<TreeOption>(d => {
+    const newKey = !d.isFile && baseKey !== undefined ? baseKey + '/' + d.name : d.name
+    return {
+      path: d.name,
+      label: d.isFile ? d.name.split("/").pop() : d.name,
+      key: newKey,
+      isLeaf: (d.children == null || d.children.length == 0) && !d.name.endsWith(".tar.gz"),
+      children: d.children == null || d.children.length == 0 ? undefined : transformStructNode(d.children, newKey),
+      suffix: !d.isFile ? undefined : () => h(
+        NButton,
+        {
+          text: true,
+          type: 'primary',
+          onClick(e) {
+            e.stopPropagation()
+            getBatchDownloadFilePath(d.name)
+          },
+        },
+        { default: () => "下载" }
+      )
+    }
+  })
+}
+
+const dataRef = ref<TreeOption[]>(transformStructNode(props.data))
+
+const transformTarNode = (data: BatchDownloadTarNode[], filePath: string): TreeOption[] => {
+  return data.map<TreeOption>(d => {
+    return {
+      label: d.name,
+      key: d.name,
+      isLeaf: d.children == null || d.children.length == 0,
+      children: d.children == null || d.children.length == 0 ? undefined : transformTarNode(d.children, filePath),
+      suffix: d.isDir ? undefined : () => h(
+        NButton,
+        {
+          text: true,
+          type: 'primary',
+          onClick(e) {
+            e.stopPropagation()
+            getBatchDownloadFilePath(filePath, d.path)
+          },
+        },
+        { default: () => "下载" }
+      )
+    }
+  })
+}
+
+const handleLoad = (node: TreeOption) => {
+  return new Promise<void>((resolve, reject) => {
+    if (node.key == null || typeof node.key !== 'string') {
+      reject()
+      return
+    }
+
+    getBatchDownloadFileTree(node.key).then(val => {
+      if (typeof node.path !== "string") {
+        reject()
+        return
+      }
+
+      const treeNodes = transformTarNode(val.children, node.path)
+      if (treeNodes.length === 0) {
+        reject()
+        return
+      }
+
+      node.children = treeNodes
+      resolve()
+    }).catch(() => {
+      reject()
+    })
+
+    // setTimeout(() => {
+    //   // node.children = [
+    //   //   {
+    //   //     label: nextLabel(node.label),
+    //   //     key: node.key + nextLabel(node.label),
+    //   //     isLeaf: false
+    //   //   }
+    //   // ]
+    //   resolve()
+    // }, 1000)
+  })
+}
+
+</script>

+ 25 - 8
src/views/batch-download/index.vue

@@ -66,9 +66,10 @@ import { NButton, NDataTable, NForm, NNumberAnimation, NInput, NEllipsis, NDrawe
 import type { DataTableColumns, PaginationProps, DropdownProps, EllipsisProps } from 'naive-ui'
 import { useDataTable } from '@/composable/comp/useDataTable'
 import { useResettableReactive } from '@/composable/useResettable'
-import { getBatchDownloadList, createBatchDownload as createBatchDownloadTask, getBatchDownloadPackage } from '@/service/batchDownload'
+import { getBatchDownloadList, createBatchDownload as createBatchDownloadTask, getBatchDownloadPackage, getBatchDownloadPath } from '@/service/batchDownload'
 import type { BatchDownloadFilter, BatchDownloadInfo, BatchDownloadConfig } from '@/service/batchDownload/interface'
 import ModalData from './ModalData.vue'
+import ViewPathDialog from './component/viewPathDialog.vue'
 
 defineOptions({
   name: 'BatchDownload',
@@ -120,7 +121,7 @@ const paginationPrefix: PaginationProps['prefix'] = (info) => {
   return (
     itemCount && (
       <div>
-        <span>总 & nbsp; </span>
+        <span>总 &nbsp; </span>
         < NNumberAnimation
           from={prevUserListTotal.value}
           to={itemCount}
@@ -128,7 +129,7 @@ const paginationPrefix: PaginationProps['prefix'] = (info) => {
             prevUserListTotal.value = itemCount
           }}
         />
-        <span> & nbsp; 条 </span>
+        <span> &nbsp; 条 </span>
       </div>
     )
   )
@@ -259,14 +260,14 @@ function createColumns(): DataTableColumns<BatchDownloadInfo> {
         } as EllipsisProps,
           {
             default: () =>
-              h("span", { style: { whiteSpace: "pre-line" } }, rowData.failReasons.join("\n"))
+              h("span", { style: { whiteSpace: "pre-line" } }, rowData.failReasons?.join("\n"))
           })
       },
     },
     {
       title: '操作',
       key: 'operate',
-      width: 200,
+      width: 280,
       fixed: 'right',
       render(row) {
         return h('div', { class: 'flex justify-center gap-4 p-x-4' }, [
@@ -274,10 +275,19 @@ function createColumns(): DataTableColumns<BatchDownloadInfo> {
             type: "info",
             strong: true,
             size: 'small',
-            onClick: () => itemView(row)
+            onClick: () => itemViewInfo(row)
           }, {
             default: () => '详情'
           }),
+          h(NButton, {
+            type: "info",
+            strong: true,
+            size: 'small',
+            disabled: row.progress !== "complete",
+            onClick: () => itemViewPath(row.id)
+          }, {
+            default: () => '查看'
+          }),
           h(NButton, {
             type: "success",
             strong: true,
@@ -339,8 +349,7 @@ const tablePagination = reactive<PaginationProps>({
   },
 })
 
-const itemView = (info: BatchDownloadInfo) => {
-  console.log({ info })
+const itemViewInfo = (info: BatchDownloadInfo) => {
   const idStr = `ID: ${info.id}`
   const snStr = "SN:\n" + info.sn.map(sn => `\t${sn}`).join("\n")
   const descStr = `Description: ${info.description}`
@@ -365,6 +374,14 @@ const itemView = (info: BatchDownloadInfo) => {
   drawerShow.value = true
 }
 
+const itemViewPath = async (id: string) => {
+  const pathList = await getBatchDownloadPath(id)
+  dialog.info({
+    title: "数据目录",
+    content: () => h(ViewPathDialog, { data: pathList })
+  })
+}
+
 const itemDownload = (id: string) => {
   getBatchDownloadPackage(id)
 }

+ 20 - 0
src/views/batch-download/page/viewLog.vue

@@ -0,0 +1,20 @@
+<template>
+  <div style="height: 400px;">
+    <CodeEditor v-model:value="code" language="javascript" theme="vs-dark" :options="editorOptions" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { CodeEditor } from 'monaco-editor-vue3';
+
+const code = ref(`function hello() {
+  console.log('Hello, Monaco Editor Vue3!');
+}`);
+
+const editorOptions = {
+  fontSize: 14,
+  minimap: { enabled: false },
+  automaticLayout: true
+};
+</script>