From 7aa09ac9e1e995e35f401d8e4d8b18c2c26f3a15 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Fri, 13 Mar 2026 22:11:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(backup):=20=E6=B7=BB=E5=8A=A0=E8=BF=9C?= =?UTF-8?q?=E7=AB=AF=E5=A4=87=E4=BB=BD=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=A4=87=E4=BB=BD=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/remote-backup-configs.json | 11 + backend/models/Consumable.js | 3 +- backend/models/ConsumableLogArchive.js | 1 - backend/models/ConsumableRecord.js | 3 +- backend/package-lock.json | 543 ++++++- backend/package.json | 12 +- backend/routes/backup.js | 306 ++++ backend/server.js | 2 +- backend/utils/autoBackupScheduler.js | 79 +- backend/utils/remoteBackup.js | 300 ++++ backend/utils/remoteBackupConfig.js | 297 ++++ frontend/package-lock.json | 20 +- frontend/package.json | 4 +- frontend/src/App.jsx | 2 + frontend/src/pages/BackupManagement.jsx | 438 ++++-- frontend/src/pages/RemoteBackupSettings.jsx | 1551 +++++++++++++++++++ 16 files changed, 3344 insertions(+), 228 deletions(-) create mode 100644 backend/config/remote-backup-configs.json create mode 100644 backend/utils/remoteBackup.js create mode 100644 backend/utils/remoteBackupConfig.js create mode 100644 frontend/src/pages/RemoteBackupSettings.jsx diff --git a/backend/config/remote-backup-configs.json b/backend/config/remote-backup-configs.json new file mode 100644 index 0000000..4604aa1 --- /dev/null +++ b/backend/config/remote-backup-configs.json @@ -0,0 +1,11 @@ +{ + "targets": [], + "globalSettings": { + "enabled": false, + "uploadAfterBackup": false, + "deleteLocalAfterUpload": false, + "retryCount": 3, + "retryDelay": 5000, + "timeout": 300000 + } +} \ No newline at end of file diff --git a/backend/models/Consumable.js b/backend/models/Consumable.js index 141701e..c931569 100644 --- a/backend/models/Consumable.js +++ b/backend/models/Consumable.js @@ -5,8 +5,7 @@ const Consumable = sequelize.define('Consumable', { consumableId: { type: DataTypes.STRING, primaryKey: true, - allowNull: false, - unique: true + allowNull: false }, name: { type: DataTypes.STRING, diff --git a/backend/models/ConsumableLogArchive.js b/backend/models/ConsumableLogArchive.js index 25f39ea..c8399af 100644 --- a/backend/models/ConsumableLogArchive.js +++ b/backend/models/ConsumableLogArchive.js @@ -14,7 +14,6 @@ const ConsumableLogArchive = sequelize.define('ConsumableLogArchive', { archiveId: { type: DataTypes.STRING, allowNull: false, - unique: true, comment: '归档记录唯一标识' }, consumableId: { diff --git a/backend/models/ConsumableRecord.js b/backend/models/ConsumableRecord.js index 8838adc..4327051 100644 --- a/backend/models/ConsumableRecord.js +++ b/backend/models/ConsumableRecord.js @@ -7,8 +7,7 @@ const ConsumableRecord = sequelize.define('ConsumableRecord', { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4, - allowNull: false, - unique: true + allowNull: false }, consumableId: { type: DataTypes.STRING, diff --git a/backend/package-lock.json b/backend/package-lock.json index cb4e797..4e93918 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,7 +8,8 @@ "name": "idc-backend", "version": "1.0.0", "dependencies": { - "axios": "^1.13.4", + "axios": "^1.13.6", + "basic-ftp": "^5.0.3", "bcryptjs": "^3.0.3", "cors": "^2.8.5", "csv-parser": "^3.2.0", @@ -21,11 +22,14 @@ "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", - "mysql2": "^3.16.0", + "mysql2": "^3.19.1", "node-cron": "^4.2.1", - "sequelize": "^6.32.1", - "sqlite3": "^5.1.6", + "sequelize": "^6.37.8", + "smb2": "^0.2.2", + "sqlite3": "^5.1.7", + "ssh2-sftp-client": "^9.1.0", "three": "^0.182.0", + "webdav": "^5.3.1", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "xlsx": "^0.18.5" @@ -627,6 +631,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@buttercup/fetch": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", + "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", + "license": "MIT", + "optionalDependencies": { + "node-fetch": "^3.3.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1850,6 +1863,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2546,6 +2560,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2594,13 +2617,13 @@ } }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -2707,7 +2730,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "devOptional": true, + "license": "MIT" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", "license": "MIT" }, "node_modules/base64-js": { @@ -2740,6 +2768,24 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -2923,6 +2969,15 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2934,6 +2989,12 @@ "node": ">=10.16.0" } }, + "node_modules/byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3125,6 +3186,15 @@ "node": ">=10" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3433,6 +3503,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -3460,6 +3544,15 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/csv-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", @@ -3478,6 +3571,15 @@ "integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3841,6 +3943,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3855,8 +3969,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/error-ex": { "version": "1.3.4", @@ -4620,6 +4733,40 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4636,6 +4783,29 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4818,6 +4988,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -5286,6 +5468,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5700,6 +5888,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -7137,6 +7331,12 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7292,9 +7492,9 @@ } }, "node_modules/lru.min": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", - "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -7369,6 +7569,17 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -7654,29 +7865,31 @@ } }, "node_modules/mysql2": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.0.tgz", - "integrity": "sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA==", + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz", + "integrity": "sha512-yn4zh+Uxu5J3Zvi6Ao96lJ7BSBRkspHflWQAmOPND+htbpIKDQw99TTvPzgihKO/QyMickZopO4OsnixnpcUwA==", "license": "MIT", "dependencies": { - "aws-ssl-profiles": "^1.1.1", + "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" }, "engines": { "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" } }, "node_modules/mysql2/node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7701,6 +7914,13 @@ "node": ">=8.0.0" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -7739,10 +7959,16 @@ "node": ">= 0.6" } }, + "node_modules/nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", + "license": "MIT" + }, "node_modules/node-abi": { - "version": "3.85.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", - "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "version": "3.88.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", + "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -7766,6 +7992,44 @@ "node": ">=6.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", @@ -7915,6 +8179,14 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ntlm": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/ntlm/-/ntlm-0.1.3.tgz", + "integrity": "sha512-pPlHxhAegZP4QAaOYd51vRd6VXTGfF7VLKJwuwN0iEB1aIi3SnqXYuS/bH/6wWBOq+Ehdil49mHm1Nseon085w==", + "engines": [ + "node" + ] + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8241,6 +8513,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8268,6 +8555,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", + "license": "ISC" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -8371,6 +8664,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -8473,7 +8767,6 @@ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "license": "MIT", - "optional": true, "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -8579,6 +8872,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8718,6 +9017,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -8764,7 +9069,6 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "license": "MIT", - "optional": true, "engines": { "node": ">= 4" } @@ -8924,15 +9228,10 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" - }, "node_modules/sequelize": { - "version": "6.37.7", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz", - "integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==", + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", "funding": [ { "type": "opencollective", @@ -9281,6 +9580,17 @@ "npm": ">= 3.0.0" } }, + "node_modules/smb2": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/smb2/-/smb2-0.2.11.tgz", + "integrity": "sha512-p9Lx72JBuQ+7+UQPDIzBywQ9x0QBk7Z77cJ3lJ3vW2mhr/U1NppSDTU9Abc+dZn9ie13CVh3z54Aa3zx8N+Aeg==", + "engines": [ + "node" + ], + "dependencies": { + "ntlm": "^0.1.3" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -9364,6 +9674,21 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/sqlite3": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", @@ -9388,15 +9713,6 @@ } } }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -9409,6 +9725,37 @@ "node": ">=0.8" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-9.1.0.tgz", + "integrity": "sha512-Hzdr9OE6GxZjcmyM9tgBSIFVyrHAp9c6U2Y4yBkmYOHoQvZ7pIm27dmltvcmRfxcWiIcg8HBvG5iAikDf+ZuzQ==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "promise-retry": "^2.0.1", + "ssh2": "^1.12.0" + }, + "engines": { + "node": ">=10.24.1" + } + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -9683,6 +10030,18 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", @@ -9802,6 +10161,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "chownr": "^2.0.0", @@ -9959,6 +10319,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10229,6 +10595,25 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10296,6 +10681,64 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdav": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.9.0.tgz", + "integrity": "sha512-OMJ6wtK1WvCO++aOLoQgE96S8KT4e5aaClWHmHXfFU369r4eyELN569B7EqT4OOUb99mmO58GkyuiCv/Ag6J0Q==", + "license": "MIT", + "dependencies": { + "@buttercup/fetch": "^0.2.1", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "entities": "^6.0.1", + "fast-xml-parser": "^5.3.4", + "hot-patcher": "^2.0.1", + "layerr": "^3.0.0", + "md5": "^2.3.0", + "minimatch": "^9.0.5", + "nested-property": "^4.0.0", + "node-fetch": "^3.3.2", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webdav/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/webdav/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 0c4d832..d3f2334 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "format:check": "prettier --check \"**/*.js\"" }, "dependencies": { - "axios": "^1.13.4", + "axios": "^1.13.6", + "basic-ftp": "^5.0.3", "bcryptjs": "^3.0.3", "cors": "^2.8.5", "csv-parser": "^3.2.0", @@ -27,11 +28,14 @@ "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", - "mysql2": "^3.16.0", + "mysql2": "^3.19.1", "node-cron": "^4.2.1", - "sequelize": "^6.32.1", - "sqlite3": "^5.1.6", + "sequelize": "^6.37.8", + "smb2": "^0.2.2", + "sqlite3": "^5.1.7", + "ssh2-sftp-client": "^9.1.0", "three": "^0.182.0", + "webdav": "^5.3.1", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "xlsx": "^0.18.5" diff --git a/backend/routes/backup.js b/backend/routes/backup.js index 062bba2..397a5c1 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -22,6 +22,16 @@ const { updateAutoBackupSettings, executeBackupNow, } = require('../utils/autoBackupScheduler'); +const { + getAllTargets, + getTarget, + addTarget, + updateTarget, + deleteTarget, + getGlobalSettings, + updateGlobalSettings, +} = require('../utils/remoteBackupConfig'); +const { testRemoteConnection, PROTOCOL_TYPES, PROTOCOL_LABELS } = require('../utils/remoteBackup'); const tempDir = path.join(__dirname, '..', 'temp'); if (!fs.existsSync(tempDir)) { @@ -548,4 +558,300 @@ router.post('/auto/test-cron', (req, res) => { } }); +// ==================== 远端备份配置路由 ==================== + +// 获取所有远端备份目标 +router.get('/remote/targets', (req, res) => { + try { + const targets = getAllTargets(); + res.json({ + success: true, + data: { targets }, + }); + } catch (error) { + console.error('获取远端备份目标失败:', error); + res.status(500).json({ + success: false, + message: '获取远端备份目标失败', + error: error.message, + }); + } +}); + +// 获取单个远端备份目标 +router.get('/remote/targets/:id', (req, res) => { + try { + const target = getTarget(req.params.id); + + if (!target) { + return res.status(404).json({ + success: false, + message: '目标不存在', + }); + } + + res.json({ + success: true, + data: { target }, + }); + } catch (error) { + console.error('获取远端备份目标失败:', error); + res.status(500).json({ + success: false, + message: '获取远端备份目标失败', + error: error.message, + }); + } +}); + +// 添加远端备份目标 +router.post('/remote/targets', (req, res) => { + try { + const targetData = req.body; + + if (!targetData.name || !targetData.protocol) { + return res.status(400).json({ + success: false, + message: '请提供目标名称和协议类型', + }); + } + + const target = addTarget(targetData); + + res.status(201).json({ + success: true, + message: '远端备份目标已添加', + data: { target }, + }); + } catch (error) { + console.error('添加远端备份目标失败:', error); + res.status(500).json({ + success: false, + message: '添加远端备份目标失败', + error: error.message, + }); + } +}); + +// 更新远端备份目标 +router.put('/remote/targets/:id', (req, res) => { + try { + const updates = req.body; + const target = updateTarget(req.params.id, updates); + + res.json({ + success: true, + message: '远端备份目标已更新', + data: { target }, + }); + } catch (error) { + console.error('更新远端备份目标失败:', error); + res.status(500).json({ + success: false, + message: '更新远端备份目标失败', + error: error.message, + }); + } +}); + +// 删除远端备份目标 +router.delete('/remote/targets/:id', (req, res) => { + try { + const deleted = deleteTarget(req.params.id); + + if (!deleted) { + return res.status(404).json({ + success: false, + message: '目标不存在', + }); + } + + res.json({ + success: true, + message: '远端备份目标已删除', + }); + } catch (error) { + console.error('删除远端备份目标失败:', error); + res.status(500).json({ + success: false, + message: '删除远端备份目标失败', + error: error.message, + }); + } +}); + +// 测试远端连接 +router.post('/remote/targets/:id/test', async (req, res) => { + try { + const target = getTarget(req.params.id); + + if (!target) { + return res.status(404).json({ + success: false, + message: '目标不存在', + }); + } + + const result = await testRemoteConnection(target); + + if (result.success) { + res.json({ + success: true, + message: '连接测试成功', + data: result, + }); + } else { + res.status(500).json({ + success: false, + message: result.message, + error: result.error, + }); + } + } catch (error) { + console.error('测试远端连接失败:', error); + res.status(500).json({ + success: false, + message: '测试远端连接失败', + error: error.message, + }); + } +}); + +// 获取远端备份全局设置 +router.get('/remote/settings', (req, res) => { + try { + const settings = getGlobalSettings(); + res.json({ + success: true, + data: { settings }, + }); + } catch (error) { + console.error('获取远端备份设置失败:', error); + res.status(500).json({ + success: false, + message: '获取远端备份设置失败', + error: error.message, + }); + } +}); + +// 更新远端备份全局设置 +router.put('/remote/settings', (req, res) => { + try { + const settings = updateGlobalSettings(req.body); + res.json({ + success: true, + message: '远端备份设置已更新', + data: { settings }, + }); + } catch (error) { + console.error('更新远端备份设置失败:', error); + res.status(500).json({ + success: false, + message: '更新远端备份设置失败', + error: error.message, + }); + } +}); + +// 获取支持的协议列表 +router.get('/remote/protocols', (req, res) => { + try { + const protocols = Object.entries(PROTOCOL_TYPES).map(([key, value]) => ({ + key, + value, + label: PROTOCOL_LABELS[value], + })); + + res.json({ + success: true, + data: { protocols }, + }); + } catch (error) { + console.error('获取协议列表失败:', error); + res.status(500).json({ + success: false, + message: '获取协议列表失败', + error: error.message, + }); + } +}); + +// 手动上传备份到远端 +router.post('/remote/upload', async (req, res) => { + try { + const { filename, targetIds } = req.body; + + if (!filename) { + return res.status(400).json({ + success: false, + message: '请提供备份文件名', + }); + } + + const backupPath = getBackupPath(); + const filePath = path.join(backupPath, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ + success: false, + message: '备份文件不存在', + }); + } + + const { uploadToRemote } = require('../utils/remoteBackup'); + const { getTarget } = require('../utils/remoteBackupConfig'); + + const targets = targetIds + ? targetIds.map(id => getTarget(id)).filter(Boolean) + : getEnabledTargets(); + + if (targets.length === 0) { + return res.status(400).json({ + success: false, + message: '没有可用的远端目标', + }); + } + + const uploadResults = []; + + for (const target of targets) { + try { + const remotePath = `${target.prefix || 'backups/'}${filename}`; + const result = await uploadToRemote(target, filePath, remotePath); + + uploadResults.push({ + targetId: target.id, + targetName: target.name, + success: true, + ...result, + }); + } catch (error) { + uploadResults.push({ + targetId: target.id, + targetName: target.name, + success: false, + error: error.message, + }); + } + } + + res.json({ + success: true, + message: '上传完成', + data: { + filename, + results: uploadResults, + }, + }); + } catch (error) { + console.error('手动上传备份失败:', error); + res.status(500).json({ + success: false, + message: '手动上传备份失败', + error: error.message, + }); + } +}); + module.exports = router; diff --git a/backend/server.js b/backend/server.js index 7c4c62a..9ff5682 100644 --- a/backend/server.js +++ b/backend/server.js @@ -56,7 +56,7 @@ async function syncConsumableModels() { const ConsumableLogArchive = require('./models/ConsumableLogArchive'); await Promise.all([ - Consumable.sync({ alter: true }), + Consumable.sync(), // 暂时禁用 alter 模式 ConsumableLog.sync(), ConsumableCategory.sync(), ConsumableRecord.sync(), diff --git a/backend/utils/autoBackupScheduler.js b/backend/utils/autoBackupScheduler.js index 0b54a0b..72f898c 100644 --- a/backend/utils/autoBackupScheduler.js +++ b/backend/utils/autoBackupScheduler.js @@ -7,6 +7,8 @@ const cron = require('node-cron'); const path = require('path'); const fs = require('fs'); const { createBackup, createIncrementalBackup, getBackupPath } = require('./backup'); +const { uploadToRemote } = require('./remoteBackup'); +const { getEnabledTargets, getGlobalSettings } = require('./remoteBackupConfig'); // 全局调度器存储 const schedulers = new Map(); @@ -108,6 +110,10 @@ function createAutoBackupTask(settings) { if (result) { console.log('自动备份完成:', result.filename); console.log(`备份类型:${result.isIncremental ? '增量备份' : '全量备份'}`); + + // 上传到远端 + await uploadToRemoteTargets(result.path, result.filename); + console.log('========================\n'); } else { console.log('无数据变化,跳过备份'); @@ -234,6 +240,69 @@ function updateAutoBackupSettings(newSettings) { return false; } +/** + * 上传备份到所有启用的远端目标 + */ +async function uploadToRemoteTargets(localFilePath, filename) { + const globalSettings = getGlobalSettings(); + + if (!globalSettings.enabled || !globalSettings.uploadAfterBackup) { + console.log('远端备份已禁用'); + return []; + } + + const enabledTargets = getEnabledTargets(); + + if (enabledTargets.length === 0) { + console.log('没有启用的远端备份目标'); + return []; + } + + const uploadResults = []; + + for (const target of enabledTargets) { + try { + console.log(`开始上传到目标:${target.name} (${target.protocol})`); + + const remotePath = `${target.prefix || 'backups/'}${filename}`; + + const result = await uploadToRemote(target, localFilePath, remotePath); + + uploadResults.push({ + targetId: target.id, + targetName: target.name, + protocol: target.protocol, + success: true, + ...result, + }); + + console.log(`上传到 ${target.name} 成功`); + } catch (error) { + console.error(`上传到 ${target.name} 失败:`, error.message); + uploadResults.push({ + targetId: target.id, + targetName: target.name, + protocol: target.protocol, + success: false, + error: error.message, + }); + } + } + + // 检查是否需要删除本地文件 + const settings = getGlobalSettings(); + if (settings.deleteLocalAfterUpload && uploadResults.every(r => r.success)) { + try { + fs.unlinkSync(localFilePath); + console.log('本地备份文件已删除'); + } catch (error) { + console.error('删除本地备份文件失败:', error.message); + } + } + + return uploadResults; +} + /** * 立即执行一次备份 */ @@ -251,8 +320,16 @@ async function executeBackupNow(options = {}) { }); console.log('手动备份完成:', result.filename); + + // 上传到远端 + const uploadResults = await uploadToRemoteTargets(result.path, result.filename); + console.log('====================\n'); - return { success: true, result }; + return { + success: true, + result, + remoteUploads: uploadResults, + }; } catch (error) { console.error('手动备份失败:', error); console.error('====================\n'); diff --git a/backend/utils/remoteBackup.js b/backend/utils/remoteBackup.js new file mode 100644 index 0000000..5854694 --- /dev/null +++ b/backend/utils/remoteBackup.js @@ -0,0 +1,300 @@ +/** + * 远端备份上传工具模块 + * 支持 FTP、SFTP、WebDAV、SMB 等协议 + */ + +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); + +// 协议类型枚举 +const PROTOCOL_TYPES = { + FTP: 'ftp', + SFTP: 'sftp', + WEBDAV: 'webdav', + SMB: 'smb', +}; + +// 协议显示名称映射 +const PROTOCOL_LABELS = { + [PROTOCOL_TYPES.FTP]: 'FTP', + [PROTOCOL_TYPES.SFTP]: 'SFTP (SSH 文件传输)', + [PROTOCOL_TYPES.WEBDAV]: 'WebDAV', + [PROTOCOL_TYPES.SMB]: 'SMB/CIFS 网络共享', +}; + +/** + * FTP 上传实现 + */ +async function uploadViaFTP(config, localFilePath, remotePath) { + const { Client } = require('basic-ftp'); + + const client = new Client(); + + try { + await client.access({ + host: config.host, + port: config.port || 21, + user: config.username, + password: config.password, + secure: config.secure || false, + secureOptions: { + rejectUnauthorized: config.rejectUnauthorized !== false, + }, + }); + + await client.cd(config.rootPath || '/'); + + const dirPath = path.dirname(remotePath); + if (dirPath !== '.') { + await ensureRemoteDir(client, dirPath, 'ftp'); + } + + await client.uploadFrom(localFilePath, path.basename(remotePath)); + + return { + success: true, + message: `FTP 上传成功:${remotePath}`, + }; + } catch (error) { + throw new Error(`FTP 上传失败:${error.message}`); + } finally { + client.close(); + } +} + +/** + * SFTP 上传实现 + */ +async function uploadViaSFTP(config, localFilePath, remotePath) { + const Client = require('ssh2-sftp-client'); + const client = new Client(); + + try { + await client.connect({ + host: config.host, + port: config.port || 22, + username: config.username, + password: config.password, + privateKey: config.privateKey ? fs.readFileSync(config.privateKey) : undefined, + passphrase: config.passphrase, + readyTimeout: config.timeout || 10000, + }); + + const dirPath = path.dirname(remotePath); + if (dirPath !== '.') { + await ensureRemoteDir(client, dirPath, 'sftp'); + } + + await client.put(fs.createReadStream(localFilePath), remotePath); + + return { + success: true, + message: `SFTP 上传成功:${remotePath}`, + }; + } catch (error) { + throw new Error(`SFTP 上传失败:${error.message}`); + } finally { + await client.end(); + } +} + +/** + * WebDAV 上传实现 + */ +async function uploadViaWebDAV(config, localFilePath, remotePath) { + const { createClient } = require('webdav'); + + const client = createClient(config.url, { + username: config.username, + password: config.password, + authType: config.authType || 'password', + headers: { + 'User-Agent': 'IDC-Backup-Client/1.0', + }, + }); + + try { + const dirPath = path.dirname(remotePath); + if (dirPath !== '/') { + await ensureRemoteDir(client, dirPath, 'webdav'); + } + + const fileContent = fs.readFileSync(localFilePath); + await client.putFileContents(remotePath, fileContent, { + overwrite: true, + }); + + return { + success: true, + message: `WebDAV 上传成功:${remotePath}`, + }; + } catch (error) { + throw new Error(`WebDAV 上传失败:${error.message}`); + } +} + +/** + * SMB/CIFS 网络共享上传实现 + */ +async function uploadViaSMB(config, localFilePath, remotePath) { + const smb = require('smb2'); + + const client = new smb({ + share: `\\\\${config.host}\\${config.share}`, + domain: config.domain || '', + username: config.username, + password: config.password, + }); + + try { + const dirPath = path.dirname(remotePath); + if (dirPath !== '.') { + await ensureRemoteDir(client, dirPath, 'smb'); + } + + await client.writeFile(remotePath, fs.readFileSync(localFilePath)); + + return { + success: true, + message: `SMB 上传成功:${remotePath}`, + }; + } catch (error) { + throw new Error(`SMB 上传失败:${error.message}`); + } +} + +/** + * 确保远程目录存在 + */ +async function ensureRemoteDir(client, dirPath, protocol) { + try { + if (protocol === 'ftp') { + const dirs = dirPath.split('/').filter(d => d); + for (const dir of dirs) { + try { + await client.cd(dir); + } catch { + await client.mkdir(dir); + await client.cd(dir); + } + } + } else if (protocol === 'sftp') { + await client.mkdir(dirPath, true); + } else if (protocol === 'webdav') { + const parts = dirPath.split('/').filter(p => p); + let currentPath = ''; + for (const part of parts) { + currentPath += '/' + part; + try { + await client.createDirectory(currentPath); + } catch (error) { + if (!error.status || error.status !== 405) { + throw error; + } + } + } + } else if (protocol === 'smb') { + const parts = dirPath.split(path.sep).filter(p => p); + let currentPath = ''; + for (const part of parts) { + currentPath += part + path.sep; + try { + await client.exists(currentPath); + } catch { + await client.mkdir(currentPath); + } + } + } + } catch (error) { + console.warn(`创建远程目录失败 [${protocol}]:`, error.message); + } +} + +/** + * 主上传函数 - 根据配置选择对应协议 + */ +async function uploadToRemote(config, localFilePath, remotePath) { + console.log(`开始上传到远端 [${config.protocol}]: ${remotePath}`); + + const startTime = Date.now(); + + let result; + + switch (config.protocol) { + case PROTOCOL_TYPES.FTP: + result = await uploadViaFTP(config, localFilePath, remotePath); + break; + case PROTOCOL_TYPES.SFTP: + result = await uploadViaSFTP(config, localFilePath, remotePath); + break; + case PROTOCOL_TYPES.WEBDAV: + result = await uploadViaWebDAV(config, localFilePath, remotePath); + break; + case PROTOCOL_TYPES.SMB: + result = await uploadViaSMB(config, localFilePath, remotePath); + break; + default: + throw new Error(`不支持的协议类型:${config.protocol}`); + } + + const duration = Date.now() - startTime; + const fileSize = fs.statSync(localFilePath).size; + + console.log(`远端上传完成 [${config.protocol}] - 耗时:${duration}ms, 文件大小:${(fileSize / 1024).toFixed(2)}KB`); + + return { + ...result, + protocol: config.protocol, + protocolLabel: PROTOCOL_LABELS[config.protocol], + duration, + fileSize, + uploadedAt: new Date().toISOString(), + }; +} + +/** + * 测试远端连接 + */ +async function testRemoteConnection(config) { + console.log(`测试远端连接 [${config.protocol}]...`); + + try { + const testContent = `IDC Backup Connection Test - ${new Date().toISOString()}`; + const testFile = path.join(require('os').tmpdir(), `backup-test-${Date.now()}.txt`); + fs.writeFileSync(testFile, testContent); + + const testRemotePath = `test/backup-connection-test-${Date.now()}.txt`; + + const result = await uploadToRemote(config, testFile, testRemotePath); + + try { + fs.unlinkSync(testFile); + } catch (e) { + console.warn('删除测试文件失败:', e.message); + } + + return { + success: true, + message: '连接测试成功', + details: result, + }; + } catch (error) { + return { + success: false, + message: `连接测试失败:${error.message}`, + error: error.message, + }; + } +} + +module.exports = { + PROTOCOL_TYPES, + PROTOCOL_LABELS, + uploadToRemote, + testRemoteConnection, + uploadViaFTP, + uploadViaSFTP, + uploadViaWebDAV, + uploadViaSMB, +}; diff --git a/backend/utils/remoteBackupConfig.js b/backend/utils/remoteBackupConfig.js new file mode 100644 index 0000000..a239f79 --- /dev/null +++ b/backend/utils/remoteBackupConfig.js @@ -0,0 +1,297 @@ +/** + * 远端备份配置管理模块 + * 管理多个远端备份目标的配置 + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { PROTOCOL_TYPES } = require('./remoteBackup'); + +// 配置文件路径 +const CONFIG_FILE = path.join(__dirname, '..', 'config', 'remote-backup-configs.json'); + +// 默认配置 +const DEFAULT_CONFIG = { + targets: [], + globalSettings: { + enabled: false, + uploadAfterBackup: true, + deleteLocalAfterUpload: false, + retryCount: 3, + retryDelay: 5000, + timeout: 300000, + }, +}; + +/** + * 加密敏感信息 + */ +function encrypt(text) { + if (!text) return ''; + const algorithm = 'aes-256-cbc'; + const key = crypto.scryptSync(process.env.JWT_SECRET || 'default-secret', 'salt', 32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; +} + +/** + * 解密敏感信息 + */ +function decrypt(text) { + if (!text) return ''; + try { + const algorithm = 'aes-256-cbc'; + const key = crypto.scryptSync(process.env.JWT_SECRET || 'default-secret', 'salt', 32); + const parts = text.split(':'); + const iv = Buffer.from(parts[0], 'hex'); + const encrypted = parts[1]; + const decipher = crypto.createDecipheriv(algorithm, key, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (error) { + console.error('解密失败:', error.message); + return text; + } +} + +/** + * 加载配置 + */ +function loadConfig() { + try { + if (fs.existsSync(CONFIG_FILE)) { + const content = fs.readFileSync(CONFIG_FILE, 'utf8'); + const config = JSON.parse(content); + return { ...DEFAULT_CONFIG, ...config }; + } + } catch (error) { + console.error('加载远端备份配置失败:', error); + } + return { ...DEFAULT_CONFIG }; +} + +/** + * 保存配置 + */ +function saveConfig(config) { + try { + const configDir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); + return true; + } catch (error) { + console.error('保存远端备份配置失败:', error); + return false; + } +} + +/** + * 获取所有远端备份目标(隐藏敏感信息) + */ +function getAllTargets() { + const config = loadConfig(); + return config.targets.map(target => ({ + ...target, + password: target.password ? '********' : undefined, + accessKeySecret: target.accessKeySecret ? '********' : undefined, + secretKey: target.secretKey ? '********' : undefined, + privateKey: target.privateKey ? '********' : undefined, + passphrase: target.passphrase ? '********' : undefined, + })); +} + +/** + * 获取单个目标(包含解密后的敏感信息) + */ +function getTarget(id) { + const config = loadConfig(); + const target = config.targets.find(t => t.id === id); + + if (!target) { + return null; + } + + return { + ...target, + password: target.password ? decrypt(target.password) : undefined, + accessKeySecret: target.accessKeySecret ? decrypt(target.accessKeySecret) : undefined, + secretKey: target.secretKey ? decrypt(target.secretKey) : undefined, + privateKey: target.privateKey ? decrypt(target.privateKey) : undefined, + passphrase: target.passphrase ? decrypt(target.passphrase) : undefined, + }; +} + +/** + * 添加远端备份目标 + */ +function addTarget(targetData) { + const config = loadConfig(); + + const newTarget = { + id: `target_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + name: targetData.name, + protocol: targetData.protocol, + enabled: targetData.enabled !== false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + switch (targetData.protocol) { + case PROTOCOL_TYPES.FTP: + case PROTOCOL_TYPES.SFTP: + Object.assign(newTarget, { + host: targetData.host, + port: targetData.port, + username: targetData.username, + password: targetData.password ? encrypt(targetData.password) : undefined, + rootPath: targetData.rootPath || '/', + secure: targetData.secure, + }); + break; + + case PROTOCOL_TYPES.WEBDAV: + Object.assign(newTarget, { + url: targetData.url, + username: targetData.username, + password: targetData.password ? encrypt(targetData.password) : undefined, + authType: targetData.authType || 'password', + rootPath: targetData.rootPath || '/', + }); + break; + + case PROTOCOL_TYPES.SMB: + Object.assign(newTarget, { + host: targetData.host, + share: targetData.share, + domain: targetData.domain, + username: targetData.username, + password: targetData.password ? encrypt(targetData.password) : undefined, + rootPath: targetData.rootPath || '/', + }); + break; + + default: + throw new Error(`不支持的协议类型:${targetData.protocol}`); + } + + config.targets.push(newTarget); + + if (saveConfig(config)) { + return newTarget; + } + + throw new Error('保存配置失败'); +} + +/** + * 更新远端备份目标 + */ +function updateTarget(id, updates) { + const config = loadConfig(); + const targetIndex = config.targets.findIndex(t => t.id === id); + + if (targetIndex === -1) { + throw new Error('目标不存在'); + } + + const existingTarget = config.targets[targetIndex]; + const updatedTarget = { ...existingTarget, ...updates, updatedAt: new Date().toISOString() }; + + if (updates.password) { + updatedTarget.password = encrypt(updates.password); + } + if (updates.accessKeySecret) { + updatedTarget.accessKeySecret = encrypt(updates.accessKeySecret); + } + if (updates.secretKey) { + updatedTarget.secretKey = encrypt(updates.secretKey); + } + if (updates.privateKey) { + updatedTarget.privateKey = encrypt(updates.privateKey); + } + if (updates.passphrase) { + updatedTarget.passphrase = encrypt(updates.passphrase); + } + + config.targets[targetIndex] = updatedTarget; + + if (saveConfig(config)) { + return updatedTarget; + } + + throw new Error('保存配置失败'); +} + +/** + * 删除远端备份目标 + */ +function deleteTarget(id) { + const config = loadConfig(); + const initialLength = config.targets.length; + + config.targets = config.targets.filter(t => t.id !== id); + + if (config.targets.length < initialLength) { + if (saveConfig(config)) { + return true; + } + throw new Error('保存配置失败'); + } + + return false; +} + +/** + * 获取全局设置 + */ +function getGlobalSettings() { + const config = loadConfig(); + return config.globalSettings; +} + +/** + * 更新全局设置 + */ +function updateGlobalSettings(settings) { + const config = loadConfig(); + config.globalSettings = { ...config.globalSettings, ...settings }; + + if (saveConfig(config)) { + return config.globalSettings; + } + + throw new Error('保存配置失败'); +} + +/** + * 获取启用的目标列表 + */ +function getEnabledTargets() { + const config = loadConfig(); + if (!config.globalSettings.enabled) { + return []; + } + return config.targets.filter(t => t.enabled); +} + +module.exports = { + loadConfig, + saveConfig, + getAllTargets, + getTarget, + addTarget, + updateTarget, + deleteTarget, + getGlobalSettings, + updateGlobalSettings, + getEnabledTargets, + encrypt, + decrypt, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eb461c3..c01e31d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,7 @@ "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", "antd": "^5.8.6", - "axios": "^1.5.0", + "axios": "^1.13.6", "dayjs": "^1.11.19", "framer-motion": "^12.34.0", "papaparse": "^5.5.3", @@ -21,7 +21,7 @@ "react-router-dom": "^6.15.0", "styled-components": "^6.3.9", "swr": "^2.4.0", - "three": "^0.160.1", + "three": "^0.183.2", "xlsx": "^0.18.5" }, "devDependencies": { @@ -3016,13 +3016,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -7802,9 +7802,9 @@ } }, "node_modules/three": { - "version": "0.160.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz", - "integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==", + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", "license": "MIT", "peer": true }, diff --git a/frontend/package.json b/frontend/package.json index 085d792..5c0a394 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", "antd": "^5.8.6", - "axios": "^1.5.0", + "axios": "^1.13.6", "dayjs": "^1.11.19", "framer-motion": "^12.34.0", "papaparse": "^5.5.3", @@ -26,7 +26,7 @@ "react-router-dom": "^6.15.0", "styled-components": "^6.3.9", "swr": "^2.4.0", - "three": "^0.160.1", + "three": "^0.183.2", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c648c3b..9b8c2bc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -79,6 +79,7 @@ const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution const PendingDeviceManagement = lazy(() => import('./pages/PendingDeviceManagement')); const BackupManagement = lazy(() => import('./pages/BackupManagement')); const AutoBackupSettings = lazy(() => import('./pages/AutoBackupSettings')); +const RemoteBackupSettings = lazy(() => import('./pages/RemoteBackupSettings')); const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest')); const { Header, Content, Sider } = Layout; @@ -595,6 +596,7 @@ const routeConfig = [ { path: '/ports', component: PortManagement }, { path: '/backup', component: BackupManagement }, { path: '/auto-backup-settings', component: AutoBackupSettings }, + { path: '/remote-backup-settings', component: RemoteBackupSettings }, { path: '/error-boundary-test', component: ErrorBoundaryTest }, ]; diff --git a/frontend/src/pages/BackupManagement.jsx b/frontend/src/pages/BackupManagement.jsx index 644b216..932e483 100644 --- a/frontend/src/pages/BackupManagement.jsx +++ b/frontend/src/pages/BackupManagement.jsx @@ -18,10 +18,12 @@ import { Statistic, Row, Col, + Dropdown, } from 'antd'; import { CloudDownloadOutlined, CloudUploadOutlined, + CloudOutlined, DeleteOutlined, DownloadOutlined, UploadOutlined, @@ -33,6 +35,11 @@ import { ExclamationCircleOutlined, InfoCircleOutlined, SettingOutlined, + MoreOutlined, + SafetyOutlined, + PlusOutlined, + ClearOutlined, + EyeOutlined, } from '@ant-design/icons'; import api, { backupAPI } from '../api'; import CloseButton from '../components/CloseButton'; @@ -70,6 +77,7 @@ const designTokens = { warning: { main: '#f59e0b', light: '#fbbf24', + gradient: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)', }, danger: { main: '#ef4444', @@ -86,6 +94,11 @@ const designTokens = { secondary: '#64748b', muted: '#94a3b8', }, + border: { + light: '#e2e8f0', + medium: '#cbd5e1', + dark: '#94a3b8', + }, }, shadows: { sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', @@ -998,86 +1011,75 @@ const BackupManagement = () => { { title: 操作, key: 'action', - width: 320, + width: 280, render: (_, record) => ( - - + + - + - + handleDelete(record.filename)} okText="确定" cancelText="取消" + okButtonProps={{ danger: true }} > - + - - -

将删除超过 30 个或超过 90 天的旧备份

-

建议先点击"预览"查看将删除的文件

- - } - onConfirm={() => handleCleanBackups(30, 90, false)} - okText="确认清理" - cancelText="取消" - > - -
- - + + + - - + + , + label: '预览清理', + onClick: () => handleCleanBackups(30, 90, true), + }, + { + key: 'clean', + icon: , + label: '清理旧备份', + danger: true, + onClick: () => { + Modal.confirm({ + title: '确认清理旧备份', + content: '将删除超过 30 个或超过 90 天的旧备份文件,此操作不可撤销', + okText: '确认清理', + cancelText: '取消', + okButtonProps: { style: dangerButtonStyle }, + onOk: () => handleCleanBackups(30, 90, false), + }); + }, + }, + ], + }} + placement="bottomRight" + > + + + + + + + + +
@@ -1638,7 +1760,12 @@ const BackupManagement = () => { - +
- +
)} diff --git a/frontend/src/pages/RemoteBackupSettings.jsx b/frontend/src/pages/RemoteBackupSettings.jsx new file mode 100644 index 0000000..cd4d51f --- /dev/null +++ b/frontend/src/pages/RemoteBackupSettings.jsx @@ -0,0 +1,1551 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Card, + Button, + Table, + Tag, + Space, + Modal, + Form, + Input, + Select, + InputNumber, + Switch, + message, + Popconfirm, + Divider, + Alert, + Row, + Col, + Typography, + Tooltip, + Steps, + Skeleton, + Badge, + Progress, + Empty, + Result, + Collapse, + Statistic, +} from 'antd'; +import { + CloudOutlined, + PlusOutlined, + EditOutlined, + DeleteOutlined, + ThunderboltOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + ArrowLeftOutlined, + SettingOutlined, + CloudUploadOutlined, + GlobalOutlined, + LockOutlined, + UserOutlined, + DatabaseOutlined, + ApiOutlined, + SafetyOutlined, + InfoCircleOutlined, + WarningOutlined, + SyncOutlined, + QuestionCircleOutlined, + CopyOutlined, + ReloadOutlined, + LinkOutlined, + HddOutlined, + FolderOutlined, +} from '@ant-design/icons'; +import api from '../api'; +import { useNavigate } from 'react-router-dom'; + +const { Title, Text, Paragraph } = Typography; +const { TextArea } = Input; +const { Panel } = Collapse; + +// 设计系统 - 统一视觉风格 +const designTokens = { + colors: { + primary: { + main: '#667eea', + light: '#764ba2', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + gradientHover: 'linear-gradient(135deg, #764ba2 0%, #667eea 100%)', + }, + success: { + main: '#10b981', + light: '#34d399', + gradient: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)', + }, + warning: { + main: '#f59e0b', + light: '#fbbf24', + gradient: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)', + }, + danger: { + main: '#ef4444', + light: '#f87171', + gradient: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)', + }, + info: { + main: '#3b82f6', + light: '#60a5fa', + gradient: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)', + }, + background: { + primary: '#f8fafc', + secondary: '#ffffff', + accent: '#f1f5f9', + gradient: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)', + }, + text: { + primary: '#1e293b', + secondary: '#64748b', + muted: '#94a3b8', + }, + border: { + light: '#e2e8f0', + medium: '#cbd5e1', + }, + }, + shadows: { + sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', + xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', + glow: '0 0 20px rgba(102, 126, 234, 0.15)', + }, + borderRadius: { + sm: '8px', + md: '12px', + lg: '16px', + xl: '20px', + }, + transitions: { + fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)', + normal: '250ms cubic-bezier(0.4, 0, 0.2, 1)', + slow: '350ms cubic-bezier(0.4, 0, 0.2, 1)', + }, +}; + +// 按钮样式系统 +const buttonStyles = { + primary: { + background: designTokens.colors.primary.gradient, + border: 'none', + borderRadius: designTokens.borderRadius.md, + fontWeight: 600, + boxShadow: designTokens.shadows.md, + transition: `all ${designTokens.transitions.normal}`, + }, + secondary: { + background: designTokens.colors.background.secondary, + border: `1px solid ${designTokens.colors.border.light}`, + borderRadius: designTokens.borderRadius.md, + fontWeight: 500, + color: designTokens.colors.text.primary, + transition: `all ${designTokens.transitions.normal}`, + }, + danger: { + background: designTokens.colors.danger.gradient, + border: 'none', + borderRadius: designTokens.borderRadius.md, + fontWeight: 600, + boxShadow: designTokens.shadows.md, + transition: `all ${designTokens.transitions.normal}`, + }, + success: { + background: designTokens.colors.success.gradient, + border: 'none', + borderRadius: designTokens.borderRadius.md, + fontWeight: 600, + boxShadow: designTokens.shadows.md, + transition: `all ${designTokens.transitions.normal}`, + }, +}; + +const RemoteBackupSettings = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [targets, setTargets] = useState([]); + const [globalSettings, setGlobalSettings] = useState({ + enabled: false, + uploadAfterBackup: true, + deleteLocalAfterUpload: false, + retryCount: 3, + retryDelay: 5000, + timeout: 300000, + }); + const [modalVisible, setModalVisible] = useState(false); + const [editingTarget, setEditingTarget] = useState(null); + const [protocols, setProtocols] = useState([]); + const [form] = Form.useForm(); + const [currentStep, setCurrentStep] = useState(0); + const [testStatus, setTestStatus] = useState(null); + const [testing, setTesting] = useState(false); + + const protocolsList = [ + { value: 'sftp', label: 'SFTP', icon: , category: 'server', description: 'SSH 安全文件传输协议' }, + { value: 'ftp', label: 'FTP/FTPS', icon: , category: 'server', description: '传统文件传输协议(支持 FTPS)' }, + { value: 'webdav', label: 'WebDAV', icon: , category: 'server', description: '基于 HTTP 的文件传输协议' }, + { value: 'smb', label: 'SMB/CIFS', icon: , category: 'server', description: 'Windows 网络共享文件夹' }, + ]; + + useEffect(() => { + loadInitialData(); + }, []); + + const loadInitialData = async () => { + try { + setInitialLoading(true); + await Promise.all([ + fetchTargets(), + fetchGlobalSettings(), + fetchProtocols(), + ]); + } finally { + setInitialLoading(false); + } + }; + + const fetchTargets = async () => { + try { + const response = await api.get('/backup/remote/targets'); + if (response?.success) { + setTargets(response.data.targets || []); + } + } catch (error) { + console.error('获取远端目标列表失败:', error); + } + }; + + const fetchGlobalSettings = async () => { + try { + const response = await api.get('/backup/remote/settings'); + if (response?.success) { + setGlobalSettings(response.data.settings || {}); + } + } catch (error) { + console.error('获取全局设置失败:', error); + } + }; + + const fetchProtocols = async () => { + try { + const response = await api.get('/backup/remote/protocols'); + if (response?.success) { + setProtocols(response.data.protocols || []); + } + } catch (error) { + console.error('获取协议列表失败:', error); + } + }; + + const handleAdd = () => { + setEditingTarget(null); + setCurrentStep(0); + setTestStatus(null); + form.resetFields(); + form.setFieldsValue({ enabled: true }); + setModalVisible(true); + }; + + const handleEdit = (record) => { + setEditingTarget(record); + setCurrentStep(1); + setTestStatus(null); + form.setFieldsValue(record); + setModalVisible(true); + }; + + const handleDelete = async (id) => { + try { + setLoading(true); + const response = await api.delete(`/backup/remote/targets/${id}`); + if (response?.success) { + message.success('删除成功'); + fetchTargets(); + } + } catch (error) { + message.error('删除失败:' + (error.response?.data?.message || error.message)); + } finally { + setLoading(false); + } + }; + + const handleTest = async (record) => { + try { + setTesting(true); + const response = await api.post(`/backup/remote/targets/${record.id}/test`); + if (response?.success) { + message.success('连接测试成功!'); + } else { + message.error('连接测试失败:' + (response?.data?.message || '未知错误')); + } + } catch (error) { + message.error('连接测试失败:' + (error.response?.data?.message || error.message)); + } finally { + setTesting(false); + } + }; + + const handleTestConnection = async () => { + try { + setTesting(true); + setTestStatus('testing'); + + const values = await form.validateFields(); + const response = await api.post('/backup/remote/test', values); + + if (response?.success) { + setTestStatus('success'); + message.success('连接测试成功!'); + } else { + setTestStatus('error'); + message.error('连接测试失败:' + (response?.data?.message || '未知错误')); + } + } catch (error) { + setTestStatus('error'); + if (error.errorFields) { + message.error('请先完善表单信息'); + } else { + message.error('连接测试失败:' + (error.response?.data?.message || error.message)); + } + } finally { + setTesting(false); + } + }; + + const handleSubmit = async () => { + try { + setLoading(true); + const values = await form.validateFields(); + + if (editingTarget) { + const response = await api.put(`/backup/remote/targets/${editingTarget.id}`, values); + if (response?.success) { + message.success('更新成功'); + setModalVisible(false); + fetchTargets(); + } + } else { + const response = await api.post('/backup/remote/targets', values); + if (response?.success) { + message.success('添加成功'); + setModalVisible(false); + fetchTargets(); + } + } + } catch (error) { + if (!error.errorFields) { + message.error('保存失败:' + (error.response?.data?.message || error.message)); + } + } finally { + setLoading(false); + } + }; + + const handleUpdateGlobalSettings = async (key, value) => { + try { + const newSettings = { ...globalSettings, [key]: value }; + const response = await api.put('/backup/remote/settings', newSettings); + if (response?.success) { + setGlobalSettings(newSettings); + message.success('设置已更新'); + } + } catch (error) { + message.error('更新设置失败:' + (error.response?.data?.message || error.message)); + } + }; + + const getProtocolLabel = (protocol) => { + const proto = protocolsList.find(p => p.value === protocol); + return proto ? proto.label : protocol; + }; + + const getProtocolIcon = (protocol) => { + const proto = protocolsList.find(p => p.value === protocol); + return proto?.icon || ; + }; + + const getStatusColor = (enabled) => enabled ? 'success' : 'default'; + + const columns = [ + { + title: ( + + + 目标名称 + + ), + dataIndex: 'name', + key: 'name', + width: 200, + render: (text, record) => ( + +
+ {React.cloneElement(getProtocolIcon(record.protocol), { style: { color: '#fff', fontSize: 18 } })} +
+
+ {text} + + {getProtocolLabel(record.protocol)} + +
+
+ ), + }, + { + title: ( + + + 连接地址 + + ), + key: 'host', + width: 220, + render: (_, record) => { + if (record.protocol === 'webdav') { + return ( + + + {record.url?.length > 30 ? `${record.url.substring(0, 30)}...` : record.url} + + + ); + } + return ( + + {record.host}:{record.port || (record.protocol === 'ftp' ? 21 : 22)} + + ); + }, + }, + { + title: ( + + + 远程路径 + + ), + key: 'path', + width: 140, + render: (_, record) => ( + + {record.prefix || record.rootPath || '/'} + + ), + }, + { + title: ( + + 状态 + + ), + dataIndex: 'enabled', + key: 'enabled', + width: 100, + render: (enabled) => ( + + {enabled ? '启用' : '禁用'} + + ), + }, + { + title: ( + + 操作 + + ), + key: 'action', + width: 200, + fixed: 'right', + render: (_, record) => ( + + + + + + + + handleDelete(record.id)} + okText="确定删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + + + +
+
+ + <CloudOutlined style={{ + marginRight: 12, + fontSize: 32, + background: designTokens.colors.primary.gradient, + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + }} /> + 远端备份配置 + + + 配置多个远端存储目标,实现备份数据异地容灾 + +
+ + + + +
+
+ + + {/* 统计概览 */} + + + +
+
+ + 远端备份状态 + +
+ {globalSettings.enabled ? '已启用' : '已禁用'} +
+
+ handleUpdateGlobalSettings('enabled', checked)} + checkedChildren="开" + unCheckedChildren="关" + style={{ + background: globalSettings.enabled ? 'rgba(255,255,255,0.3)' : undefined + }} + /> +
+
+ + + +
+
+
+ +
+ 远端目标 +
+
+ {targets.length} + +
+
+
+ t.enabled).length / Math.max(targets.length, 1)) * 100} + showInfo={false} + strokeColor={designTokens.colors.primary.main} + size="small" + style={{ marginTop: 8 }} + /> + + {targets.filter(t => t.enabled).length} 个已启用 + +
+
+ + + +
+
+ +
+ 自动上传 +
+
+ + {globalSettings.uploadAfterBackup ? '已开启' : '已关闭'} + + handleUpdateGlobalSettings('uploadAfterBackup', checked)} + disabled={!globalSettings.enabled} + checkedChildren="开" + unCheckedChildren="关" + /> +
+
+ + + +
+
+ +
+ 删除本地文件 +
+
+ + {globalSettings.deleteLocalAfterUpload ? '已开启' : '已关闭'} + + handleUpdateGlobalSettings('deleteLocalAfterUpload', checked)} + disabled={!globalSettings.enabled} + checkedChildren="开" + unCheckedChildren="关" + /> +
+
+ +
+ + {/* 远端目标列表 */} + +
+ +
+ + 远端备份目标列表 + +
+ } + extra={ + + + 共 {targets.length} 个目标 + + + } + > + `共 ${total} 个目标`, + }} + scroll={{ x: 1100 }} + locale={{ + emptyText: ( + +
+ +
+ 暂无远端备份目标 + 点击下方按钮添加您的第一个远端备份目标 + + + } + /> + ), + }} + /> + + + {/* 帮助文档 */} + + +
+ +
+ 使用帮助与最佳实践 + + } + key="help" + style={{ + border: 'none', + background: designTokens.colors.background.secondary, + borderRadius: designTokens.borderRadius.lg, + }} + > + +
+ + 🎯 + 推荐配置 + + } + style={{ + borderRadius: designTokens.borderRadius.md, + border: `1px solid ${designTokens.colors.border.light}`, + }} + > +
    +
  • 多地备份:配置至少 2 个不同地理位置的目标
  • +
  • 加密传输:敏感数据使用 SFTP 或 HTTPS 协议
  • +
  • 定期测试:每月至少测试一次连接
  • +
