From b58f5da52bf6413838ceb37efc2ad51d42fedac2 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Tue, 15 Apr 2025 08:27:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=BF=9E=E6=8E=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=9A=84=E5=AF=BC=E5=85=A5/=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 225 ++++++++++-- packages/backend/package.json | 2 + .../src/connections/connections.controller.ts | 346 +++++++++++++++++- .../src/connections/connections.routes.ts | 53 ++- packages/data/nexus-terminal.db | Bin 49152 -> 49152 bytes packages/frontend/src/locales/en.json | 10 +- packages/frontend/src/locales/zh.json | 10 +- .../frontend/src/views/ConnectionsView.vue | 144 +++++++- 8 files changed, 762 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 228ad41..15bdc84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -745,7 +745,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -756,7 +755,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -782,7 +780,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -794,7 +791,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -817,21 +813,27 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.17.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -841,21 +843,18 @@ "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -866,7 +865,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1271,6 +1269,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -1485,7 +1489,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/buildcheck": { @@ -1497,6 +1500,17 @@ "node": ">=10.0.0" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1725,6 +1739,51 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -1811,6 +1870,12 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -2824,7 +2889,6 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "license": "MIT", - "optional": true, "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" @@ -2935,6 +2999,12 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2961,8 +3031,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/klona": { "version": "2.0.6", @@ -3360,6 +3429,79 @@ "dev": true, "license": "MIT" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nan": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", @@ -3862,6 +4004,12 @@ "node": ">=10" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -4398,7 +4546,6 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -4409,7 +4556,6 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "license": "MIT", - "optional": true, "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -4477,8 +4623,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/sqlite3": { "version": "5.1.7", @@ -4555,6 +4700,14 @@ "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "license": "MIT" }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4915,6 +5068,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -4972,7 +5131,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -5394,7 +5552,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -5447,10 +5604,14 @@ "name": "@nexus-terminal/backend", "version": "0.1.0", "dependencies": { + "@types/multer": "^1.4.12", "bcrypt": "^5.1.1", "connect-sqlite3": "^0.9.15", "express": "^5.1.0", "express-session": "^1.18.1", + "https-proxy-agent": "^7.0.6", + "multer": "^1.4.5-lts.2", + "socks": "^2.8.4", "sqlite3": "^5.1.7", "ssh2": "^1.16.0", "ws": "^8.18.1" @@ -5468,6 +5629,28 @@ "typescript": "^5.0.0" } }, + "packages/backend/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "packages/backend/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "packages/frontend": { "name": "@nexus-terminal/frontend", "version": "0.1.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index ef69a2d..3134854 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -9,11 +9,13 @@ "dev": "npx ts-node-dev --respawn --transpile-only src/index.ts" }, "dependencies": { + "@types/multer": "^1.4.12", "bcrypt": "^5.1.1", "connect-sqlite3": "^0.9.15", "express": "^5.1.0", "express-session": "^1.18.1", "https-proxy-agent": "^7.0.6", + "multer": "^1.4.5-lts.2", "socks": "^2.8.4", "sqlite3": "^5.1.7", "ssh2": "^1.16.0", diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 782f5d0..fa57941 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -746,7 +746,349 @@ export const testConnection = async (req: Request, res: Response): Promise } } catch (error: any) { - console.error(`测试连接 ${connectionId} 时发生内部错误:`, error); - res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); + console.error(`测试连接 ${connectionId} 时发生内部错误:`, error); + res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); + } + }; + +// --- 新增:导出连接配置 --- +/** + * 导出所有连接配置 (GET /api/v1/connections/export) + */ +export const exportConnections = async (req: Request, res: Response): Promise => { + const userId = req.session.userId; // 保留以备将来多用户 + + try { + // 1. 查询所有连接及其关联的代理信息 + const connectionsWithProxies = await new Promise((resolve, reject) => { + db.all( + `SELECT + c.id, c.name, c.host, c.port, c.username, c.auth_method, + c.encrypted_password, c.encrypted_private_key, c.encrypted_passphrase, + c.proxy_id, + p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, + p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, + -- p.auth_method as proxy_auth_method, -- Removed: Column likely doesn't exist based on error + p.encrypted_password as proxy_encrypted_password + -- p.encrypted_private_key as proxy_encrypted_private_key, -- Removed: Column likely doesn't exist + -- p.encrypted_passphrase as proxy_encrypted_passphrase -- Removed: Column likely doesn't exist + FROM connections c + LEFT JOIN proxies p ON c.proxy_id = p.id + ORDER BY c.name ASC`, + (err, rows: any[]) => { + if (err) { + console.error('查询连接和代理信息以供导出时出错:', err.message); + return reject(new Error('导出连接失败:查询连接信息出错')); + } + resolve(rows); + } + ); + }); + + // 2. 查询所有连接的标签信息 + const connectionTags = await new Promise<{[connId: number]: number[]}>((resolve, reject) => { + db.all('SELECT connection_id, tag_id FROM connection_tags', (err, rows: {connection_id: number, tag_id: number}[]) => { + if (err) { + console.error('查询连接标签以供导出时出错:', err.message); + return reject(new Error('导出连接失败:查询标签信息出错')); + } + const tagsMap: {[connId: number]: number[]} = {}; + rows.forEach(row => { + if (!tagsMap[row.connection_id]) { + tagsMap[row.connection_id] = []; + } + tagsMap[row.connection_id].push(row.tag_id); + }); + resolve(tagsMap); + }); + }); + + // 3. 格式化数据以供导出 + const formattedData = connectionsWithProxies.map(row => { + const connection: any = { + // 不导出 id,因为导入时需要重新创建 + name: row.name, + host: row.host, + port: row.port, + username: row.username, + auth_method: row.auth_method, + encrypted_password: row.encrypted_password, + encrypted_private_key: row.encrypted_private_key, + encrypted_passphrase: row.encrypted_passphrase, + tag_ids: connectionTags[row.id] || [], // 从 map 中获取标签 ID + // 不导出 created_at, updated_at, last_connected_at + }; + + // 添加代理信息(如果存在) + if (row.proxy_db_id) { + connection.proxy = { + // 不导出代理的 id,因为导入时可能需要重新创建或匹配 + name: row.proxy_name, + type: row.proxy_type, + host: row.proxy_host, + port: row.proxy_port, + username: row.proxy_username, + // auth_method: row.proxy_auth_method, // Removed + encrypted_password: row.proxy_encrypted_password + // encrypted_private_key: row.proxy_encrypted_private_key, // Removed + // encrypted_passphrase: row.proxy_encrypted_passphrase, // Removed + }; + } else { + connection.proxy = null; // 明确设为 null + } + + return connection; + }); + + // 4. 设置响应头,提示浏览器下载文件 + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `nexus-terminal-connections-${timestamp}.json`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Type', 'application/json'); + + // 发送 JSON 数据 + res.status(200).json(formattedData); + + } catch (error: any) { + console.error('导出连接时发生错误:', error); + res.status(500).json({ message: error.message || '导出连接时发生内部服务器错误。' }); + } + }; + +// --- 新增:导入连接配置 --- +/** + * 导入连接配置 (POST /api/v1/connections/import) + */ +export const importConnections = async (req: Request, res: Response): Promise => { + const userId = req.session.userId; // 保留以备将来多用户 + + if (!req.file) { + res.status(400).json({ message: '未找到上传的文件 (需要名为 "connectionsFile" 的文件)。' }); + return; + } + + let importedData: any[]; + try { + const fileContent = req.file.buffer.toString('utf8'); + importedData = JSON.parse(fileContent); + if (!Array.isArray(importedData)) { + throw new Error('JSON 文件内容必须是一个数组。'); + } + } catch (error: any) { + res.status(400).json({ message: `解析 JSON 文件失败: ${error.message}` }); + return; + } + + let successCount = 0; + let failureCount = 0; + const errors: { connectionName?: string; message: string }[] = []; + const now = Math.floor(Date.now() / 1000); + + // 准备数据库语句 + const findProxyStmt = db.prepare(`SELECT id FROM proxies WHERE name = ? AND type = ? AND host = ? AND port = ?`); + // 恢复为文档定义的列 + const insertProxyStmt = db.prepare(`INSERT INTO proxies (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + const insertConnStmt = db.prepare(`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + const insertTagStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); + + try { // Wrap the entire database operation in a try block + await new Promise((resolveOuter, rejectOuter) => { + // 使用事务处理导入 + db.serialize(() => { // Removed async here + db.run('BEGIN TRANSACTION', async (beginErr: Error | null) => { // <--- Added async here + if (beginErr) return rejectOuter(new Error(`开始事务失败: ${beginErr.message}`)); + + try { + // 使用 Promise.allSettled 来处理所有连接的导入 + const importPromises = importedData.map(connData => (async () => { // async IIFE + // 1. 验证基本连接数据结构 + if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) { + // failureCount++; // 由 allSettled 结果判断 + // errors.push({ connectionName: connData.name || '未知连接', message: '缺少必要的连接字段 (name, host, port, username, auth_method)。' }); + throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。'); + } + // 验证 auth_method 和凭证 + if (connData.auth_method === 'password' && !connData.encrypted_password) { + throw new Error('密码认证缺少 encrypted_password。'); + } + if (connData.auth_method === 'key' && !connData.encrypted_private_key) { + throw new Error('密钥认证缺少 encrypted_private_key。'); + } + + let proxyIdToUse: number | null = null; + + // 2. 处理代理信息(如果存在) + if (connData.proxy) { + const proxyData = connData.proxy; + // 验证代理数据 + if (!proxyData.name || !proxyData.type || !proxyData.host || !proxyData.port /* || !proxyData.auth_method */) { // auth_method 可能不存在,暂时移除强制校验 + throw new Error('代理信息不完整 (缺少 name, type, host, port)。'); + } + // 验证代理凭证存在性 (如果 auth_method 存在) + if (proxyData.auth_method === 'password' && !proxyData.encrypted_password) { + throw new Error('代理密码认证缺少 encrypted_password。'); + } + if (proxyData.auth_method === 'key' && !proxyData.encrypted_private_key) { + throw new Error('代理密钥认证缺少 encrypted_private_key。'); + } + + // 尝试查找现有代理 + const existingProxy = await new Promise<{ id: number } | undefined>((resolve, reject) => { + findProxyStmt.get(proxyData.name, proxyData.type, proxyData.host, proxyData.port, (err: Error | null, row: { id: number } | undefined) => { + if (err) return reject(new Error(`查找代理时出错: ${err.message}`)); + resolve(row); + }); + }); + + if (existingProxy) { + proxyIdToUse = existingProxy.id; + console.log(`导入连接 ${connData.name}: 找到现有代理 ${proxyData.name} (ID: ${proxyIdToUse})`); + } else { + // 代理不存在,创建新代理 + console.log(`导入连接 ${connData.name}: 代理 ${proxyData.name} 不存在,正在创建...`); + const proxyResult = await new Promise<{ lastID: number }>((resolve, reject) => { + insertProxyStmt.run( + proxyData.name, proxyData.type, proxyData.host, proxyData.port, + proxyData.username || null, + // 恢复为文档定义的参数 + proxyData.auth_method || 'none', // 提供默认值 'none' 如果不存在 + proxyData.encrypted_password || null, + proxyData.encrypted_private_key || null, + proxyData.encrypted_passphrase || null, + now, now, + function (this: Statement, err: Error | null) { + if (err) return reject(new Error(`创建代理时出错: ${err.message}`)); + resolve({ lastID: (this as any).lastID }); + } + ); + }); + proxyIdToUse = proxyResult.lastID; + console.log(`导入连接 ${connData.name}: 新代理 ${proxyData.name} 创建成功 (ID: ${proxyIdToUse})`); + } + } // 结束代理处理 + + // 3. 插入连接信息 + const connResult = await new Promise<{ lastID: number }>((resolve, reject) => { + insertConnStmt.run( + connData.name, connData.host, connData.port, connData.username, connData.auth_method, + connData.encrypted_password || null, + connData.encrypted_private_key || null, + connData.encrypted_passphrase || null, + proxyIdToUse, // 使用找到或创建的代理 ID + now, now, + function (this: Statement, err: Error | null) { + if (err) return reject(new Error(`插入连接时出错: ${err.message}`)); + resolve({ lastID: (this as any).lastID }); + } + ); + }); + const newConnectionId = connResult.lastID; + + // 4. 处理标签关联 + if (Array.isArray(connData.tag_ids) && connData.tag_ids.length > 0) { + for (const tagId of connData.tag_ids) { + if (typeof tagId === 'number' && tagId > 0) { + // 注意:这里假设 tagId 在 tags 表中已存在。 + await new Promise((resolve, reject) => { + insertTagStmt.run(newConnectionId, tagId, (err: Error | null) => { + if (err) { + console.warn(`导入连接 ${connData.name}: 关联标签 ID ${tagId} 失败: ${err.message}`); + // 决定是否因此失败 + // reject(new Error(`关联标签 ID ${tagId} 失败: ${err.message}`)); + } + resolve(); // 继续处理下一个标签 + }); + }); + } + } + } + // 如果 IIFE 成功完成,返回 null 或 undefined 表示成功 + return null; + + })().catch(err => { // 捕获 async IIFE 中的错误 + // 返回一个包含错误信息的对象,以便 Promise.allSettled 处理 + return { connectionName: connData.name || '未知连接', error: err }; + })); // 结束 map 和 async IIFE + + // 等待所有导入 Promise 完成 + const results = await Promise.allSettled(importPromises); + + // 处理结果 + results.forEach(result => { + if (result.status === 'fulfilled' && result.value?.error) { + // IIFE 成功执行但内部捕获并返回了错误 + failureCount++; + errors.push({ connectionName: result.value.connectionName, message: result.value.error.message }); + } else if (result.status === 'fulfilled') { + // IIFE 成功执行且没有返回错误 + successCount++; + } else { // status === 'rejected' - IIFE 本身抛出未捕获错误 + failureCount++; + const reason = result.reason as any; + // 尝试获取 connectionName,如果 IIFE 在早期失败可能没有 + const name = importedData[results.indexOf(result)]?.name || '未知连接'; + errors.push({ connectionName: name, message: reason?.message || '未知导入错误' }); + } + }); + + // 根据是否有失败决定提交或回滚 + if (failureCount > 0) { + console.warn(`导入连接存在 ${failureCount} 个错误,正在回滚事务...`); + db.run('ROLLBACK', (rollbackErr: Error | null) => { + if (rollbackErr) console.error("回滚事务失败:", rollbackErr); + // 即使回滚失败,仍需告知前端导入失败 + rejectOuter(new Error(`导入失败,存在 ${failureCount} 个错误。`)); // 使用 rejectOuter 传递错误 + }); + } else { + // 所有记录处理完毕,提交事务 + db.run('COMMIT', (commitErr: Error | null) => { + if (commitErr) { + console.error('提交导入事务时出错:', commitErr); + rejectOuter(new Error(`提交导入事务失败: ${commitErr.message}`)); + } else { + resolveOuter(); // 事务成功,resolve 外层 Promise + } + }); + } + } catch (innerError: any) { + // 捕获 Promise.allSettled 或其他同步错误 + console.error('导入事务内部出错:', innerError); + db.run('ROLLBACK', (rollbackErr: Error | null) => { + if (rollbackErr) console.error("回滚事务失败:", rollbackErr); + rejectOuter(innerError); // 将内部错误传递出去 + }); + } + }); // 结束 BEGIN TRANSACTION 回调 + }); // 结束 db.serialize + }); // 结束 new Promise + + // 如果 Promise 成功 resolve (事务提交成功) + res.status(200).json({ + message: `导入成功完成。共导入 ${successCount} 条连接。`, + successCount, + failureCount: 0 + }); + + } catch (error: any) { // 捕获外层 try 或 rejectOuter 传递的错误 + console.error('导入连接时发生错误:', error); + // 如果错误是由 rejectOuter 传递的,并且包含失败计数,则使用它 + if (failureCount > 0) { + res.status(400).json({ + message: error.message || `导入失败,存在 ${failureCount} 个错误。`, + successCount, + failureCount, + errors + }); + } else { + // 其他错误 (如文件解析、开始事务失败等) + res.status(500).json({ message: error.message || '导入连接时发生内部服务器错误。' }); + } + } finally { + // Finalize prepared statements regardless of success or failure + // Ensure statements are finalized even if db.serialize wasn't fully entered + findProxyStmt?.finalize(); + insertProxyStmt?.finalize(); + insertConnStmt?.finalize(); + insertTagStmt?.finalize(); } }; diff --git a/packages/backend/src/connections/connections.routes.ts b/packages/backend/src/connections/connections.routes.ts index 9416860..71a430e 100644 --- a/packages/backend/src/connections/connections.routes.ts +++ b/packages/backend/src/connections/connections.routes.ts @@ -1,19 +1,68 @@ -import { Router } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; // 引入 Request, Response, NextFunction import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件 +import multer from 'multer'; // 引入 multer 用于文件上传 import { createConnection, getConnections, getConnectionById, // 引入获取单个连接的控制器 updateConnection, // 引入更新连接的控制器 deleteConnection, // 引入删除连接的控制器 - testConnection // 引入测试连接的控制器 + testConnection, // 引入测试连接的控制器 + exportConnections, // 引入导出连接的控制器 + importConnections // 引入导入连接的控制器 } from './connections.controller'; const router = Router(); +// 配置 multer 用于处理 JSON 文件上传 (存储在内存中) +const storage = multer.memoryStorage(); // 将文件存储在内存中作为 Buffer +const upload = multer({ + storage: storage, + limits: { fileSize: 5 * 1024 * 1024 }, // 限制文件大小为 5MB + fileFilter: (req: Request, file, cb) => { // Add type for req + if (file.mimetype === 'application/json') { + cb(null, true); + } else { + // Attach error to request instead of calling cb with error directly + // This makes it easier to handle consistently and return JSON + (req as any).fileValidationError = '只允许上传 JSON 文件!'; + cb(null, false); // Reject the file + } + } +}); + // 应用认证中间件到所有 /connections 路由 router.use(isAuthenticated); // 恢复认证检查 +// --- Specific routes before parameterized routes --- + +// GET /api/v1/connections/export - 导出连接配置 +router.get('/export', exportConnections); + +// POST /api/v1/connections/import - 导入连接配置 +router.post('/import', (req: Request, res: Response, next: NextFunction) => { + // Use multer middleware, but handle errors specifically + upload.single('connectionsFile')(req, res, (err: any) => { + // Check for file filter validation error first + if ((req as any).fileValidationError) { + return res.status(400).json({ message: (req as any).fileValidationError }); + } + // Check for other multer errors (e.g., file size limit) + if (err instanceof multer.MulterError) { + return res.status(400).json({ message: `文件上传错误: ${err.message}` }); + } else if (err) { + // Other unexpected errors during upload + console.error("Unexpected error during file upload:", err); + return res.status(500).json({ message: '文件上传处理失败' }); + } + // If no errors, proceed to the controller + next(); + }); +}, importConnections); + + +// --- General CRUD and other routes --- + // GET /api/v1/connections - 获取连接列表 router.get('/', getConnections); diff --git a/packages/data/nexus-terminal.db b/packages/data/nexus-terminal.db index ba743d10b7739ebbe6f6aa71cf6f4bd058e2364f..fd999cf66f7d68c884c090f65697f818e9872822 100644 GIT binary patch delta 110 zcmZo@U~Xt&o*>P5d7_LnMB?D8z@6#rh>oGstwAj!(X$SlrTTAW%`%*(*Qz{Gch zfj^Az24BHuL4hQ`$pv{COf{^N@8*?CEcZ(PyWESZQJsaKK~$4TRJJjfg$1Z(a(ez` KHi+b+f&c(>S0aJ{ delta 110 zcmV-!0FnQIfCGSl1CSd5pphIy0idy92Ok8j6?34oYafb083PCb0~ZN(b7gXK4FCWD z0uI{%4_OY|4sf#(5MmCKaBgS?{YRgC0+ZTqbrmr4XZ@n>0)Zp}3;-1=0u>#BZ3G09 QXK#}R1@LG61@NM9P+5v32mk;8 diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 7487ff0..155e381 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -86,7 +86,15 @@ "test": { "success": "Connection test successful!", "failed": "Connection test failed: {error}" - } + }, + "exportConnections": "Export Connections", + "importConnections": "Import Connections", + "exportError": "Failed to export connections: {message}", + "importError": "Failed to import connections: {message}", + "importErrorFileType": "Invalid file type. Please select a JSON file.", + "importErrorUnknown": "Unknown import error occurred.", + "importErrorNetwork": "Network error during import.", + "importSuccess": "Import completed. Successful: {successCount}, Failed: {failureCount}." }, "proxies": { "title": "Proxy Management", diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index f860f85..3a2d7f7 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -86,7 +86,15 @@ "test": { "success": "连接测试成功!", "failed": "连接测试失败: {error}" - } + }, + "exportConnections": "导出连接", + "importConnections": "导入连接", + "exportError": "导出连接失败: {message}", + "importError": "导入连接失败: {message}", + "importErrorFileType": "文件类型无效。请选择 JSON 文件。", + "importErrorUnknown": "发生未知导入错误。", + "importErrorNetwork": "导入过程中发生网络错误。", + "importSuccess": "导入完成。成功: {successCount}, 失败: {failureCount}." }, "proxies": { "title": "代理管理", diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue index 505e41e..92ac969 100644 --- a/packages/frontend/src/views/ConnectionsView.vue +++ b/packages/frontend/src/views/ConnectionsView.vue @@ -62,6 +62,143 @@ const closeForm = () => { editingConnection.value = null; // 清除编辑状态 showForm.value = false; }; + +// 新增:处理导出连接按钮点击事件 +const handleExportConnections = async () => { + try { + // 调用后端导出 API + const response = await fetch('/api/v1/connections/export', { + method: 'GET', + headers: { + // 如果需要认证,可能需要添加 Authorization 头 + // 'Authorization': `Bearer ${token}`, // 示例 + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + // 处理错误响应 + const errorData = await response.json(); + console.error('导出连接失败:', errorData.message || response.statusText); + alert(t('connections.exportError', { message: errorData.message || response.statusText })); // 提示用户错误 + return; + } + + // 获取文件名 (从 Content-Disposition 头) + const disposition = response.headers.get('Content-Disposition'); + let filename = 'nexus-terminal-connections.json'; // 默认文件名 + if (disposition && disposition.includes('attachment')) { + const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + const matches = filenameRegex.exec(disposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + } + } + + // 创建 Blob 对象并触发下载 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); // 需要添加到 DOM 才能触发点击 + a.click(); + a.remove(); // 清理 + window.URL.revokeObjectURL(url); // 释放对象 URL + + } catch (error: any) { + console.error('导出连接时发生网络或处理错误:', error); + alert(t('connections.exportError', { message: error.message || '未知错误' })); + } +}; + +// --- Import/Export Logic --- +const fileInput = ref(null); // Ref for the hidden file input + +// 点击导入按钮时触发文件选择 +const handleImportClick = () => { + fileInput.value?.click(); // 触发隐藏 input 的点击事件 +}; + +// 处理文件选择变化 +const handleFileChange = async (event: Event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (!file) { + return; // 没有选择文件 + } + + if (file.type !== 'application/json') { + alert(t('connections.importErrorFileType')); // 提示文件类型错误 + target.value = ''; // 清空文件输入,以便可以再次选择相同文件 + return; + } + + const formData = new FormData(); + formData.append('connectionsFile', file); // 'connectionsFile' 必须与后端 multer 配置匹配 + + try { + const response = await fetch('/api/v1/connections/import', { + method: 'POST', + body: formData, + headers: { + // 不需要 'Content-Type': 'multipart/form-data',浏览器会自动设置 + // 如果需要认证,添加 Authorization 头 + 'Accept': 'application/json', // 期望服务器返回 JSON + }, + }); + + // 首先检查响应是否成功 + if (!response.ok) { + let errorMsg = `${response.status} ${response.statusText}`; + try { + // 尝试解析可能的 JSON 错误体 + const errorResult = await response.json(); + console.error('导入连接失败 (JSON):', errorResult.message || response.statusText, errorResult.errors); + errorMsg = errorResult.message || errorMsg; + if (errorResult.errors && Array.isArray(errorResult.errors)) { + errorMsg += '\n' + errorResult.errors.map((e: any) => `- ${e.connectionName || '未知'}: ${e.message}`).join('\n'); + } + } catch (jsonError) { + // 如果解析 JSON 失败,尝试读取文本 + try { + const textError = await response.text(); + console.error('导入连接失败 (Text):', textError); + // 如果文本错误信息不为空,则使用它,否则保留状态码/文本 + if (textError) { + errorMsg = textError.substring(0, 500); // 限制长度避免过长的 HTML 错误页 + } + } catch (textReadError) { + console.error('读取错误响应文本失败:', textReadError); + // 保留原始的状态码/文本错误 + } + } + alert(t('connections.importError', { message: errorMsg })); + } else { + // 响应成功,解析 JSON + try { + const result = await response.json(); + console.log('导入连接成功:', result); + alert(t('connections.importSuccess', { successCount: result.successCount, failureCount: result.failureCount })); + // 导入成功后刷新连接列表 + connectionsStore.fetchConnections(); + } catch (jsonParseError: any) { + console.error('解析成功响应 JSON 时出错:', jsonParseError); + alert(t('connections.importError', { message: `无法解析服务器成功响应: ${jsonParseError.message}` })); + } + } + + } catch (error: any) { + console.error('导入连接时发生网络或处理错误:', error); + alert(t('connections.importError', { message: error.message || t('connections.importErrorNetwork') })); + } finally { + // 无论成功或失败,都清空文件输入框的值,以便用户可以重新上传相同的文件 + if (target) { + target.value = ''; + } + } +};