+
+ + + + ⚠️ + 注意事项 + + } + style={{ + borderRadius: designTokens.borderRadius.md, + border: `1px solid ${designTokens.colors.border.light}`, + }} + > +
    +
  • 首次使用前请先测试连接
  • +
  • 密码等敏感信息已加密存储
  • +
  • 启用"删除本地文件"前请确保远端上传成功
  • +
  • 建议定期检查远端备份文件完整性
  • +
+
+ + + + + + + {/* 添加/编辑弹窗 */} + setModalVisible(false)} + footer={null} + width={800} + destroyOnClose + centered + styles={{ + body: { padding: '24px 32px' }, + }} + > +
+ + {editingTarget ? '编辑远端备份目标' : '添加远端备份目标'} + + + + } + /> + } + /> + } + /> + + +
+ {/* 步骤 1: 基本信息 */} + {currentStep === 0 && ( +
+ + + + + {renderProtocolSelector()} + + + + + +
+ +
+
+ )} + + {/* 步骤 2: 协议配置 */} + {currentStep === 1 && ( +
+ + {() => renderProtocolFields(form.getFieldValue('protocol'))} + + +
+ + + + + +
+
+ )} + + {/* 步骤 3: 测试保存 */} + {currentStep === 2 && ( +
+ + 请确认以下配置信息是否正确: +
    +
  • 目标名称:{form.getFieldValue('name')}
  • +
  • 协议类型:{getProtocolLabel(form.getFieldValue('protocol'))}
  • +
  • 启用状态:{form.getFieldValue('enabled') ? '启用' : '禁用'}
  • +
+
+ } + type="info" + showIcon + style={{ marginBottom: 16 }} + /> + + {testStatus === 'success' && ( + + )} + + {testStatus === 'error' && ( + + )} + +
+ + + + + +
+
+ )} + + +
+ + ); +}; + +export default RemoteBackupSettings;