From 53118b085de571598df3bf228453a254ed05a121 Mon Sep 17 00:00:00 2001 From: Lan Date: Sat, 27 Dec 2025 23:33:38 +0800 Subject: [PATCH 01/66] chore: upgrade dependencies - Update axios from ^1.11.0 to ^1.13.2 - Update eslint from ^9.32.0 to ^9.39.2 - Update glob from ^11.0.3 to ^13.0.0 - Update vite from ^7.0.6 to ^7.3.0 - Update pnpm-lock.yaml to reflect dependency version changes - Improve build tooling and linting capabilities with latest versions --- package.json | 8 +- pnpm-lock.yaml | 743 +++++++++++++++++++++++++++---------------------- 2 files changed, 414 insertions(+), 337 deletions(-) diff --git a/package.json b/package.json index 5bf1efb..f18265e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@tailwindcss/typography": "^0.5.16", - "axios": "^1.11.0", + "axios": "^1.13.2", "dompurify": "^3.2.6", "file-saver": "^2.0.5", "lru-cache": "^11.1.0", @@ -41,16 +41,16 @@ "@vue/eslint-config-typescript": "^14.6.0", "@vue/tsconfig": "^0.7.0", "autoprefixer": "^10.4.21", - "eslint": "^9.32.0", + "eslint": "^9.39.2", "eslint-plugin-vue": "^10.4.0", - "glob": "^11.0.3", + "glob": "^13.0.0", "npm-run-all2": "^8.0.4", "postcss": "^8.5.6", "prettier": "^3.6.2", "rimraf": "^6.0.1", "tailwindcss": "^3.4.17", "typescript": "~5.8.3", - "vite": "^7.0.6", + "vite": "^7.3.0", "vite-plugin-vue-devtools": "^8.0.0", "vue-tsc": "^3.0.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9458d3c..cf95d43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17) axios: - specifier: ^1.11.0 - version: 1.11.0 + specifier: ^1.13.2 + version: 1.13.2 dompurify: specifier: ^3.2.6 version: 3.2.6 @@ -71,16 +71,16 @@ importers: version: 3.0.5 '@vitejs/plugin-vue': specifier: ^6.0.1 - version: 6.0.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) + version: 6.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.0.1 - version: 5.1.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) + version: 5.1.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) '@vue/eslint-config-prettier': specifier: ^10.2.0 - version: 10.2.0(eslint@9.34.0(jiti@1.21.7))(prettier@3.6.2) + version: 10.2.0(eslint@9.39.2(jiti@1.21.7))(prettier@3.6.2) '@vue/eslint-config-typescript': specifier: ^14.6.0 - version: 14.6.0(eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.34.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@1.21.7))))(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) + version: 14.6.0(eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7))))(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) '@vue/tsconfig': specifier: ^0.7.0 version: 0.7.0(typescript@5.8.3)(vue@3.5.21(typescript@5.8.3)) @@ -88,14 +88,14 @@ importers: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) eslint: - specifier: ^9.32.0 - version: 9.34.0(jiti@1.21.7) + specifier: ^9.39.2 + version: 9.39.2(jiti@1.21.7) eslint-plugin-vue: specifier: ^10.4.0 - version: 10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.34.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@1.21.7))) + version: 10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7))) glob: - specifier: ^11.0.3 - version: 11.0.3 + specifier: ^13.0.0 + version: 13.0.0 npm-run-all2: specifier: ^8.0.4 version: 8.0.4 @@ -115,11 +115,11 @@ importers: specifier: ~5.8.3 version: 5.8.3 vite: - specifier: ^7.0.6 - version: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + specifier: ^7.3.0 + version: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) vite-plugin-vue-devtools: specifier: ^8.0.0 - version: 8.0.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) + version: 8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) vue-tsc: specifier: ^3.0.4 version: 3.0.6(typescript@5.8.3) @@ -274,158 +274,158 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} - '@esbuild/aix-ppc64@0.25.9': - resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.9': - resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.9': - resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.9': - resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.9': - resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.9': - resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.9': - resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.9': - resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.9': - resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.9': - resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.9': - resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.9': - resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.9': - resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.9': - resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.9': - resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.9': - resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.9': - resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.9': - resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.9': - resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.9': - resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.9': - resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.9': - resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.9': - resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.9': - resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.9': - resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.9': - resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -436,36 +436,54 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/config-array@0.21.0': resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.3.1': - resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.15.2': - resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.34.0': - resolution: {integrity: sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.3.5': - resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@humanfs/core@0.19.1': @@ -550,119 +568,124 @@ packages: '@rolldown/pluginutils@1.0.0-beta.34': resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} - '@rollup/rollup-android-arm-eabi@4.50.0': - resolution: {integrity: sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==} + '@rollup/rollup-android-arm-eabi@4.54.0': + resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.50.0': - resolution: {integrity: sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==} + '@rollup/rollup-android-arm64@4.54.0': + resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.50.0': - resolution: {integrity: sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==} + '@rollup/rollup-darwin-arm64@4.54.0': + resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.50.0': - resolution: {integrity: sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==} + '@rollup/rollup-darwin-x64@4.54.0': + resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.50.0': - resolution: {integrity: sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==} + '@rollup/rollup-freebsd-arm64@4.54.0': + resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.50.0': - resolution: {integrity: sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==} + '@rollup/rollup-freebsd-x64@4.54.0': + resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.50.0': - resolution: {integrity: sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==} + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.50.0': - resolution: {integrity: sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==} + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.50.0': - resolution: {integrity: sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==} + '@rollup/rollup-linux-arm64-gnu@4.54.0': + resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.50.0': - resolution: {integrity: sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==} + '@rollup/rollup-linux-arm64-musl@4.54.0': + resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loongarch64-gnu@4.50.0': - resolution: {integrity: sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==} + '@rollup/rollup-linux-loong64-gnu@4.54.0': + resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.50.0': - resolution: {integrity: sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==} + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.50.0': - resolution: {integrity: sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==} + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.50.0': - resolution: {integrity: sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==} + '@rollup/rollup-linux-riscv64-musl@4.54.0': + resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.50.0': - resolution: {integrity: sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==} + '@rollup/rollup-linux-s390x-gnu@4.54.0': + resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.50.0': - resolution: {integrity: sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==} + '@rollup/rollup-linux-x64-gnu@4.54.0': + resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.50.0': - resolution: {integrity: sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==} + '@rollup/rollup-linux-x64-musl@4.54.0': + resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openharmony-arm64@4.50.0': - resolution: {integrity: sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==} + '@rollup/rollup-openharmony-arm64@4.54.0': + resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.50.0': - resolution: {integrity: sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==} + '@rollup/rollup-win32-arm64-msvc@4.54.0': + resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.50.0': - resolution: {integrity: sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==} + '@rollup/rollup-win32-ia32-msvc@4.54.0': + resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.50.0': - resolution: {integrity: sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==} + '@rollup/rollup-win32-x64-gnu@4.54.0': + resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.54.0': + resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} cpu: [x64] os: [win32] @@ -950,8 +973,8 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.11.0: - resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1057,6 +1080,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1124,8 +1156,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - esbuild@0.25.9: - resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true @@ -1180,8 +1212,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.34.0: - resolution: {integrity: sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==} + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -1284,8 +1316,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} fraction.js@4.3.7: @@ -1323,15 +1355,19 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} hasBin: true - glob@11.0.3: - resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} hasBin: true + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1461,8 +1497,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.1.0: @@ -1565,8 +1601,8 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - minimatch@10.0.3: - resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} minimatch@3.1.2: @@ -1700,6 +1736,10 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1851,8 +1891,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rollup@4.50.0: - resolution: {integrity: sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==} + rollup@4.54.0: + resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1960,8 +2000,8 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} to-regex-range@5.0.1: @@ -2051,8 +2091,8 @@ packages: peerDependencies: vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - vite@7.1.4: - resolution: {integrity: sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==} + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2371,91 +2411,98 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@esbuild/aix-ppc64@0.25.9': + '@esbuild/aix-ppc64@0.27.2': optional: true - '@esbuild/android-arm64@0.25.9': + '@esbuild/android-arm64@0.27.2': optional: true - '@esbuild/android-arm@0.25.9': + '@esbuild/android-arm@0.27.2': optional: true - '@esbuild/android-x64@0.25.9': + '@esbuild/android-x64@0.27.2': optional: true - '@esbuild/darwin-arm64@0.25.9': + '@esbuild/darwin-arm64@0.27.2': optional: true - '@esbuild/darwin-x64@0.25.9': + '@esbuild/darwin-x64@0.27.2': optional: true - '@esbuild/freebsd-arm64@0.25.9': + '@esbuild/freebsd-arm64@0.27.2': optional: true - '@esbuild/freebsd-x64@0.25.9': + '@esbuild/freebsd-x64@0.27.2': optional: true - '@esbuild/linux-arm64@0.25.9': + '@esbuild/linux-arm64@0.27.2': optional: true - '@esbuild/linux-arm@0.25.9': + '@esbuild/linux-arm@0.27.2': optional: true - '@esbuild/linux-ia32@0.25.9': + '@esbuild/linux-ia32@0.27.2': optional: true - '@esbuild/linux-loong64@0.25.9': + '@esbuild/linux-loong64@0.27.2': optional: true - '@esbuild/linux-mips64el@0.25.9': + '@esbuild/linux-mips64el@0.27.2': optional: true - '@esbuild/linux-ppc64@0.25.9': + '@esbuild/linux-ppc64@0.27.2': optional: true - '@esbuild/linux-riscv64@0.25.9': + '@esbuild/linux-riscv64@0.27.2': optional: true - '@esbuild/linux-s390x@0.25.9': + '@esbuild/linux-s390x@0.27.2': optional: true - '@esbuild/linux-x64@0.25.9': + '@esbuild/linux-x64@0.27.2': optional: true - '@esbuild/netbsd-arm64@0.25.9': + '@esbuild/netbsd-arm64@0.27.2': optional: true - '@esbuild/netbsd-x64@0.25.9': + '@esbuild/netbsd-x64@0.27.2': optional: true - '@esbuild/openbsd-arm64@0.25.9': + '@esbuild/openbsd-arm64@0.27.2': optional: true - '@esbuild/openbsd-x64@0.25.9': + '@esbuild/openbsd-x64@0.27.2': optional: true - '@esbuild/openharmony-arm64@0.25.9': + '@esbuild/openharmony-arm64@0.27.2': optional: true - '@esbuild/sunos-x64@0.25.9': + '@esbuild/sunos-x64@0.27.2': optional: true - '@esbuild/win32-arm64@0.25.9': + '@esbuild/win32-arm64@0.27.2': optional: true - '@esbuild/win32-ia32@0.25.9': + '@esbuild/win32-ia32@0.27.2': optional: true - '@esbuild/win32-x64@0.25.9': + '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.8.0(eslint@9.34.0(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.8.0(eslint@9.39.2(jiti@1.21.7))': dependencies: - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@1.21.7))': + dependencies: + eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} + '@eslint-community/regexpp@4.12.2': {} + '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 @@ -2464,33 +2511,45 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.3.1': {} + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color - '@eslint/core@0.15.2': + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.1 + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.34.0': {} + '@eslint/js@9.39.2': {} '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.3.5': + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': dependencies: - '@eslint/core': 0.15.2 + '@eslint/core': 0.17.0 levn: 0.4.1 '@humanfs/core@0.19.1': {} @@ -2568,67 +2627,70 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.34': {} - '@rollup/rollup-android-arm-eabi@4.50.0': + '@rollup/rollup-android-arm-eabi@4.54.0': optional: true - '@rollup/rollup-android-arm64@4.50.0': + '@rollup/rollup-android-arm64@4.54.0': optional: true - '@rollup/rollup-darwin-arm64@4.50.0': + '@rollup/rollup-darwin-arm64@4.54.0': optional: true - '@rollup/rollup-darwin-x64@4.50.0': + '@rollup/rollup-darwin-x64@4.54.0': optional: true - '@rollup/rollup-freebsd-arm64@4.50.0': + '@rollup/rollup-freebsd-arm64@4.54.0': optional: true - '@rollup/rollup-freebsd-x64@4.50.0': + '@rollup/rollup-freebsd-x64@4.54.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.50.0': + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.50.0': + '@rollup/rollup-linux-arm-musleabihf@4.54.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.50.0': + '@rollup/rollup-linux-arm64-gnu@4.54.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.50.0': + '@rollup/rollup-linux-arm64-musl@4.54.0': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.50.0': + '@rollup/rollup-linux-loong64-gnu@4.54.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.50.0': + '@rollup/rollup-linux-ppc64-gnu@4.54.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.50.0': + '@rollup/rollup-linux-riscv64-gnu@4.54.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.50.0': + '@rollup/rollup-linux-riscv64-musl@4.54.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.50.0': + '@rollup/rollup-linux-s390x-gnu@4.54.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.50.0': + '@rollup/rollup-linux-x64-gnu@4.54.0': optional: true - '@rollup/rollup-linux-x64-musl@4.50.0': + '@rollup/rollup-linux-x64-musl@4.54.0': optional: true - '@rollup/rollup-openharmony-arm64@4.50.0': + '@rollup/rollup-openharmony-arm64@4.54.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.50.0': + '@rollup/rollup-win32-arm64-msvc@4.54.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.50.0': + '@rollup/rollup-win32-ia32-msvc@4.54.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.50.0': + '@rollup/rollup-win32-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true '@rushstack/eslint-patch@1.12.0': {} @@ -2662,15 +2724,15 @@ snapshots: '@types/trusted-types@2.0.7': optional: true - '@typescript-eslint/eslint-plugin@8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.42.0 - '@typescript-eslint/type-utils': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) - '@typescript-eslint/utils': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/utils': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.42.0 - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -2679,14 +2741,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3)': + '@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.42.0 '@typescript-eslint/types': 8.42.0 '@typescript-eslint/typescript-estree': 8.42.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.42.0 debug: 4.4.1 - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -2709,13 +2771,13 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.42.0 '@typescript-eslint/typescript-estree': 8.42.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/utils': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) debug: 4.4.1 - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -2739,13 +2801,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3)': + '@typescript-eslint/utils@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.8.0(eslint@9.34.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.8.0(eslint@9.39.2(jiti@1.21.7)) '@typescript-eslint/scope-manager': 8.42.0 '@typescript-eslint/types': 8.42.0 '@typescript-eslint/typescript-estree': 8.42.0(typescript@5.8.3) - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -2755,22 +2817,22 @@ snapshots: '@typescript-eslint/types': 8.42.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))': + '@vitejs/plugin-vue-jsx@5.1.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.3) '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.3) '@rolldown/pluginutils': 1.0.0-beta.34 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.3) - vite: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) vue: 3.5.21(typescript@5.8.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))': + '@vitejs/plugin-vue@6.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 - vite: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) vue: 3.5.21(typescript@5.8.3) '@volar/language-core@2.4.23': @@ -2855,14 +2917,14 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.7 - '@vue/devtools-core@8.0.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))': + '@vue/devtools-core@8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))': dependencies: '@vue/devtools-kit': 8.0.1 '@vue/devtools-shared': 8.0.1 mitt: 3.0.1 nanoid: 5.1.5 pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) + vite-hot-client: 2.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) vue: 3.5.21(typescript@5.8.3) transitivePeerDependencies: - vite @@ -2895,23 +2957,23 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/eslint-config-prettier@10.2.0(eslint@9.34.0(jiti@1.21.7))(prettier@3.6.2)': + '@vue/eslint-config-prettier@10.2.0(eslint@9.39.2(jiti@1.21.7))(prettier@3.6.2)': dependencies: - eslint: 9.34.0(jiti@1.21.7) - eslint-config-prettier: 10.1.8(eslint@9.34.0(jiti@1.21.7)) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.34.0(jiti@1.21.7)))(eslint@9.34.0(jiti@1.21.7))(prettier@3.6.2) + eslint: 9.39.2(jiti@1.21.7) + eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(prettier@3.6.2) prettier: 3.6.2 transitivePeerDependencies: - '@types/eslint' - '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.34.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@1.21.7))))(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3)': + '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7))))(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': dependencies: - '@typescript-eslint/utils': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) - eslint: 9.34.0(jiti@1.21.7) - eslint-plugin-vue: 10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.34.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@1.21.7))) + '@typescript-eslint/utils': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.2(jiti@1.21.7) + eslint-plugin-vue: 10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7))) fast-glob: 3.3.3 - typescript-eslint: 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) - vue-eslint-parser: 10.2.0(eslint@9.34.0(jiti@1.21.7)) + typescript-eslint: 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@1.21.7)) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -3009,10 +3071,10 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 - axios@1.11.0: + axios@1.13.2: dependencies: follow-redirects: 1.15.11 - form-data: 4.0.4 + form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -3113,6 +3175,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + deep-is@0.1.4: {} default-browser-id@5.0.0: {} @@ -3167,64 +3233,64 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - esbuild@0.25.9: + esbuild@0.27.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.9 - '@esbuild/android-arm': 0.25.9 - '@esbuild/android-arm64': 0.25.9 - '@esbuild/android-x64': 0.25.9 - '@esbuild/darwin-arm64': 0.25.9 - '@esbuild/darwin-x64': 0.25.9 - '@esbuild/freebsd-arm64': 0.25.9 - '@esbuild/freebsd-x64': 0.25.9 - '@esbuild/linux-arm': 0.25.9 - '@esbuild/linux-arm64': 0.25.9 - '@esbuild/linux-ia32': 0.25.9 - '@esbuild/linux-loong64': 0.25.9 - '@esbuild/linux-mips64el': 0.25.9 - '@esbuild/linux-ppc64': 0.25.9 - '@esbuild/linux-riscv64': 0.25.9 - '@esbuild/linux-s390x': 0.25.9 - '@esbuild/linux-x64': 0.25.9 - '@esbuild/netbsd-arm64': 0.25.9 - '@esbuild/netbsd-x64': 0.25.9 - '@esbuild/openbsd-arm64': 0.25.9 - '@esbuild/openbsd-x64': 0.25.9 - '@esbuild/openharmony-arm64': 0.25.9 - '@esbuild/sunos-x64': 0.25.9 - '@esbuild/win32-arm64': 0.25.9 - '@esbuild/win32-ia32': 0.25.9 - '@esbuild/win32-x64': 0.25.9 + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.34.0(jiti@1.21.7)): + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) - eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.34.0(jiti@1.21.7)))(eslint@9.34.0(jiti@1.21.7))(prettier@3.6.2): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(prettier@3.6.2): dependencies: - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.11 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.34.0(jiti@1.21.7)) + eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.34.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@1.21.7))): + eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7))): dependencies: - '@eslint-community/eslint-utils': 4.8.0(eslint@9.34.0(jiti@1.21.7)) - eslint: 9.34.0(jiti@1.21.7) + '@eslint-community/eslint-utils': 4.8.0(eslint@9.39.2(jiti@1.21.7)) + eslint: 9.39.2(jiti@1.21.7) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.2 semver: 7.7.2 - vue-eslint-parser: 10.2.0(eslint@9.34.0(jiti@1.21.7)) + vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@1.21.7)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) eslint-scope@8.4.0: dependencies: @@ -3235,25 +3301,24 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.34.0(jiti@1.21.7): + eslint@9.39.2(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.8.0(eslint@9.34.0(jiti@1.21.7)) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.3.1 - '@eslint/core': 0.15.2 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.34.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -3369,7 +3434,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.4: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -3417,7 +3482,7 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: + glob@10.5.0: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 @@ -3426,15 +3491,21 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@11.0.3: + glob@11.1.0: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.0.3 + minimatch: 10.1.1 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.0 + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + globals@14.0.0: {} gopd@1.2.0: {} @@ -3524,7 +3595,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3600,7 +3671,7 @@ snapshots: dependencies: mime-db: 1.52.0 - minimatch@10.0.3: + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -3720,6 +3791,11 @@ snapshots: lru-cache: 11.1.0 minipass: 7.1.2 + path-scurry@2.0.1: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + pathe@2.0.3: {} perfect-debounce@1.0.0: {} @@ -3836,34 +3912,35 @@ snapshots: rimraf@6.0.1: dependencies: - glob: 11.0.3 + glob: 11.1.0 package-json-from-dist: 1.0.1 - rollup@4.50.0: + rollup@4.54.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.50.0 - '@rollup/rollup-android-arm64': 4.50.0 - '@rollup/rollup-darwin-arm64': 4.50.0 - '@rollup/rollup-darwin-x64': 4.50.0 - '@rollup/rollup-freebsd-arm64': 4.50.0 - '@rollup/rollup-freebsd-x64': 4.50.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.50.0 - '@rollup/rollup-linux-arm-musleabihf': 4.50.0 - '@rollup/rollup-linux-arm64-gnu': 4.50.0 - '@rollup/rollup-linux-arm64-musl': 4.50.0 - '@rollup/rollup-linux-loongarch64-gnu': 4.50.0 - '@rollup/rollup-linux-ppc64-gnu': 4.50.0 - '@rollup/rollup-linux-riscv64-gnu': 4.50.0 - '@rollup/rollup-linux-riscv64-musl': 4.50.0 - '@rollup/rollup-linux-s390x-gnu': 4.50.0 - '@rollup/rollup-linux-x64-gnu': 4.50.0 - '@rollup/rollup-linux-x64-musl': 4.50.0 - '@rollup/rollup-openharmony-arm64': 4.50.0 - '@rollup/rollup-win32-arm64-msvc': 4.50.0 - '@rollup/rollup-win32-ia32-msvc': 4.50.0 - '@rollup/rollup-win32-x64-msvc': 4.50.0 + '@rollup/rollup-android-arm-eabi': 4.54.0 + '@rollup/rollup-android-arm64': 4.54.0 + '@rollup/rollup-darwin-arm64': 4.54.0 + '@rollup/rollup-darwin-x64': 4.54.0 + '@rollup/rollup-freebsd-arm64': 4.54.0 + '@rollup/rollup-freebsd-x64': 4.54.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 + '@rollup/rollup-linux-arm-musleabihf': 4.54.0 + '@rollup/rollup-linux-arm64-gnu': 4.54.0 + '@rollup/rollup-linux-arm64-musl': 4.54.0 + '@rollup/rollup-linux-loong64-gnu': 4.54.0 + '@rollup/rollup-linux-ppc64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-musl': 4.54.0 + '@rollup/rollup-linux-s390x-gnu': 4.54.0 + '@rollup/rollup-linux-x64-gnu': 4.54.0 + '@rollup/rollup-linux-x64-musl': 4.54.0 + '@rollup/rollup-openharmony-arm64': 4.54.0 + '@rollup/rollup-win32-arm64-msvc': 4.54.0 + '@rollup/rollup-win32-ia32-msvc': 4.54.0 + '@rollup/rollup-win32-x64-gnu': 4.54.0 + '@rollup/rollup-win32-x64-msvc': 4.54.0 fsevents: 2.3.3 run-applescript@7.0.0: {} @@ -3926,7 +4003,7 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 - glob: 10.4.5 + glob: 10.5.0 lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 @@ -3981,7 +4058,7 @@ snapshots: dependencies: any-promise: 1.3.0 - tinyglobby@0.2.14: + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 @@ -4002,13 +4079,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3): + typescript-eslint@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) - '@typescript-eslint/parser': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/eslint-plugin': 8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.42.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.42.0(eslint@9.34.0(jiti@1.21.7))(typescript@5.8.3) - eslint: 9.34.0(jiti@1.21.7) + '@typescript-eslint/utils': 8.42.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -4036,17 +4113,17 @@ snapshots: util-deprecate@1.0.2: {} - vite-dev-rpc@1.1.0(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): + vite-dev-rpc@1.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): dependencies: birpc: 2.5.0 - vite: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-hot-client: 2.1.0(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) + vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + vite-hot-client: 2.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) - vite-hot-client@2.1.0(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): + vite-hot-client@2.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): dependencies: - vite: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-plugin-inspect@11.3.3(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): + vite-plugin-inspect@11.3.3(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): dependencies: ansis: 4.1.0 debug: 4.4.1 @@ -4056,27 +4133,27 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.1 unplugin-utils: 0.3.0 - vite: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-dev-rpc: 1.1.0(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) + vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + vite-dev-rpc: 1.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.0.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)): + vite-plugin-vue-devtools@8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)): dependencies: - '@vue/devtools-core': 8.0.1(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) + '@vue/devtools-core': 8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) '@vue/devtools-kit': 8.0.1 '@vue/devtools-shared': 8.0.1 execa: 9.6.0 sirv: 3.0.1 - vite: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-plugin-inspect: 11.3.3(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) - vite-plugin-vue-inspector: 5.3.2(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) + vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + vite-plugin-inspect: 11.3.3(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) + vite-plugin-vue-inspector: 5.3.2(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) transitivePeerDependencies: - '@nuxt/kit' - supports-color - vue - vite-plugin-vue-inspector@5.3.2(vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): + vite-plugin-vue-inspector@5.3.2(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): dependencies: '@babel/core': 7.28.3 '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.3) @@ -4087,18 +4164,18 @@ snapshots: '@vue/compiler-dom': 3.5.21 kolorist: 1.8.0 magic-string: 0.30.18 - vite: 7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) + vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) transitivePeerDependencies: - supports-color - vite@7.1.4(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1): + vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1): dependencies: - esbuild: 0.25.9 + esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.50.0 - tinyglobby: 0.2.14 + rollup: 4.54.0 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.3.0 fsevents: 2.3.3 @@ -4107,10 +4184,10 @@ snapshots: vscode-uri@3.1.0: {} - vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@1.21.7)): + vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 4.4.1 - eslint: 9.34.0(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 From 88c15c7506d9ba0694df57742ff40494c9c37afc Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 31 Dec 2025 03:22:55 +0800 Subject: [PATCH 02/66] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20S3=E9=A2=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/FileUploadArea.vue | 176 ++++++++++- src/composables/useFileUpload.ts | 147 +++++++++- src/composables/usePresignedUpload.ts | 357 +++++++++++++++++++++++ src/services/index.ts | 98 ++++++- src/types/index.ts | 66 ++++- src/views/SendFileView.vue | 48 ++- 6 files changed, 864 insertions(+), 28 deletions(-) create mode 100644 src/composables/usePresignedUpload.ts diff --git a/src/components/common/FileUploadArea.vue b/src/components/common/FileUploadArea.vue index 3071ec4..5e7acce 100644 --- a/src/components/common/FileUploadArea.vue +++ b/src/components/common/FileUploadArea.vue @@ -4,7 +4,8 @@ :class="[ isDarkMode ? 'bg-gray-800 bg-opacity-50 border-gray-600 hover:border-indigo-500' - : 'bg-gray-100 border-gray-300 hover:border-indigo-500' + : 'bg-gray-100 border-gray-300 hover:border-indigo-500', + statusClass ]" @click="triggerFileUpload" @dragover.prevent @@ -16,18 +17,22 @@ class="hidden" @change="handleFileUpload" :accept="acceptedTypes" + :disabled="isUploading" />
- + + +

- {{ selectedFile ? selectedFile.name : placeholderText }} + {{ displayText }}

-

- {{ descriptionText }} + + +

+ {{ statusDescription }}

+ + +
+
+ {{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }} + {{ progress }}% +
+
+ + + \ No newline at end of file diff --git a/src/composables/useFileUpload.ts b/src/composables/useFileUpload.ts index 7f1b673..6bb3131 100644 --- a/src/composables/useFileUpload.ts +++ b/src/composables/useFileUpload.ts @@ -1,12 +1,30 @@ import { ref, computed, readonly } from 'vue' import { FileService } from '@/services' import { useAlertStore } from '@/stores/alertStore' -import type { UploadProgress, UploadStatus } from '@/types' +import { usePresignedUpload } from '@/composables/usePresignedUpload' +import type { UploadProgress, UploadStatus, PresignUploadOptions, ExpireStyle } from '@/types' import { UPLOAD_STATUS, FILE_SIZE_LIMITS } from '@/constants' -export function useFileUpload() { +export interface FileUploadOptions { + /** 是否使用预签名上传,默认 false 保持向后兼容 */ + usePresigned?: boolean + /** 过期时间值 */ + expireValue?: number + /** 过期时间类型 */ + expireStyle?: ExpireStyle + /** 进度回调 */ + onProgress?: (progress: UploadProgress) => void +} + +export function useFileUpload(options?: { defaultUsePresigned?: boolean }) { const alertStore = useAlertStore() + // 预签名上传 composable + const presignedUpload = usePresignedUpload() + + // 是否默认使用预签名上传 + const defaultUsePresigned = options?.defaultUsePresigned ?? false + // 状态管理 const uploadStatus = ref(UPLOAD_STATUS.IDLE) const uploadProgress = ref({ @@ -17,11 +35,34 @@ export function useFileUpload() { const uploadedCode = ref('') const currentFile = ref(null) + // 当前是否使用预签名上传 + const isUsingPresigned = ref(false) + // 计算属性 - const isUploading = computed(() => uploadStatus.value === UPLOAD_STATUS.UPLOADING) - const isSuccess = computed(() => uploadStatus.value === UPLOAD_STATUS.SUCCESS) - const isError = computed(() => uploadStatus.value === UPLOAD_STATUS.ERROR) - const isIdle = computed(() => uploadStatus.value === UPLOAD_STATUS.IDLE) + const isUploading = computed(() => { + if (isUsingPresigned.value) { + return presignedUpload.isUploading.value || presignedUpload.isInitializing.value || presignedUpload.isConfirming.value + } + return uploadStatus.value === UPLOAD_STATUS.UPLOADING + }) + const isSuccess = computed(() => { + if (isUsingPresigned.value) { + return presignedUpload.isSuccess.value + } + return uploadStatus.value === UPLOAD_STATUS.SUCCESS + }) + const isError = computed(() => { + if (isUsingPresigned.value) { + return presignedUpload.isError.value + } + return uploadStatus.value === UPLOAD_STATUS.ERROR + }) + const isIdle = computed(() => { + if (isUsingPresigned.value) { + return presignedUpload.presignStatus.value === 'idle' + } + return uploadStatus.value === UPLOAD_STATUS.IDLE + }) // 文件验证 const validateFile = (file: File): boolean => { @@ -35,19 +76,43 @@ export function useFileUpload() { return true } - // 上传文件 - const uploadFile = async (file: File): Promise => { + /** + * 上传文件(支持预签名上传和传统上传) + */ + const uploadFile = async (file: File, uploadOptions?: FileUploadOptions): Promise => { + const shouldUsePresigned = uploadOptions?.usePresigned ?? defaultUsePresigned + if (!validateFile(file)) { return null } + // 记录当前使用的上传方式 + isUsingPresigned.value = shouldUsePresigned + currentFile.value = file + + if (shouldUsePresigned) { + // 使用预签名上传 + return await uploadFileWithPresigned(file, uploadOptions) + } else { + // 使用传统上传方式 + return await uploadFileTraditional(file, uploadOptions?.onProgress) + } + } + + /** + * 传统上传方式 + */ + const uploadFileTraditional = async ( + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise => { try { uploadStatus.value = UPLOAD_STATUS.UPLOADING - currentFile.value = file uploadedCode.value = '' const response = await FileService.uploadFile(file, (progress) => { uploadProgress.value = progress + onProgress?.(progress) }) if (response.code === 200 && response.detail?.code) { @@ -66,6 +131,37 @@ export function useFileUpload() { } } + /** + * 预签名上传方式 + */ + const uploadFileWithPresigned = async ( + file: File, + uploadOptions?: FileUploadOptions + ): Promise => { + // 构建预签名上传选项 + const presignOptions: PresignUploadOptions = { + expireValue: uploadOptions?.expireValue, + expireStyle: uploadOptions?.expireStyle, + onProgress: (progress) => { + // 同步进度到本 composable 的状态 + uploadProgress.value = progress + uploadOptions?.onProgress?.(progress) + } + } + + const result = await presignedUpload.uploadFile(file, presignOptions) + + if (result) { + // 同步预签名上传的结果到本 composable 的状态 + uploadedCode.value = result + uploadStatus.value = UPLOAD_STATUS.SUCCESS + } else { + uploadStatus.value = UPLOAD_STATUS.ERROR + } + + return result + } + // 上传文本 const uploadText = async (text: string): Promise => { if (!text.trim()) { @@ -105,6 +201,22 @@ export function useFileUpload() { } uploadedCode.value = '' currentFile.value = null + + // 如果使用预签名上传,也重置预签名状态 + if (isUsingPresigned.value) { + presignedUpload.reset() + } + isUsingPresigned.value = false + } + + /** + * 取消上传 + */ + const cancelUpload = async (): Promise => { + if (isUsingPresigned.value) { + await presignedUpload.cancelUpload() + } + resetUpload() } // 格式化文件大小 @@ -122,6 +234,13 @@ export function useFileUpload() { uploadProgress: readonly(uploadProgress), uploadedCode: readonly(uploadedCode), currentFile: readonly(currentFile), + isUsingPresigned: readonly(isUsingPresigned), + + // 预签名上传相关状态(透传) + presignStatus: presignedUpload.presignStatus, + uploadSession: presignedUpload.uploadSession, + currentMode: presignedUpload.currentMode, + presignErrorMessage: presignedUpload.errorMessage, // 计算属性 isUploading, @@ -129,11 +248,19 @@ export function useFileUpload() { isError, isIdle, + // 预签名上传计算属性(透传) + isInitializing: presignedUpload.isInitializing, + isConfirming: presignedUpload.isConfirming, + // 方法 uploadFile, uploadText, resetUpload, + cancelUpload, validateFile, - formatFileSize + formatFileSize, + + // 预签名上传方法(透传) + getPresignStatus: presignedUpload.getStatus } } \ No newline at end of file diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts new file mode 100644 index 0000000..b121a7e --- /dev/null +++ b/src/composables/usePresignedUpload.ts @@ -0,0 +1,357 @@ +import { ref, computed, readonly } from 'vue' +import { PresignUploadService } from '@/services' +import { useAlertStore } from '@/stores/alertStore' +import { FILE_SIZE_LIMITS } from '@/constants' +import type { + PresignUploadStatus, + PresignUploadMode, + PresignInitResponse, + PresignUploadOptions, + PresignStatusResponse, + UploadProgress, + ExpireStyle +} from '@/types' +import axios from 'axios' + +// 预签名上传状态常量 +export const PRESIGN_UPLOAD_STATUS = { + IDLE: 'idle', + INITIALIZING: 'initializing', + UPLOADING: 'uploading', + CONFIRMING: 'confirming', + SUCCESS: 'success', + ERROR: 'error' +} as const + +// 默认过期配置 +const DEFAULT_EXPIRE_VALUE = 1 +const DEFAULT_EXPIRE_STYLE: ExpireStyle = 'day' + +/** + * 预签名上传 Composable + * 支持 S3 直传模式和服务器代理模式 + */ +export function usePresignedUpload() { + const alertStore = useAlertStore() + + // 状态管理 + const presignStatus = ref(PRESIGN_UPLOAD_STATUS.IDLE) + const uploadSession = ref(null) + const uploadProgress = ref({ + loaded: 0, + total: 0, + percentage: 0 + }) + const uploadedCode = ref('') + const errorMessage = ref('') + + // 计算属性 + const isInitializing = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.INITIALIZING) + const isUploading = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.UPLOADING) + const isConfirming = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.CONFIRMING) + const isSuccess = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.SUCCESS) + const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR) + const currentMode = computed(() => uploadSession.value?.mode ?? null) + + + /** + * 文件大小验证 + */ + const validateFileSize = (file: File): boolean => { + if (file.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) { + const maxSizeMB = Math.round(FILE_SIZE_LIMITS.MAX_FILE_SIZE / 1024 / 1024) + errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB` + alertStore.showAlert(errorMessage.value, 'error') + return false + } + return true + } + + /** + * 进度追踪回调 + */ + const createProgressHandler = ( + externalHandler?: (progress: UploadProgress) => void + ): ((progress: UploadProgress) => void) => { + return (progress: UploadProgress) => { + // 确保进度值在有效范围内 + uploadProgress.value = { + loaded: Math.max(0, progress.loaded), + total: Math.max(0, progress.total), + percentage: Math.min(100, Math.max(0, progress.percentage)) + } + // 调用外部进度回调 + if (externalHandler) { + externalHandler(uploadProgress.value) + } + } + } + + /** + * 错误处理 + */ + const handleUploadError = (error: unknown): void => { + presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR + + if (axios.isAxiosError(error)) { + const status = error.response?.status + const detail = error.response?.data?.detail + + switch (status) { + case 400: + errorMessage.value = detail || '请求参数错误' + break + case 403: + errorMessage.value = detail || '操作被禁止' + break + case 404: + errorMessage.value = '上传会话不存在或已过期' + break + case 500: + errorMessage.value = '服务器错误,请稍后重试' + break + default: + errorMessage.value = detail || '上传失败,请重试' + } + } else if (error instanceof Error) { + errorMessage.value = error.message + } else { + errorMessage.value = '未知错误' + } + + alertStore.showAlert(errorMessage.value, 'error') + } + + + /** + * 初始化上传会话 + */ + const initUploadSession = async ( + file: File, + options?: PresignUploadOptions + ): Promise => { + try { + presignStatus.value = PRESIGN_UPLOAD_STATUS.INITIALIZING + + const response = await PresignUploadService.initUpload({ + file_name: file.name, + file_size: file.size, + expire_value: options?.expireValue ?? DEFAULT_EXPIRE_VALUE, + expire_style: options?.expireStyle ?? DEFAULT_EXPIRE_STYLE + }) + + if (response.code === 200 && response.detail) { + uploadSession.value = response.detail + return response.detail + } else { + throw new Error(response.message || '初始化上传失败') + } + } catch (error) { + handleUploadError(error) + return null + } + } + + /** + * 直传模式上传到 S3 + */ + const uploadDirect = async ( + file: File, + session: PresignInitResponse, + options?: PresignUploadOptions + ): Promise => { + try { + presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING + + // 上传到 S3 + const progressHandler = createProgressHandler(options?.onProgress) + const uploadSuccess = await PresignUploadService.directUploadToS3( + session.upload_url, + file, + progressHandler + ) + + if (!uploadSuccess) { + throw new Error('S3 上传失败') + } + + // 确认上传 + presignStatus.value = PRESIGN_UPLOAD_STATUS.CONFIRMING + const confirmResponse = await PresignUploadService.confirmUpload(session.upload_id, { + expire_value: options?.expireValue ?? DEFAULT_EXPIRE_VALUE, + expire_style: options?.expireStyle ?? DEFAULT_EXPIRE_STYLE + }) + + if (confirmResponse.code === 200 && confirmResponse.detail?.code) { + presignStatus.value = PRESIGN_UPLOAD_STATUS.SUCCESS + uploadedCode.value = String(confirmResponse.detail.code) + // 确保进度为 100% + uploadProgress.value = { + loaded: file.size, + total: file.size, + percentage: 100 + } + alertStore.showAlert('文件上传成功!', 'success') + return uploadedCode.value + } else { + throw new Error(confirmResponse.message || '确认上传失败') + } + } catch (error) { + handleUploadError(error) + return null + } + } + + + /** + * 代理模式上传 + */ + const uploadProxy = async ( + file: File, + session: PresignInitResponse, + options?: PresignUploadOptions + ): Promise => { + try { + presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING + + const progressHandler = createProgressHandler(options?.onProgress) + const response = await PresignUploadService.proxyUpload( + session.upload_id, + file, + progressHandler + ) + + if (response.code === 200 && response.detail?.code) { + presignStatus.value = PRESIGN_UPLOAD_STATUS.SUCCESS + uploadedCode.value = String(response.detail.code) + // 确保进度为 100% + uploadProgress.value = { + loaded: file.size, + total: file.size, + percentage: 100 + } + alertStore.showAlert('文件上传成功!', 'success') + return uploadedCode.value + } else { + throw new Error(response.message || '代理上传失败') + } + } catch (error) { + handleUploadError(error) + return null + } + } + + /** + * 上传文件(主入口方法) + * 根据 mode 自动选择直传或代理上传 + */ + const uploadFile = async ( + file: File, + options?: PresignUploadOptions + ): Promise => { + // 重置状态 + reset() + + // 验证文件大小 + if (!validateFileSize(file)) { + presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR + return null + } + + // 初始化上传会话 + const session = await initUploadSession(file, options) + if (!session) { + return null + } + + // 根据模式选择上传方式 + if (session.mode === 'direct') { + return await uploadDirect(file, session, options) + } else { + return await uploadProxy(file, session, options) + } + } + + + /** + * 取消上传 + */ + const cancelUpload = async (): Promise => { + if (!uploadSession.value?.upload_id) { + return + } + + try { + await PresignUploadService.cancelUpload(uploadSession.value.upload_id) + alertStore.showAlert('上传已取消', 'info') + } catch (error) { + // 取消失败时静默处理,因为会话可能已过期 + console.warn('取消上传失败:', error) + } finally { + reset() + } + } + + /** + * 获取上传状态 + */ + const getStatus = async (): Promise => { + if (!uploadSession.value?.upload_id) { + return null + } + + try { + const response = await PresignUploadService.getUploadStatus(uploadSession.value.upload_id) + if (response.code === 200 && response.detail) { + // 检查会话是否过期 + if (response.detail.is_expired) { + errorMessage.value = '上传会话已过期' + alertStore.showAlert(errorMessage.value, 'warning') + } + return response.detail + } + return null + } catch (error) { + handleUploadError(error) + return null + } + } + + /** + * 重置状态 + */ + const reset = (): void => { + presignStatus.value = PRESIGN_UPLOAD_STATUS.IDLE + uploadSession.value = null + uploadProgress.value = { + loaded: 0, + total: 0, + percentage: 0 + } + uploadedCode.value = '' + errorMessage.value = '' + } + + return { + // 状态 (只读) + presignStatus: readonly(presignStatus), + uploadSession: readonly(uploadSession), + uploadProgress: readonly(uploadProgress), + uploadedCode: readonly(uploadedCode), + errorMessage: readonly(errorMessage), + + // 计算属性 + isInitializing, + isUploading, + isConfirming, + isSuccess, + isError, + currentMode, + + // 方法 + uploadFile, + cancelUpload, + getStatus, + reset + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 536296c..774f02a 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,7 +1,8 @@ // API 服务层 import api from '@/utils/api' +import axios from 'axios' import type { ApiResponse, FileInfo, ConfigState, AdminUser, FileUploadResponse, TextSendResponse, DashboardData, FileListResponse, FileEditForm } from '@/types' -import type { UploadProgress } from '@/types' +import type { UploadProgress, PresignInitRequest, PresignInitResponse, PresignConfirmRequest, PresignUploadResult, PresignStatusResponse } from '@/types' // 系统配置服务 export class ConfigService { @@ -128,12 +129,105 @@ export class StatsService { } } +// 预签名上传服务 +export class PresignUploadService { + /** + * 初始化上传会话 + */ + static async initUpload(request: PresignInitRequest): Promise> { + return api.post('/presign/upload/init', request) + } + + /** + * 代理模式上传 + */ + static async proxyUpload( + uploadId: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('file', file) + + return api.put(`/presign/upload/proxy/${uploadId}`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress: UploadProgress = { + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + } + onProgress(progress) + } + } + }) + } + + /** + * 确认直传上传 + */ + static async confirmUpload( + uploadId: string, + request?: PresignConfirmRequest + ): Promise> { + return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) + } + + /** + * 查询上传状态 + */ + static async getUploadStatus(uploadId: string): Promise> { + return api.get(`/presign/upload/status/${uploadId}`) + } + + /** + * 取消上传 + */ + static async cancelUpload(uploadId: string): Promise> { + return api.delete(`/presign/upload/${uploadId}`) + } + + /** + * S3 直传(不经过后端) + */ + static async directUploadToS3( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise { + try { + await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': 'application/octet-stream' + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress: UploadProgress = { + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + } + onProgress(progress) + } + } + }) + return true + } catch { + return false + } + } +} + // 导出所有服务 export const services = { config: ConfigService, file: FileService, auth: AuthService, - stats: StatsService + stats: StatsService, + presignUpload: PresignUploadService } export default services \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 812f3fd..6dfd698 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -177,4 +177,68 @@ export interface RouteConfig { requiresAuth?: boolean title?: string } -} \ No newline at end of file +} + + +// ==================== 预签名上传相关类型 ==================== + +// 预签名上传模式 +export type PresignUploadMode = 'direct' | 'proxy' + +// 预签名上传状态 +export type PresignUploadStatus = + | 'idle' + | 'initializing' + | 'uploading' + | 'confirming' + | 'success' + | 'error' + +// 过期类型 +export type ExpireStyle = 'day' | 'hour' | 'minute' | 'forever' | 'count' + +// 初始化上传请求 +export interface PresignInitRequest { + file_name: string + file_size: number + expire_value?: number + expire_style?: ExpireStyle +} + +// 初始化上传响应 +export interface PresignInitResponse { + upload_id: string + upload_url: string + mode: PresignUploadMode + expires_in: number +} + +// 确认上传请求 +export interface PresignConfirmRequest { + expire_value?: number + expire_style?: ExpireStyle +} + +// 上传结果 +export interface PresignUploadResult { + code: string + name: string +} + +// 上传状态查询响应 +export interface PresignStatusResponse { + upload_id: string + file_name: string + file_size: number + mode: PresignUploadMode + created_at: string + expires_at: string + is_expired: boolean +} + +// 上传配置选项 +export interface PresignUploadOptions { + expireValue?: number + expireStyle?: ExpireStyle + onProgress?: (progress: UploadProgress) => void +} diff --git a/src/views/SendFileView.vue b/src/views/SendFileView.vue index b4e00af..1f08d87 100644 --- a/src/views/SendFileView.vue +++ b/src/views/SendFileView.vue @@ -511,6 +511,8 @@ import PageHeader from '@/components/common/PageHeader.vue' import SendTypeSelector from '@/components/common/SendTypeSelector.vue' import FileUploadArea from '@/components/common/FileUploadArea.vue' import TextInputArea from '@/components/common/TextInputArea.vue' +import { usePresignedUpload } from '@/composables/usePresignedUpload' +import type { ExpireStyle } from '@/types' interface Config { name: string @@ -552,6 +554,14 @@ const { t } = useI18n() const alertStore = useAlertStore() const sendRecords = computed(() => fileDataStore.shareData) +// 预签名上传 +const { + presignStatus, + uploadProgress: presignProgress, + uploadFile: presignUploadFile, + reset: resetPresignUpload +} = usePresignedUpload() + const fileHash = ref('') @@ -891,6 +901,28 @@ const handleDefaultFileUpload = async (file: File) => { const response: ApiResponse<{ code?: string; name?: string }> = await api.post('share/file/', formData, config) return response } + +// 预签名上传处理 +const handlePresignedUpload = async (file: File) => { + const code = await presignUploadFile(file, { + expireValue: expirationValue.value ? parseInt(expirationValue.value) : 1, + expireStyle: expirationMethod.value as ExpireStyle, + onProgress: (progress) => { + uploadProgress.value = progress.percentage + } + }) + + if (code) { + return { + code: 200, + detail: { + code: code, + name: file.name + } + } as ApiResponse<{ code?: string; name?: string }> + } + throw new Error(t('send.messages.uploadFailed')) +} const checkOpenUpload = () => { if (config.openUpload === 0 && localStorage.getItem('token') === null) { alertStore.showAlert(t('send.messages.guestUploadDisabled'), 'error') @@ -963,11 +995,16 @@ const handleSubmit = async () => { let response: ApiResponse if (sendType.value === 'file') { - // 使用切片上传替代原来的直接上传 - if (config.enableChunk) { - response = await handleChunkUpload(selectedFile.value!) - } else { - response = await handleDefaultFileUpload(selectedFile.value!) + // 优先使用预签名上传,如果失败则回退到其他方式 + try { + response = await handlePresignedUpload(selectedFile.value!) + } catch { + // 预签名上传失败,回退到切片上传或默认上传 + if (config.enableChunk) { + response = await handleChunkUpload(selectedFile.value!) + } else { + response = await handleDefaultFileUpload(selectedFile.value!) + } } } else { // 文本上传保持不变 @@ -1008,6 +1045,7 @@ const handleSubmit = async () => { selectedFile.value = null textContent.value = '' uploadProgress.value = 0 + resetPresignUpload() // 显示详情 selectedRecord.value = newRecord // 自动复制取件码链接 From fec85d63ac8aea42167f46d6e8df9310257d406d Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 31 Dec 2025 03:35:19 +0800 Subject: [PATCH 03/66] fix: https://github.com/vastsa/FileCodeBox/issues/446 --- src/i18n/locales/en-US.ts | 302 +++++++++++++++++++------------------- src/i18n/locales/zh-CN.ts | 250 +++++++++++++++---------------- 2 files changed, 276 insertions(+), 276 deletions(-) diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 8aeec94..cb5313c 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -71,77 +71,77 @@ export default { }, settings: { title: 'System Settings', - basicSettings: 'Basic Settings', - websiteInfo: 'Website Basic Information', - websiteName: 'Website Name', - websiteDescription: 'Website Description', + basicSettings: 'General', + websiteInfo: 'Site Information', + websiteName: 'Site Name', + websiteDescription: 'Site Description', siteName: 'Site Name', adminPassword: 'Admin Password', - passwordPlaceholder: 'Leave blank to keep current password', - passwordNote: 'Leave blank to keep unchanged', - keywords: 'Keywords', - themeSelection: 'Theme Selection', - robotsFile: 'Robots.txt', - notificationSettings: 'Notification Settings', - notificationTitle: 'Notification Title', - notificationContent: 'Notification Content', - storageSettings: 'Storage Settings', + passwordPlaceholder: 'Enter new password to change', + passwordNote: 'Leave empty to keep current', + keywords: 'SEO Keywords', + themeSelection: 'Theme', + robotsFile: 'Robots.txt Configuration', + notificationSettings: 'Notifications', + notificationTitle: 'Announcement Title', + notificationContent: 'Announcement Content', + storageSettings: 'Storage', storagePath: 'Storage Path', - storagePathPlaceholder: 'Leave blank to use default path, optional', - storageMethod: 'Storage Method', + storagePathPlaceholder: 'Leave empty for default path', + storageMethod: 'Storage Type', localStorage: 'Local Storage', - s3Storage: 'S3 Storage', - webdavStorage: 'Webdav Storage', - chunkUploadNote: 'Enable chunk upload (experimental feature, may have unknown issues)', - s3AccessKeyId: 'S3 AccessKeyId', - s3SecretAccessKey: 'S3 SecretAccessKey', - s3BucketName: 'S3 BucketName', - s3EndpointUrl: 'S3 EndpointUrl', - s3RegionName: 'S3 Region Name', - s3SignatureVersion: 'S3 Signature Version', - s3Hostname: 'S3 Hostname', + s3Storage: 'S3 Object Storage', + webdavStorage: 'WebDAV Storage', + chunkUploadNote: 'Chunked Upload (experimental, better for large files)', + s3AccessKeyId: 'Access Key ID', + s3SecretAccessKey: 'Secret Access Key', + s3BucketName: 'Bucket Name', + s3EndpointUrl: 'Endpoint URL', + s3RegionName: 'Region Name', + s3SignatureVersion: 'Signature Version', + s3Hostname: 'Custom Domain', s3v2: 'S3v2', s3v4: 'S3v4', - autoPlaceholder: 'auto', + autoPlaceholder: 'Auto-detect', enabled: 'Enabled', disabled: 'Disabled', - webdavUrl: 'Please enter Webdav URL', - webdavUsername: 'Please enter Webdav Username', - webdavPassword: 'Please enter Webdav Password', - webdavUrlPlaceholder: 'Please enter Webdav URL', - webdavUsernamePlaceholder: 'Please enter Webdav Username', - webdavPasswordPlaceholder: 'Please enter Webdav Password', - enableProxy: 'Enable Proxy', - uploadLimits: 'Upload Limits', - uploadRateLimit: 'Upload Rate Limit', - uploadPerMinute: 'Upload Per Minute', - minute: 'minute', - uploadCountLimit: 'Upload Count Limit', + webdavUrl: 'Enter WebDAV server URL', + webdavUsername: 'Enter WebDAV username', + webdavPassword: 'Enter WebDAV password', + webdavUrlPlaceholder: 'Enter WebDAV server URL', + webdavUsernamePlaceholder: 'Enter WebDAV username', + webdavPasswordPlaceholder: 'Enter WebDAV password', + enableProxy: 'Enable Download Proxy', + uploadLimits: 'Upload Settings', + uploadRateLimit: 'Time Window (limit uploads within this period)', + uploadPerMinute: 'Time Window (limit uploads within this period)', + minute: 'min', + uploadCountLimit: 'Max Files Allowed (within the time window)', files: 'files', - fileSizeLimit: 'File Size Limit', - expirationMethod: 'Expiration Method', - expirationType: 'Expiration Type', + fileSizeLimit: 'Max File Size', + expirationMethod: 'Expiration Options', + expirationType: 'Expiration Options', expiration: { - day: 'By Day', - hour: 'By Hour', - minute: 'By Minute', - forever: 'Forever', - count: 'By Count' + day: 'Days', + hour: 'Hours', + minute: 'Minutes', + forever: 'Never Expire', + count: 'Download Count' }, - maxSaveTime: 'Maximum Save Time', + maxSaveTime: 'Max Retention Period', timeUnits: { - second: 'second', - minute: 'minute', - hour: 'hour', + second: 'sec', + minute: 'min', + hour: 'hr', day: 'day' }, - guestUpload: 'Guest Upload', - errorLimits: 'Error Limits', - errorRateLimit: 'Error Rate Limit', - errorPerMinute: 'Error Per Minute', - errorCountLimit: 'Error Count Limit', + guestUpload: 'Allow Guest Uploads', + errorLimits: 'Access Protection', + errorRateLimit: 'Detection Window (count errors within this period)', + errorPerMinute: 'Detection Window (count errors within this period)', + errorCountLimit: 'Max Errors Allowed (block after exceeding)', times: 'times', - saveChanges: 'Save Changes', + saveChanges: 'Save Settings', fileSizeUnits: { kb: 'KB', mb: 'MB', @@ -314,137 +314,137 @@ export default { manage: { settings: { title: 'System Settings', - basicSettings: 'Basic Settings', - websiteInfo: 'Website Basic Information', - websiteName: 'Website Name', - websiteDescription: 'Website Description', + basicSettings: 'General', + websiteInfo: 'Site Information', + websiteName: 'Site Name', + websiteDescription: 'Site Description', siteName: 'Site Name', adminPassword: 'Admin Password', - passwordPlaceholder: 'Leave blank to keep current password', - passwordNote: 'Leave blank to keep unchanged', - keywords: 'Keywords', - themeSelection: 'Theme Selection', - notificationSettings: 'Notification Settings', - notificationTitle: 'Notification Title', - notificationContent: 'Notification Content', - storageSettings: 'Storage Settings', + passwordPlaceholder: 'Enter new password to change', + passwordNote: 'Leave empty to keep current', + keywords: 'SEO Keywords', + themeSelection: 'Theme', + notificationSettings: 'Notifications', + notificationTitle: 'Announcement Title', + notificationContent: 'Announcement Content', + storageSettings: 'Storage', storagePath: 'Storage Path', - storagePathPlaceholder: 'Leave blank to use default path, optional', - storageMethod: 'Storage Method', + storagePathPlaceholder: 'Leave empty for default path', + storageMethod: 'Storage Type', localStorage: 'Local Storage', - s3Storage: 'S3 Storage', - webdavStorage: 'Webdav Storage', - chunkUploadNote: 'Enable chunk upload (experimental feature, may have unknown issues)', + s3Storage: 'S3 Object Storage', + webdavStorage: 'WebDAV Storage', + chunkUploadNote: 'Chunked Upload (experimental, better for large files)', enabled: 'Enabled', disabled: 'Disabled', - webdavUrl: 'Please enter Webdav URL', - webdavUsername: 'Please enter Webdav Username', - webdavPassword: 'Please enter Webdav Password', - webdavUrlPlaceholder: 'Please enter Webdav URL', - webdavUsernamePlaceholder: 'Please enter Webdav Username', - webdavPasswordPlaceholder: 'Please enter Webdav Password', - enableProxy: 'Enable Proxy', - uploadLimits: 'Upload Limits', - uploadRateLimit: 'Upload Rate Limit', - uploadPerMinute: 'Upload Per Minute', - minute: 'minute', - uploadCountLimit: 'Upload Count Limit', + webdavUrl: 'Enter WebDAV server URL', + webdavUsername: 'Enter WebDAV username', + webdavPassword: 'Enter WebDAV password', + webdavUrlPlaceholder: 'Enter WebDAV server URL', + webdavUsernamePlaceholder: 'Enter WebDAV username', + webdavPasswordPlaceholder: 'Enter WebDAV password', + enableProxy: 'Enable Download Proxy', + uploadLimits: 'Upload Settings', + uploadRateLimit: 'Time Window (limit uploads within this period)', + uploadPerMinute: 'Time Window (limit uploads within this period)', + minute: 'min', + uploadCountLimit: 'Max Files Allowed (within the time window)', files: 'files', - fileSizeLimit: 'File Size Limit', - expirationMethod: 'Expiration Method', - expirationType: 'Expiration Type', + fileSizeLimit: 'Max File Size', + expirationMethod: 'Expiration Options', + expirationType: 'Expiration Options', expiration: { - day: 'By Day', - hour: 'By Hour', - minute: 'By Minute', - forever: 'Forever', - count: 'By Count' + day: 'Days', + hour: 'Hours', + minute: 'Minutes', + forever: 'Never Expire', + count: 'Download Count' }, - maxSaveTime: 'Maximum Save Time', + maxSaveTime: 'Max Retention Period', timeUnits: { - second: 'second', - minute: 'minute', - hour: 'hour', + second: 'sec', + minute: 'min', + hour: 'hr', day: 'day' }, - guestUpload: 'Guest Upload', - errorLimits: 'Error Limits', - errorRateLimit: 'Error Rate Limit', - errorPerMinute: 'Error Per Minute', - errorCountLimit: 'Error Count Limit', + guestUpload: 'Allow Guest Uploads', + errorLimits: 'Access Protection', + errorRateLimit: 'Detection Window (count errors within this period)', + errorPerMinute: 'Detection Window (count errors within this period)', + errorCountLimit: 'Max Errors Allowed (block after exceeding)', times: 'times', fileSizeUnits: { kb: 'KB', mb: 'MB', gb: 'GB' }, - saveChanges: 'Save Changes' + saveChanges: 'Save Settings' }, systemSettings: { title: 'System Settings', - basicSettings: 'Basic Settings', - websiteInfo: 'Website Basic Information', - websiteName: 'Website Name', - websiteDescription: 'Website Description', + basicSettings: 'General', + websiteInfo: 'Site Information', + websiteName: 'Site Name', + websiteDescription: 'Site Description', adminPassword: 'Admin Password', - passwordPlaceholder: 'Leave blank to keep current password', - passwordNote: 'Leave blank to keep unchanged', - keywords: 'Keywords', - themeSelection: 'Theme Selection', - notificationSettings: 'Notification Settings', - notificationTitle: 'Notification Title', - notificationContent: 'Notification Content', - storageSettings: 'Storage Settings', + passwordPlaceholder: 'Enter new password to change', + passwordNote: 'Leave empty to keep current', + keywords: 'SEO Keywords', + themeSelection: 'Theme', + notificationSettings: 'Notifications', + notificationTitle: 'Announcement Title', + notificationContent: 'Announcement Content', + storageSettings: 'Storage', storagePath: 'Storage Path', - storagePathPlaceholder: 'Leave blank to use default path, optional', - storageMethod: 'Storage Method', + storagePathPlaceholder: 'Leave empty for default path', + storageMethod: 'Storage Type', localStorage: 'Local Storage', - s3Storage: 'S3 Storage', - webdavStorage: 'Webdav Storage', - chunkUploadNote: 'Enable chunk upload (experimental feature, may have unknown issues)', + s3Storage: 'S3 Object Storage', + webdavStorage: 'WebDAV Storage', + chunkUploadNote: 'Chunked Upload (experimental, better for large files)', enabled: 'Enabled', disabled: 'Disabled', - webdavUrl: 'Please enter Webdav URL', - webdavUsername: 'Please enter Webdav Username', - webdavPassword: 'Please enter Webdav Password', - enableProxy: 'Enable Proxy', - uploadLimits: 'Upload Limits', - uploadRateLimit: 'Upload Rate Limit', - minute: 'minute', - uploadCountLimit: 'Upload Count Limit', + webdavUrl: 'Enter WebDAV server URL', + webdavUsername: 'Enter WebDAV username', + webdavPassword: 'Enter WebDAV password', + enableProxy: 'Enable Download Proxy', + uploadLimits: 'Upload Settings', + uploadRateLimit: 'Time Window (limit uploads within this period)', + minute: 'min', + uploadCountLimit: 'Max Files Allowed (within the time window)', files: 'files', - fileSizeLimit: 'File Size Limit', - expirationMethod: 'Expiration Method', + fileSizeLimit: 'Max File Size', + expirationMethod: 'Expiration Options', expirationMethods: { - day: 'By Day', - hour: 'By Hour', - minute: 'By Minute', - forever: 'Forever', - count: 'By Count' + day: 'Days', + hour: 'Hours', + minute: 'Minutes', + forever: 'Never Expire', + count: 'Download Count' }, expiration: { - day: 'By Day', - hour: 'By Hour', - minute: 'By Minute', - forever: 'Forever', - count: 'By Count' + day: 'Days', + hour: 'Hours', + minute: 'Minutes', + forever: 'Never Expire', + count: 'Download Count' }, - maxSaveTime: 'Maximum Save Time', + maxSaveTime: 'Max Retention Period', timeUnits: { - second: 'second', - minute: 'minute', - hour: 'hour', + second: 'sec', + minute: 'min', + hour: 'hr', day: 'day' }, - guestUpload: 'Guest Upload', - errorLimits: 'Error Limits', - errorRateLimit: 'Error Rate Limit', - errorCountLimit: 'Error Count Limit', + guestUpload: 'Allow Guest Uploads', + errorLimits: 'Access Protection', + errorRateLimit: 'Detection Window (count errors within this period)', + errorCountLimit: 'Max Errors Allowed (block after exceeding)', times: 'times', saveSettings: 'Save Settings', - saveSuccess: 'Save successful', - saveFailed: 'Save failed', - getConfigFailed: 'Failed to get configuration' + saveSuccess: 'Settings saved successfully', + saveFailed: 'Failed to save, please try again', + getConfigFailed: 'Failed to load settings, please refresh' }, dashboard: { title: 'Dashboard', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 5fca9d5..e935e6e 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -76,71 +76,71 @@ export default { websiteDescription: '网站描述', siteName: '网站名称', adminPassword: '管理员密码', - passwordPlaceholder: '留空则不修改密码', - passwordNote: '留空则不修改', - keywords: '关键词', - themeSelection: '主题选择', - robotsFile: 'Robots.txt', + passwordPlaceholder: '如需修改请输入新密码', + passwordNote: '不修改请留空', + keywords: 'SEO 关键词', + themeSelection: '界面主题', + robotsFile: 'Robots.txt 配置', notificationSettings: '通知设置', - notificationTitle: '通知标题', - notificationContent: '通知内容', - storageSettings: '存储设置', - storagePath: '存储路径', - storagePathPlaceholder: '留空则使用默认路径,可不填写', + notificationTitle: '公告标题', + notificationContent: '公告内容', + storageSettings: '存储配置', + storagePath: '文件存储路径', + storagePathPlaceholder: '使用默认路径可留空', storageMethod: '存储方式', localStorage: '本地存储', - s3Storage: 'S3 存储', - webdavStorage: 'Webdav 存储', - chunkUploadNote: '开启切片上传(实验性功能,可能会出现未知问题)', - s3AccessKeyId: 'S3 AccessKeyId', - s3SecretAccessKey: 'S3 SecretAccessKey', - s3BucketName: 'S3 BucketName', - s3EndpointUrl: 'S3 EndpointUrl', - s3RegionName: 'S3 Region Name', - s3SignatureVersion: 'S3 Signature Version', - s3Hostname: 'S3 Hostname', + s3Storage: 'S3 对象存储', + webdavStorage: 'WebDAV 存储', + chunkUploadNote: '分片上传(实验性功能,大文件上传更稳定)', + s3AccessKeyId: 'Access Key ID', + s3SecretAccessKey: 'Secret Access Key', + s3BucketName: '存储桶名称', + s3EndpointUrl: '服务端点 URL', + s3RegionName: '区域名称', + s3SignatureVersion: '签名版本', + s3Hostname: '自定义域名', s3v2: 'S3v2', s3v4: 'S3v4', - autoPlaceholder: 'auto', - enabled: '已开启', - disabled: '已关闭', - webdavUrl: '请输入 Webdav URL', - webdavUsername: '请输入 Webdav Username', - webdavPassword: '请输入 Webdav Password', - webdavUrlPlaceholder: '请输入 Webdav URL', - webdavUsernamePlaceholder: '请输入 Webdav Username', - webdavPasswordPlaceholder: '请输入 Webdav Password', - enableProxy: '启用代理', - uploadLimits: '上传限制', - uploadRateLimit: '每分钟上传限制', - uploadPerMinute: '每分钟上传限制', + autoPlaceholder: '自动识别', + enabled: '已启用', + disabled: '已禁用', + webdavUrl: '输入 WebDAV 服务地址', + webdavUsername: '输入 WebDAV 用户名', + webdavPassword: '输入 WebDAV 密码', + webdavUrlPlaceholder: '输入 WebDAV 服务地址', + webdavUsernamePlaceholder: '输入 WebDAV 用户名', + webdavPasswordPlaceholder: '输入 WebDAV 密码', + enableProxy: '启用下载代理', + uploadLimits: '上传设置', + uploadRateLimit: '限流时间窗口(在此时间内限制上传次数)', + uploadPerMinute: '限流时间窗口(在此时间内限制上传次数)', minute: '分钟', - uploadCountLimit: '上传数量限制', + uploadCountLimit: '允许上传文件数(限流窗口内最多上传几个文件)', files: '个文件', - fileSizeLimit: '文件大小限制', + fileSizeLimit: '单文件大小上限', expirationMethod: '过期方式', - expirationType: '过期类型', + expirationType: '过期方式', expiration: { - day: '按天', + day: '按天数', hour: '按小时', minute: '按分钟', - forever: '永久', - count: '按次数' + forever: '永久有效', + count: '按下载次数' }, - maxSaveTime: '最长保存时间', + maxSaveTime: '文件最长保存时间', timeUnits: { second: '秒', - minute: '分', - hour: '时', + minute: '分钟', + hour: '小时', day: '天' }, - guestUpload: '游客上传', - errorLimits: '错误限制', - errorRateLimit: '每分钟错误限制', - errorPerMinute: '每分钟错误限制', - errorCountLimit: '错误次数限制', + guestUpload: '允许游客上传', + errorLimits: '访问保护', + errorRateLimit: '检测时间窗口(在此时间内统计错误次数)', + errorPerMinute: '检测时间窗口(在此时间内统计错误次数)', + errorCountLimit: '允许错误次数(超过后临时封禁)', times: '次', - saveChanges: '保存更改', + saveChanges: '保存设置', fileSizeUnits: { kb: 'KB', mb: 'MB', @@ -356,59 +356,59 @@ export default { basicSettings: '基本设置', siteName: '网站名称', adminPassword: '管理员密码', - passwordPlaceholder: '留空则不修改密码', - passwordNote: '留空则不修改', - keywords: '关键词', - themeSelection: '主题选择', - robotsFile: 'Robots.txt', + passwordPlaceholder: '如需修改请输入新密码', + passwordNote: '不修改请留空', + keywords: 'SEO 关键词', + themeSelection: '界面主题', + robotsFile: 'Robots.txt 配置', notificationSettings: '通知设置', - notificationTitle: '通知标题', - notificationContent: '通知内容', - storageSettings: '存储设置', - storagePath: '存储路径', - storagePathPlaceholder: '留空则使用默认路径,可不填写', + notificationTitle: '公告标题', + notificationContent: '公告内容', + storageSettings: '存储配置', + storagePath: '文件存储路径', + storagePathPlaceholder: '使用默认路径可留空', storageMethod: '存储方式', localStorage: '本地存储', - s3Storage: 'S3 存储', - webdavStorage: 'Webdav 存储', - chunkUploadNote: '开启切片上传(实验性功能,可能会出现未知问题)', - uploadLimits: '上传限制', - uploadPerMinute: '每分钟上传限制', - uploadCountLimit: '上传数量限制', - fileSizeLimit: '文件大小限制', + s3Storage: 'S3 对象存储', + webdavStorage: 'WebDAV 存储', + chunkUploadNote: '分片上传(实验性功能,大文件上传更稳定)', + uploadLimits: '上传设置', + uploadPerMinute: '限流时间窗口(在此时间内限制上传次数)', + uploadCountLimit: '允许上传文件数(限流窗口内最多上传几个文件)', + fileSizeLimit: '单文件大小上限', expiration: { - day: '按天', + day: '按天数', hour: '按小时', minute: '按分钟', - forever: '永久', - count: '按次数' + forever: '永久有效', + count: '按下载次数' }, - maxSaveTime: '最长保存时间', - s3AccessKeyId: 'S3 AccessKeyId', - s3SecretAccessKey: 'S3 SecretAccessKey', - s3BucketName: 'S3 BucketName', - s3EndpointUrl: 'S3 EndpointUrl', - s3RegionName: 'S3 Region Name', - s3SignatureVersion: 'S3 Signature Version', - s3Hostname: 'S3 Hostname', + maxSaveTime: '文件最长保存时间', + s3AccessKeyId: 'Access Key ID', + s3SecretAccessKey: 'Secret Access Key', + s3BucketName: '存储桶名称', + s3EndpointUrl: '服务端点 URL', + s3RegionName: '区域名称', + s3SignatureVersion: '签名版本', + s3Hostname: '自定义域名', s3v2: 'S3v2', s3v4: 'S3v4', - autoPlaceholder: 'auto', - enableProxy: '启用代理', - webdavUrlPlaceholder: '请输入 Webdav URL', - webdavUsernamePlaceholder: '请输入 Webdav Username', - webdavPasswordPlaceholder: '请输入 Webdav Password', + autoPlaceholder: '自动识别', + enableProxy: '启用下载代理', + webdavUrlPlaceholder: '输入 WebDAV 服务地址', + webdavUsernamePlaceholder: '输入 WebDAV 用户名', + webdavPasswordPlaceholder: '输入 WebDAV 密码', fileSizeUnits: { kb: 'KB', mb: 'MB', gb: 'GB' }, - expirationType: '过期类型', - guestUpload: '游客上传', - errorLimits: '错误限制', - errorPerMinute: '每分钟错误限制', - errorCountLimit: '错误次数限制', - saveChanges: '保存更改' + expirationType: '过期方式', + guestUpload: '允许游客上传', + errorLimits: '访问保护', + errorPerMinute: '检测时间窗口(在此时间内统计错误次数)', + errorCountLimit: '允许错误次数(超过后临时封禁)', + saveChanges: '保存设置' }, dashboard: { title: '仪表盘', @@ -458,64 +458,64 @@ export default { websiteName: '网站名称', websiteDescription: '网站描述', adminPassword: '管理员密码', - passwordPlaceholder: '留空则不修改密码', - passwordNote: '留空则不修改', - keywords: '关键词', - themeSelection: '主题选择', + passwordPlaceholder: '如需修改请输入新密码', + passwordNote: '不修改请留空', + keywords: 'SEO 关键词', + themeSelection: '界面主题', notificationSettings: '通知设置', - notificationTitle: '通知标题', - notificationContent: '通知内容', - storageSettings: '存储设置', - storagePath: '存储路径', - storagePathPlaceholder: '留空则使用默认路径,可不填写', + notificationTitle: '公告标题', + notificationContent: '公告内容', + storageSettings: '存储配置', + storagePath: '文件存储路径', + storagePathPlaceholder: '使用默认路径可留空', storageMethod: '存储方式', localStorage: '本地存储', - s3Storage: 'S3 存储', - webdavStorage: 'Webdav 存储', - chunkUploadNote: '开启切片上传(实验性功能,可能会出现未知问题)', - enabled: '已开启', - disabled: '已关闭', - webdavUrl: '请输入 Webdav URL', - webdavUsername: '请输入 Webdav Username', - webdavPassword: '请输入 Webdav Password', - enableProxy: '启用代理', - uploadLimits: '上传限制', - uploadRateLimit: '每分钟上传限制', + s3Storage: 'S3 对象存储', + webdavStorage: 'WebDAV 存储', + chunkUploadNote: '分片上传(实验性功能,大文件上传更稳定)', + enabled: '已启用', + disabled: '已禁用', + webdavUrl: '输入 WebDAV 服务地址', + webdavUsername: '输入 WebDAV 用户名', + webdavPassword: '输入 WebDAV 密码', + enableProxy: '启用下载代理', + uploadLimits: '上传设置', + uploadRateLimit: '限流时间窗口(在此时间内限制上传次数)', minute: '分钟', - uploadCountLimit: '上传数量限制', + uploadCountLimit: '允许上传文件数(限流窗口内最多上传几个文件)', files: '个文件', - fileSizeLimit: '文件大小限制', + fileSizeLimit: '单文件大小上限', expirationMethod: '过期方式', expirationMethods: { - day: '按天', + day: '按天数', hour: '按小时', minute: '按分钟', - forever: '永久', - count: '按次数' + forever: '永久有效', + count: '按下载次数' }, expiration: { - day: '按天', + day: '按天数', hour: '按小时', minute: '按分钟', - forever: '永久', - count: '按次数' + forever: '永久有效', + count: '按下载次数' }, - maxSaveTime: '最长保存时间', + maxSaveTime: '文件最长保存时间', timeUnits: { second: '秒', - minute: '分', - hour: '时', + minute: '分钟', + hour: '小时', day: '天' }, - guestUpload: '游客上传', - errorLimits: '错误限制', - errorRateLimit: '每分钟错误限制', - errorCountLimit: '错误次数限制', + guestUpload: '允许游客上传', + errorLimits: '访问保护', + errorRateLimit: '检测时间窗口(在此时间内统计错误次数)', + errorCountLimit: '允许错误次数(超过后临时封禁)', times: '次', saveSettings: '保存设置', - saveSuccess: '保存成功', - saveFailed: '保存失败', - getConfigFailed: '获取配置失败' + saveSuccess: '设置已保存', + saveFailed: '保存失败,请重试', + getConfigFailed: '获取配置失败,请刷新页面' }, login: { title: '登录', From 17b194f33fc021be97e1d9d8036bf29a8a0b1e34 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 31 Dec 2025 03:45:29 +0800 Subject: [PATCH 04/66] fix: https://github.com/vastsa/FileCodeBox/issues/444 --- src/i18n/locales/en-US.ts | 6 ++ src/i18n/locales/zh-CN.ts | 6 ++ src/views/manage/FileManageView.vue | 131 +++++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index cb5313c..550b53e 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -285,6 +285,12 @@ export default { allFiles: 'All Files', editFileInfo: 'Edit File Information', saveChanges: 'Save Changes', + viewText: 'View', + textPreview: 'Text Preview', + copyText: 'Copy Text', + copySuccess: 'Text copied to clipboard', + copyFailed: 'Copy failed, please try again', + charCount: '{count} characters', headers: { code: 'Retrieve Code', name: 'Name', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e935e6e..1596dda 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -284,6 +284,12 @@ export default { allFiles: '所有文件', editFileInfo: '编辑文件信息', saveChanges: '保存更改', + viewText: '查看', + textPreview: '文本预览', + copyText: '复制文本', + copySuccess: '文本已复制到剪贴板', + copyFailed: '复制失败,请重试', + charCount: '共 {count} 个字符', headers: { code: '取件码', name: '名称', diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 635df10..f300c8f 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -65,17 +65,24 @@ -
- - {{ file.text }} +
+ + {{ file.text || '-' }} - -
-
- {{ file.text }} -
-
+ +
@@ -304,6 +311,81 @@
+ + + @@ -318,6 +400,9 @@ import { TrashIcon, PencilIcon, CheckIcon, + EyeIcon, + CopyIcon, + FileTextIcon, } from 'lucide-vue-next' import DataTable from '@/components/common/DataTable.vue' import DataPagination from '@/components/common/DataPagination.vue' @@ -367,6 +452,32 @@ const editForm = ref({ expired_count: null }) +// 文本预览相关状态 +const showTextPreview = ref(false) +const previewText = ref('') + +// 打开文本预览 +const openTextPreview = (text: string) => { + previewText.value = text + showTextPreview.value = true +} + +// 关闭文本预览 +const closeTextPreview = () => { + showTextPreview.value = false + previewText.value = '' +} + +// 复制文本到剪贴板 +const copyText = async () => { + try { + await navigator.clipboard.writeText(previewText.value) + alertStore.showAlert(t('fileManage.copySuccess'), 'success') + } catch { + alertStore.showAlert(t('fileManage.copyFailed'), 'error') + } +} + // 打开编辑模态框 const openEditModal = (file: FileListItem) => { editForm.value = { From 1f76be56970162aaa212a104e512a7275feb51d4 Mon Sep 17 00:00:00 2001 From: Lan Date: Thu, 1 Jan 2026 12:45:23 +0800 Subject: [PATCH 05/66] fix: https://github.com/vastsa/FileCodeBox/issues/449 --- src/composables/useFileUpload.ts | 29 ++++++++++++++++++++++---- src/composables/usePresignedUpload.ts | 30 +++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/composables/useFileUpload.ts b/src/composables/useFileUpload.ts index 6bb3131..3e507ed 100644 --- a/src/composables/useFileUpload.ts +++ b/src/composables/useFileUpload.ts @@ -2,8 +2,28 @@ import { ref, computed, readonly } from 'vue' import { FileService } from '@/services' import { useAlertStore } from '@/stores/alertStore' import { usePresignedUpload } from '@/composables/usePresignedUpload' -import type { UploadProgress, UploadStatus, PresignUploadOptions, ExpireStyle } from '@/types' -import { UPLOAD_STATUS, FILE_SIZE_LIMITS } from '@/constants' +import type { UploadProgress, UploadStatus, PresignUploadOptions, ExpireStyle, ConfigState } from '@/types' +import { UPLOAD_STATUS, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' + +/** + * 获取最大文件大小限制(字节) + * 优先从后端配置获取,否则使用默认值 + */ +function getMaxFileSize(): number { + try { + const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG) + if (configStr) { + const config = JSON.parse(configStr) as Partial + if (config.uploadSize && config.uploadSize > 0) { + // uploadSize 单位是字节 + return config.uploadSize + } + } + } catch { + // 解析失败时使用默认值 + } + return FILE_SIZE_LIMITS.MAX_FILE_SIZE +} export interface FileUploadOptions { /** 是否使用预签名上传,默认 false 保持向后兼容 */ @@ -66,9 +86,10 @@ export function useFileUpload(options?: { defaultUsePresigned?: boolean }) { // 文件验证 const validateFile = (file: File): boolean => { - if (file.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) { + const maxFileSize = getMaxFileSize() + if (file.size > maxFileSize) { alertStore.showAlert( - `文件大小不能超过 ${Math.round(FILE_SIZE_LIMITS.MAX_FILE_SIZE / 1024 / 1024)}MB`, + `文件大小不能超过 ${Math.round(maxFileSize / 1024 / 1024)}MB`, 'error' ) return false diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts index b121a7e..1852395 100644 --- a/src/composables/usePresignedUpload.ts +++ b/src/composables/usePresignedUpload.ts @@ -1,7 +1,7 @@ import { ref, computed, readonly } from 'vue' import { PresignUploadService } from '@/services' import { useAlertStore } from '@/stores/alertStore' -import { FILE_SIZE_LIMITS } from '@/constants' +import { FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' import type { PresignUploadStatus, PresignUploadMode, @@ -9,10 +9,31 @@ import type { PresignUploadOptions, PresignStatusResponse, UploadProgress, - ExpireStyle + ExpireStyle, + ConfigState } from '@/types' import axios from 'axios' +/** + * 获取最大文件大小限制(字节) + * 优先从后端配置获取,否则使用默认值 + */ +function getMaxFileSize(): number { + try { + const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG) + if (configStr) { + const config = JSON.parse(configStr) as Partial + if (config.uploadSize && config.uploadSize > 0) { + // uploadSize 单位是字节 + return config.uploadSize + } + } + } catch { + // 解析失败时使用默认值 + } + return FILE_SIZE_LIMITS.MAX_FILE_SIZE +} + // 预签名上传状态常量 export const PRESIGN_UPLOAD_STATUS = { IDLE: 'idle', @@ -58,8 +79,9 @@ export function usePresignedUpload() { * 文件大小验证 */ const validateFileSize = (file: File): boolean => { - if (file.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) { - const maxSizeMB = Math.round(FILE_SIZE_LIMITS.MAX_FILE_SIZE / 1024 / 1024) + const maxFileSize = getMaxFileSize() + if (file.size > maxFileSize) { + const maxSizeMB = Math.round(maxFileSize / 1024 / 1024) errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB` alertStore.showAlert(errorMessage.value, 'error') return false From 394437294e2f49d6ae305b3fe6bd516875c7f862 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 7 Jan 2026 18:54:41 +0800 Subject: [PATCH 06/66] fix: https://github.com/vastsa/FileCodeBox/issues/322 --- src/i18n/locales/en-US.ts | 79 ++++++++++++------------- src/i18n/locales/zh-CN.ts | 53 ++++++++--------- src/views/SendFileView.vue | 117 ++++++++++++++++++++++++------------- vite.config.ts | 3 +- 4 files changed, 143 insertions(+), 109 deletions(-) diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 550b53e..e5ff285 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -189,6 +189,7 @@ export default { description: 'Supports various common formats' }, submit: 'Secure Send', + submitting: 'Sending...', needRetrieveFile: 'Need to retrieve? Click here', sendRecords: 'Send Records', secureEncryption: 'Secure Encryption', @@ -217,7 +218,7 @@ export default { forever: 'Forever' } }, - time: { + time: { day: 'By Day', hour: 'By Hour', minute: 'By Minute', @@ -311,7 +312,7 @@ export default { } }, - // Side drawer + // Side drawer drawer: { noRecords: 'No records' }, @@ -466,42 +467,42 @@ export default { version: 'Version v2.2.1 Updated: 2025-09-04' }, fileManage: { - title: 'File Management', - searchPlaceholder: 'Search file name, description...', - allFiles: 'All Files', - editFileInfo: 'Edit File Info', - saveChanges: 'Save Changes', - headers: { - code: 'Code', - name: 'Name', - size: 'Size', - description: 'Description', - expiration: 'Expiration', - actions: 'Actions' - }, - form: { - code: 'Code', - codePlaceholder: 'Enter code', - filename: 'File Name', - filenamePlaceholder: 'Enter file name', - suffix: 'File Suffix', - suffixPlaceholder: 'Enter file suffix', - downloadLimit: 'Download Limit', - downloadLimitPlaceholder: 'Enter download limit' - }, - updateFailed: 'Update failed', - deleteFailed: 'Delete failed', - loadFileListFailed: 'Failed to load file list' - }, - login: { - title: 'Login', - password: 'Password', - passwordPlaceholder: 'Password', - loginButton: 'Login', - loggingIn: 'Logging in...', - invalidPassword: 'Invalid password', - passwordTooShort: 'Password must be at least 6 characters', - loginFailed: 'Login failed', + title: 'File Management', + searchPlaceholder: 'Search file name, description...', + allFiles: 'All Files', + editFileInfo: 'Edit File Info', + saveChanges: 'Save Changes', + headers: { + code: 'Code', + name: 'Name', + size: 'Size', + description: 'Description', + expiration: 'Expiration', + actions: 'Actions' + }, + form: { + code: 'Code', + codePlaceholder: 'Enter code', + filename: 'File Name', + filenamePlaceholder: 'Enter file name', + suffix: 'File Suffix', + suffixPlaceholder: 'Enter file suffix', + downloadLimit: 'Download Limit', + downloadLimitPlaceholder: 'Enter download limit' + }, + updateFailed: 'Update failed', + deleteFailed: 'Delete failed', + loadFileListFailed: 'Failed to load file list' + }, + login: { + title: 'Login', + password: 'Password', + passwordPlaceholder: 'Password', + loginButton: 'Login', + loggingIn: 'Logging in...', + invalidPassword: 'Invalid password', + passwordTooShort: 'Password must be at least 6 characters', + loginFailed: 'Login failed', noValidToken: 'Login failed: No valid token received' } }, @@ -600,4 +601,4 @@ export default { delayedLoading: 'Use onMounted hook to delay load non-critical resources or initialization', nonCriticalInit: 'Place non-immediately needed initialization code here' } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 1596dda..157aecd 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -190,6 +190,7 @@ export default { description: '支持各种常见格式' }, submit: '安全寄送', + submitting: '发送中...', needRetrieveFile: '需要取件?点击这里', sendRecords: '发件记录', secureEncryption: '安全加密', @@ -311,35 +312,35 @@ export default { }, // 文件记录 - fileRecord: { - filename: '文件名', - size: '文件大小', - date: '取件日期', - code: '取件码', - actions: '操作', - download: '下载', - viewDetails: '查看详情', - deleteRecord: '删除记录', - preview: '预览', - copyContent: '复制内容', - contentCopied: '内容已复制到剪贴板', - copyFailed: '复制失败,请重试' - }, + fileRecord: { + filename: '文件名', + size: '文件大小', + date: '取件日期', + code: '取件码', + actions: '操作', + download: '下载', + viewDetails: '查看详情', + deleteRecord: '删除记录', + preview: '预览', + copyContent: '复制内容', + contentCopied: '内容已复制到剪贴板', + copyFailed: '复制失败,请重试' + }, // 文件详情模态框 fileDetail: { - title: '文件详情', - content: '文件内容', - previewContent: '预览内容', - download: '点击下载', - qrCode: '取件二维码', - scanQrCode: '扫描二维码快速取件' - }, + title: '文件详情', + content: '文件内容', + previewContent: '预览内容', + download: '点击下载', + qrCode: '取件二维码', + scanQrCode: '扫描二维码快速取件' + }, - // 内容预览 - contentPreview: { - title: '内容预览' - }, + // 内容预览 + contentPreview: { + title: '内容预览' + }, // 侧边抽屉 drawer: { @@ -622,4 +623,4 @@ export default { delayedLoading: '使用 onMounted 钩子延迟加载一些非关键资源或初始化', nonCriticalInit: '这里可以放置一些非立即需要的初始化代码' } -} \ No newline at end of file +} diff --git a/src/views/SendFileView.vue b/src/views/SendFileView.vue index 1f08d87..dcc9387 100644 --- a/src/views/SendFileView.vue +++ b/src/views/SendFileView.vue @@ -30,10 +30,7 @@ />
- +
@@ -115,7 +112,9 @@ :class="[ 'absolute right-0 top-0 h-full appearance-none cursor-pointer transition-all duration-300', 'focus:outline-none focus:ring-2 focus:ring-offset-0', - expirationMethod === 'forever' ? 'w-full px-5 rounded-2xl' : 'w-28 pl-4 pr-9 border-l rounded-r-2xl', + expirationMethod === 'forever' + ? 'w-full px-5 rounded-2xl' + : 'w-28 pl-4 pr-9 border-l rounded-r-2xl', isDarkMode ? 'text-gray-100 border-gray-700/60 focus:ring-indigo-500/80 bg-gray-800/60' : 'text-gray-900 border-gray-200 focus:ring-indigo-500/60 bg-white' @@ -166,14 +165,36 @@ @@ -532,8 +553,6 @@ interface ShareRecord { retrieveCode: string } - - const config: Config = JSON.parse(localStorage.getItem('config') || '{}') as Config const router = useRouter() @@ -549,6 +568,7 @@ const expirationValue = ref('1') const uploadProgress = ref(0) const showDrawer = ref(false) const selectedRecord = ref(null) +const isSubmitting = ref(false) const { t } = useI18n() const alertStore = useAlertStore() @@ -564,8 +584,6 @@ const { const fileHash = ref('') - - const handleFileSelected = async (file: File) => { selectedFile.value = file if (!checkOpenUpload()) return @@ -573,8 +591,6 @@ const handleFileSelected = async (file: File) => { fileHash.value = await calculateFileHash(file) } - - const handleFileDrop = async (event: DragEvent) => { if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) { const file = event.dataTransfer.files[0] @@ -604,7 +620,10 @@ const handlePaste = async (event: ClipboardEvent) => { try { fileHash.value = await calculateFileHash(file) - alertStore.showAlert(t('send.messages.fileAddedFromClipboard', { filename: file.name }), 'success') + alertStore.showAlert( + t('send.messages.fileAddedFromClipboard', { filename: file.name }), + 'success' + ) } catch (err) { alertStore.showAlert(t('send.messages.fileProcessingFailed'), 'error') console.error('File hash calculation failed:', err) @@ -860,10 +879,13 @@ const handleChunkUpload = async (file: File) => { } // 3. 完成上传 - const completeResponse: ApiResponse<{ code?: string; name?: string }> = await api.post(`chunk/upload/complete/${uploadId}`, { - expire_value: expirationValue.value ? parseInt(expirationValue.value) : 1, - expire_style: expirationMethod.value - }) + const completeResponse: ApiResponse<{ code?: string; name?: string }> = await api.post( + `chunk/upload/complete/${uploadId}`, + { + expire_value: expirationValue.value ? parseInt(expirationValue.value) : 1, + expire_style: expirationMethod.value + } + ) if (completeResponse.code !== 200) { throw new Error(t('send.messages.completeUploadFailed')) @@ -898,7 +920,11 @@ const handleDefaultFileUpload = async (file: File) => { formData.append('file', file) formData.append('expire_value', expirationValue.value) formData.append('expire_style', expirationMethod.value) - const response: ApiResponse<{ code?: string; name?: string }> = await api.post('share/file/', formData, config) + const response: ApiResponse<{ code?: string; name?: string }> = await api.post( + 'share/file/', + formData, + config + ) return response } @@ -933,7 +959,10 @@ const checkOpenUpload = () => { const checkFileSize = (file: File) => { if (file.size > config.uploadSize) { - alertStore.showAlert(t('send.messages.fileSizeExceeded', { size: getStorageUnit(config.uploadSize) }), 'error') + alertStore.showAlert( + t('send.messages.fileSizeExceeded', { size: getStorageUnit(config.uploadSize) }), + 'error' + ) selectedFile.value = null return false } @@ -971,27 +1000,30 @@ const checkUpload = () => { return true } const handleSubmit = async () => { - if (sendType.value === 'file' && !selectedFile.value) { - alertStore.showAlert(t('send.messages.selectFile'), 'error') - return - } - if (sendType.value === 'text' && !textContent.value.trim()) { - alertStore.showAlert(t('send.messages.enterText'), 'error') - return - } - if (expirationMethod.value !== 'forever' && !expirationValue.value) { - alertStore.showAlert(t('send.messages.enterExpirationValue'), 'error') - return - } - - // 添加过期时间验证 - if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) { - const maxDays = Math.floor(config.max_save_seconds / 86400) - alertStore.showAlert(t('send.messages.expirationTooLong', { days: maxDays }), 'error') - return - } + if (isSubmitting.value) return + isSubmitting.value = true try { + if (sendType.value === 'file' && !selectedFile.value) { + alertStore.showAlert(t('send.messages.selectFile'), 'error') + return + } + if (sendType.value === 'text' && !textContent.value.trim()) { + alertStore.showAlert(t('send.messages.enterText'), 'error') + return + } + if (expirationMethod.value !== 'forever' && !expirationValue.value) { + alertStore.showAlert(t('send.messages.enterExpirationValue'), 'error') + return + } + + // 添加过期时间验证 + if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) { + const maxDays = Math.floor(config.max_save_seconds / 86400) + alertStore.showAlert(t('send.messages.expirationTooLong', { days: maxDays }), 'error') + return + } + let response: ApiResponse if (sendType.value === 'file') { @@ -1020,8 +1052,8 @@ const handleSubmit = async () => { } if (response && response.code === 200) { - const retrieveCode = (response.detail as unknown as { code?: string })?.code || '' - const fileName = (response.detail as unknown as { name?: string })?.name || '' + const retrieveCode = (response.detail as unknown as { code?: string })?.code || '' + const fileName = (response.detail as unknown as { name?: string })?.name || '' // 添加新的发送记录 const newRecord: ShareRecord = { id: Date.now(), @@ -1064,6 +1096,7 @@ const handleSubmit = async () => { } } finally { uploadProgress.value = 0 + isSubmitting.value = false } } diff --git a/vite.config.ts b/vite.config.ts index d3d07e7..058b471 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,11 +3,10 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' -import vueDevTools from 'vite-plugin-vue-devtools' import tailwindcss from 'tailwindcss' import autoprefixer from 'autoprefixer' export default defineConfig({ - plugins: [vue(), vueJsx(), vueDevTools()], + plugins: [vue(), vueJsx()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) From f5e385316f2c8b9c7ee03147fc671cdeafaaecc2 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 7 Jan 2026 18:58:05 +0800 Subject: [PATCH 07/66] fix: https://github.com/vastsa/FileCodeBox/issues/320 --- src/components/common/FileUploadArea.vue | 44 ++++++++++-------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/components/common/FileUploadArea.vue b/src/components/common/FileUploadArea.vue index 5e7acce..d1d9293 100644 --- a/src/components/common/FileUploadArea.vue +++ b/src/components/common/FileUploadArea.vue @@ -22,16 +22,13 @@
- + - +

{{ statusDescription }}

- +
-
+
{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }} {{ progress }}%
- +
- +
{{ t('send.secureEncryption') }} - +
+ + + + +
\ No newline at end of file + diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index e5ff285..aa4cf60 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -53,6 +53,7 @@ export default { // Admin module admin: { + logout: 'Logout', dashboard: { title: 'Dashboard', totalFiles: 'Total Files', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 157aecd..b9c7c47 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -52,6 +52,7 @@ export default { // 管理员模块 admin: { + logout: '退出登录', dashboard: { title: '仪表盘', totalFiles: '总文件数', diff --git a/src/layout/AdminLayout/AdminLayout.vue b/src/layout/AdminLayout/AdminLayout.vue index 4637527..fc6dfda 100644 --- a/src/layout/AdminLayout/AdminLayout.vue +++ b/src/layout/AdminLayout/AdminLayout.vue @@ -1,25 +1,37 @@ \ No newline at end of file + diff --git a/src/composables/index.ts b/src/composables/index.ts new file mode 100644 index 0000000..5086761 --- /dev/null +++ b/src/composables/index.ts @@ -0,0 +1,12 @@ +export { useAdminFiles } from './useAdminFiles' +export { useAdminLogin } from './useAdminLogin' +export { useAppShell } from './useAppShell' +export { useDashboardStats } from './useDashboardStats' +export { useInjectedDarkMode } from './useInjectedDarkMode' +export { usePresignedUpload } from './usePresignedUpload' +export { usePublicConfigBootstrap } from './usePublicConfigBootstrap' +export { useRetrieveFlow } from './useRetrieveFlow' +export { useRouteLoading } from './useRouteLoading' +export { useSendFlow } from './useSendFlow' +export { useSystemConfig } from './useSystemConfig' +export { useTheme } from './useTheme' diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts new file mode 100644 index 0000000..936441a --- /dev/null +++ b/src/composables/useAdminFiles.ts @@ -0,0 +1,163 @@ +import { computed, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { FileService } from '@/services' +import { useAlertStore } from '@/stores/alertStore' +import type { AdminFileViewItem, FileEditForm, FileListItem } from '@/types' +import { copyToClipboard } from '@/utils/clipboard' +import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common' + +const TEXT_PREVIEW_THRESHOLD = 30 + +export function useAdminFiles() { + const { t } = useI18n() + const alertStore = useAlertStore() + + const tableData = ref([]) + const hasLoadError = ref(false) + const params = ref({ + page: 1, + size: 10, + total: 0, + keyword: '' + }) + + const showEditModal = ref(false) + const editForm = ref({ + id: null, + code: '', + prefix: '', + suffix: '', + expired_at: '', + expired_count: null + }) + + const showTextPreview = ref(false) + const previewText = ref('') + const totalPages = computed(() => Math.ceil(params.value.total / params.value.size)) + + const createFileViewItem = (file: FileListItem): AdminFileViewItem => ({ + ...file, + displaySize: formatFileSize(file.size), + displayExpiredAt: file.expired_at + ? formatTimestamp(file.expired_at) + : t('send.expiration.units.forever'), + canPreviewText: Boolean(file.text && file.text.length > TEXT_PREVIEW_THRESHOLD) + }) + + const resetEditForm = () => { + editForm.value = { + id: null, + code: '', + prefix: '', + suffix: '', + expired_at: '', + expired_count: null + } + } + + const loadFiles = async () => { + try { + hasLoadError.value = false + const res = await FileService.getAdminFileList(params.value) + if (!res.detail) return + + tableData.value = res.detail.data.map(createFileViewItem) + params.value.total = res.detail.total + } catch (error) { + hasLoadError.value = true + alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), 'error') + } + } + + const handleSearch = async () => { + params.value.page = 1 + await loadFiles() + } + + const handlePageChange = async (page: number | string) => { + if (typeof page === 'string') return + if (page < 1 || page > totalPages.value) return + + params.value.page = page + await loadFiles() + } + + const openEditModal = (file: FileListItem) => { + editForm.value = { + id: file.id, + code: file.code, + prefix: file.prefix, + suffix: file.suffix, + expired_at: file.expired_at ? file.expired_at.slice(0, 16) : '', + expired_count: file.expired_count + } + showEditModal.value = true + } + + const closeEditModal = () => { + showEditModal.value = false + resetEditForm() + } + + const handleUpdate = async () => { + try { + await FileService.updateFile(editForm.value) + await loadFiles() + closeEditModal() + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error') + } + } + + const deleteFile = async (id: number) => { + if (!window.confirm(t('manage.fileManage.deleteConfirm'))) { + return + } + + try { + await FileService.deleteAdminFile(id) + await loadFiles() + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error') + } + } + + const openTextPreview = (text: string) => { + previewText.value = text + showTextPreview.value = true + } + + const closeTextPreview = () => { + showTextPreview.value = false + previewText.value = '' + } + + const copyText = async () => { + await copyToClipboard(previewText.value, { + successMsg: t('fileManage.copySuccess'), + errorMsg: t('fileManage.copyFailed'), + notify: (message, type) => alertStore.showAlert(message, type) + }) + } + + return { + tableData, + hasLoadError, + params, + showEditModal, + editForm, + showTextPreview, + previewText, + totalPages, + closeEditModal, + closeTextPreview, + copyText, + deleteFile, + handlePageChange, + handleSearch, + handleUpdate, + loadFiles, + openEditModal, + openTextPreview + } +} diff --git a/src/composables/useAdminLogin.ts b/src/composables/useAdminLogin.ts new file mode 100644 index 0000000..7c81627 --- /dev/null +++ b/src/composables/useAdminLogin.ts @@ -0,0 +1,53 @@ +import { ref } from 'vue' +import { AuthService } from '@/services' +import { useAdminStore } from '@/stores/adminStore' +import { useAlertStore } from '@/stores/alertStore' +import { getErrorMessage } from '@/utils/common' + +export function useAdminLogin() { + const alertStore = useAlertStore() + const adminStore = useAdminStore() + const password = ref('') + const isLoading = ref(false) + + const validateForm = () => { + if (!password.value) { + alertStore.showAlert('无效的密码', 'error') + return false + } + + if (password.value.length < 6) { + alertStore.showAlert('密码长度至少为6位', 'error') + return false + } + + return true + } + + const handleSubmit = async () => { + if (!validateForm()) return false + + isLoading.value = true + try { + const response = await AuthService.login(password.value) + if (!response.detail?.token) { + alertStore.showAlert('登录失败:未获取到有效令牌', 'error') + return false + } + + adminStore.setToken(response.detail.token) + return true + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error') + return false + } finally { + isLoading.value = false + } + } + + return { + password, + isLoading, + handleSubmit + } +} diff --git a/src/composables/useAppShell.ts b/src/composables/useAppShell.ts new file mode 100644 index 0000000..dee134a --- /dev/null +++ b/src/composables/useAppShell.ts @@ -0,0 +1,52 @@ +import { computed, onMounted, onUnmounted, provide } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { AUTH_EVENTS } from '@/services' +import { ROUTES } from '@/constants' +import { useTheme } from './useTheme' +import { usePublicConfigBootstrap } from './usePublicConfigBootstrap' +import { useRouteLoading } from './useRouteLoading' + +export function useAppShell() { + const route = useRoute() + const router = useRouter() + const { isDarkMode, toggleTheme, initTheme } = useTheme() + const { isLoading, setupRouteLoading } = useRouteLoading(router) + const { syncPublicConfig } = usePublicConfigBootstrap() + const showGlobalControls = computed(() => route.meta.showGlobalControls !== false) + + let cleanupThemeListener: (() => void) | null = null + + const handleUnauthorized = () => { + if (router.currentRoute.value.path !== ROUTES.LOGIN) { + void router.push({ + path: ROUTES.LOGIN, + query: { + redirect: router.currentRoute.value.fullPath + } + }) + } + } + + onMounted(() => { + cleanupThemeListener = initTheme() + setupRouteLoading() + window.addEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized) + void syncPublicConfig() + }) + + onUnmounted(() => { + cleanupThemeListener?.() + window.removeEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized) + }) + + provide('isDarkMode', isDarkMode) + provide('toggleTheme', toggleTheme) + provide('isLoading', isLoading) + + return { + isDarkMode, + isLoading, + route, + showGlobalControls + } +} diff --git a/src/composables/useDashboardStats.ts b/src/composables/useDashboardStats.ts new file mode 100644 index 0000000..452fc92 --- /dev/null +++ b/src/composables/useDashboardStats.ts @@ -0,0 +1,108 @@ +import { reactive } from 'vue' +import { StatsService } from '@/services' +import type { DashboardViewData } from '@/types' +import { formatFileSize } from '@/utils/common' + +const emptyDashboardData = (): DashboardViewData => ({ + hasExtendedStats: false, + totalFiles: 0, + storageUsed: 0, + yesterdayCount: 0, + todayCount: 0, + yesterdaySize: 0, + todaySize: 0, + sysUptime: null, + activeCount: 0, + expiredCount: 0, + textCount: 0, + fileCount: 0, + chunkedCount: 0, + usedCount: 0, + storageBackend: '-', + uploadSizeLimit: 0, + openUpload: 0, + enableChunk: 0, + maxSaveSeconds: 0, + topSuffixes: [], + recentFiles: [], + storageUsedText: '0 Bytes', + yesterdaySizeText: '0 Bytes', + todaySizeText: '0 Bytes', + uploadSizeLimitText: '0 Bytes', + sysUptimeText: '-', + activeRatio: 0, + textRatio: 0, + fileRatio: 0, + todaySizeRatio: 0 +}) + +const toNumber = (value: number | string | null | undefined) => Number(value || 0) + +const clampRatio = (value: number) => Math.max(0, Math.min(100, Math.round(value))) + +const hasOwn = (target: object, key: string) => Object.prototype.hasOwnProperty.call(target, key) + +const formatDuration = (startTimestamp: number | null) => { + if (!startTimestamp) return '-' + const uptime = Date.now() - startTimestamp + const days = Math.floor(uptime / (24 * 60 * 60 * 1000)) + const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)) + return `${days}天${hours}小时` +} + +export function useDashboardStats() { + const dashboardData = reactive(emptyDashboardData()) + + const fetchDashboardData = async () => { + const response = await StatsService.getDashboard() + if (!response.detail) return + + const detail = response.detail + dashboardData.totalFiles = toNumber(detail.totalFiles) + dashboardData.storageUsed = toNumber(detail.storageUsed) + dashboardData.yesterdayCount = toNumber(detail.yesterdayCount) + dashboardData.todayCount = toNumber(detail.todayCount) + dashboardData.yesterdaySize = toNumber(detail.yesterdaySize) + dashboardData.todaySize = toNumber(detail.todaySize) + dashboardData.sysUptime = detail.sysUptime + dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount') + dashboardData.activeCount = dashboardData.hasExtendedStats + ? toNumber(detail.activeCount) + : dashboardData.totalFiles + dashboardData.expiredCount = toNumber(detail.expiredCount) + dashboardData.textCount = toNumber(detail.textCount) + dashboardData.fileCount = toNumber(detail.fileCount) + dashboardData.chunkedCount = toNumber(detail.chunkedCount) + dashboardData.usedCount = toNumber(detail.usedCount) + dashboardData.storageBackend = detail.storageBackend || '-' + dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit) + dashboardData.openUpload = toNumber(detail.openUpload) + dashboardData.enableChunk = toNumber(detail.enableChunk) + dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds) + dashboardData.topSuffixes = detail.topSuffixes || [] + dashboardData.recentFiles = detail.recentFiles || [] + + dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) + dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) + dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) + dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) + dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) + dashboardData.activeRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.textRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.fileRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit + ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) + : 0 + } + + return { + dashboardData, + fetchDashboardData + } +} diff --git a/src/composables/useFileDownload.ts b/src/composables/useFileDownload.ts deleted file mode 100644 index b578fb2..0000000 --- a/src/composables/useFileDownload.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { ref, computed } from 'vue' -import { FileService } from '@/services' -import { useAlertStore } from '@/stores/alertStore' -import type { FileInfo } from '@/types' -import { saveAs } from 'file-saver' - -export function useFileDownload() { - const alertStore = useAlertStore() - - // 状态管理 - const isLoading = ref(false) - const fileInfo = ref(null) - const downloadCode = ref('') - - // 计算属性 - const hasFileInfo = computed(() => fileInfo.value !== null) - const canDownload = computed(() => hasFileInfo.value && !isLoading.value) - - // 获取文件信息 - const getFileInfo = async (code: string): Promise => { - if (!code.trim()) { - alertStore.showAlert('请输入取件码', 'warning') - return null - } - - try { - isLoading.value = true - downloadCode.value = code - - const response = await FileService.getFile(code) - - if (response.code === 200 && response.detail) { - fileInfo.value = response.detail - return response.detail - } else { - throw new Error(response.message || '文件不存在或已过期') - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '获取文件信息失败' - alertStore.showAlert(errorMessage, 'error') - fileInfo.value = null - return null - } finally { - isLoading.value = false - } - } - - // 下载文件 - const downloadFile = async (code?: string): Promise => { - const targetCode = code || downloadCode.value - - if (!targetCode.trim()) { - alertStore.showAlert('请输入取件码', 'warning') - return false - } - - try { - isLoading.value = true - - // 如果没有文件信息,先获取 - if (!fileInfo.value || downloadCode.value !== targetCode) { - const info = await getFileInfo(targetCode) - if (!info) { - return false - } - } - - const blob = await FileService.downloadFile(targetCode) - - // 使用 file-saver 保存文件 - if (fileInfo.value?.name) { - saveAs(blob, fileInfo.value.name) - alertStore.showAlert('文件下载成功!', 'success') - return true - } else { - throw new Error('文件名获取失败') - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '下载失败' - alertStore.showAlert(errorMessage, 'error') - return false - } finally { - isLoading.value = false - } - } - - // 重置状态 - const resetDownload = () => { - isLoading.value = false - fileInfo.value = null - downloadCode.value = '' - } - - // 格式化文件大小 - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } - - // 格式化时间 - const formatTime = (timeString: string): string => { - try { - const date = new Date(timeString) - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }) - } catch { - return timeString - } - } - - // 检查文件是否过期 - const isFileExpired = computed(() => { - if (!fileInfo.value?.expireTime) return false - return new Date(fileInfo.value.expireTime) < new Date() - }) - - return { - // 状态 - isLoading, - fileInfo, - downloadCode, - - // 计算属性 - hasFileInfo, - canDownload, - isFileExpired, - - // 方法 - getFileInfo, - downloadFile, - resetDownload, - formatFileSize, - formatTime - } -} \ No newline at end of file diff --git a/src/composables/useFileUpload.ts b/src/composables/useFileUpload.ts deleted file mode 100644 index 3e507ed..0000000 --- a/src/composables/useFileUpload.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { ref, computed, readonly } from 'vue' -import { FileService } from '@/services' -import { useAlertStore } from '@/stores/alertStore' -import { usePresignedUpload } from '@/composables/usePresignedUpload' -import type { UploadProgress, UploadStatus, PresignUploadOptions, ExpireStyle, ConfigState } from '@/types' -import { UPLOAD_STATUS, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' - -/** - * 获取最大文件大小限制(字节) - * 优先从后端配置获取,否则使用默认值 - */ -function getMaxFileSize(): number { - try { - const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (configStr) { - const config = JSON.parse(configStr) as Partial - if (config.uploadSize && config.uploadSize > 0) { - // uploadSize 单位是字节 - return config.uploadSize - } - } - } catch { - // 解析失败时使用默认值 - } - return FILE_SIZE_LIMITS.MAX_FILE_SIZE -} - -export interface FileUploadOptions { - /** 是否使用预签名上传,默认 false 保持向后兼容 */ - usePresigned?: boolean - /** 过期时间值 */ - expireValue?: number - /** 过期时间类型 */ - expireStyle?: ExpireStyle - /** 进度回调 */ - onProgress?: (progress: UploadProgress) => void -} - -export function useFileUpload(options?: { defaultUsePresigned?: boolean }) { - const alertStore = useAlertStore() - - // 预签名上传 composable - const presignedUpload = usePresignedUpload() - - // 是否默认使用预签名上传 - const defaultUsePresigned = options?.defaultUsePresigned ?? false - - // 状态管理 - const uploadStatus = ref(UPLOAD_STATUS.IDLE) - const uploadProgress = ref({ - loaded: 0, - total: 0, - percentage: 0 - }) - const uploadedCode = ref('') - const currentFile = ref(null) - - // 当前是否使用预签名上传 - const isUsingPresigned = ref(false) - - // 计算属性 - const isUploading = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.isUploading.value || presignedUpload.isInitializing.value || presignedUpload.isConfirming.value - } - return uploadStatus.value === UPLOAD_STATUS.UPLOADING - }) - const isSuccess = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.isSuccess.value - } - return uploadStatus.value === UPLOAD_STATUS.SUCCESS - }) - const isError = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.isError.value - } - return uploadStatus.value === UPLOAD_STATUS.ERROR - }) - const isIdle = computed(() => { - if (isUsingPresigned.value) { - return presignedUpload.presignStatus.value === 'idle' - } - return uploadStatus.value === UPLOAD_STATUS.IDLE - }) - - // 文件验证 - const validateFile = (file: File): boolean => { - const maxFileSize = getMaxFileSize() - if (file.size > maxFileSize) { - alertStore.showAlert( - `文件大小不能超过 ${Math.round(maxFileSize / 1024 / 1024)}MB`, - 'error' - ) - return false - } - return true - } - - /** - * 上传文件(支持预签名上传和传统上传) - */ - const uploadFile = async (file: File, uploadOptions?: FileUploadOptions): Promise => { - const shouldUsePresigned = uploadOptions?.usePresigned ?? defaultUsePresigned - - if (!validateFile(file)) { - return null - } - - // 记录当前使用的上传方式 - isUsingPresigned.value = shouldUsePresigned - currentFile.value = file - - if (shouldUsePresigned) { - // 使用预签名上传 - return await uploadFileWithPresigned(file, uploadOptions) - } else { - // 使用传统上传方式 - return await uploadFileTraditional(file, uploadOptions?.onProgress) - } - } - - /** - * 传统上传方式 - */ - const uploadFileTraditional = async ( - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise => { - try { - uploadStatus.value = UPLOAD_STATUS.UPLOADING - uploadedCode.value = '' - - const response = await FileService.uploadFile(file, (progress) => { - uploadProgress.value = progress - onProgress?.(progress) - }) - - if (response.code === 200 && response.detail?.code) { - uploadStatus.value = UPLOAD_STATUS.SUCCESS - uploadedCode.value = String(response.detail.code) - alertStore.showAlert('文件上传成功!', 'success') - return String(response.detail.code) - } else { - throw new Error(response.message || '上传失败') - } - } catch (error) { - uploadStatus.value = UPLOAD_STATUS.ERROR - const errorMessage = error instanceof Error ? error.message : '上传失败' - alertStore.showAlert(errorMessage, 'error') - return null - } - } - - /** - * 预签名上传方式 - */ - const uploadFileWithPresigned = async ( - file: File, - uploadOptions?: FileUploadOptions - ): Promise => { - // 构建预签名上传选项 - const presignOptions: PresignUploadOptions = { - expireValue: uploadOptions?.expireValue, - expireStyle: uploadOptions?.expireStyle, - onProgress: (progress) => { - // 同步进度到本 composable 的状态 - uploadProgress.value = progress - uploadOptions?.onProgress?.(progress) - } - } - - const result = await presignedUpload.uploadFile(file, presignOptions) - - if (result) { - // 同步预签名上传的结果到本 composable 的状态 - uploadedCode.value = result - uploadStatus.value = UPLOAD_STATUS.SUCCESS - } else { - uploadStatus.value = UPLOAD_STATUS.ERROR - } - - return result - } - - // 上传文本 - const uploadText = async (text: string): Promise => { - if (!text.trim()) { - alertStore.showAlert('请输入要发送的文本内容', 'warning') - return null - } - - try { - uploadStatus.value = UPLOAD_STATUS.UPLOADING - uploadedCode.value = '' - - const response = await FileService.uploadText(text) - - if (response.code === 200 && response.detail?.code) { - uploadStatus.value = UPLOAD_STATUS.SUCCESS - uploadedCode.value = String(response.detail.code) - alertStore.showAlert('文本发送成功!', 'success') - return String(response.detail.code) - } else { - throw new Error(response.message || '发送失败') - } - } catch (error) { - uploadStatus.value = UPLOAD_STATUS.ERROR - const errorMessage = error instanceof Error ? error.message : '发送失败' - alertStore.showAlert(errorMessage, 'error') - return null - } - } - - // 重置状态 - const resetUpload = () => { - uploadStatus.value = UPLOAD_STATUS.IDLE - uploadProgress.value = { - loaded: 0, - total: 0, - percentage: 0 - } - uploadedCode.value = '' - currentFile.value = null - - // 如果使用预签名上传,也重置预签名状态 - if (isUsingPresigned.value) { - presignedUpload.reset() - } - isUsingPresigned.value = false - } - - /** - * 取消上传 - */ - const cancelUpload = async (): Promise => { - if (isUsingPresigned.value) { - await presignedUpload.cancelUpload() - } - resetUpload() - } - - // 格式化文件大小 - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } - - return { - // 状态 - uploadStatus: readonly(uploadStatus), - uploadProgress: readonly(uploadProgress), - uploadedCode: readonly(uploadedCode), - currentFile: readonly(currentFile), - isUsingPresigned: readonly(isUsingPresigned), - - // 预签名上传相关状态(透传) - presignStatus: presignedUpload.presignStatus, - uploadSession: presignedUpload.uploadSession, - currentMode: presignedUpload.currentMode, - presignErrorMessage: presignedUpload.errorMessage, - - // 计算属性 - isUploading, - isSuccess, - isError, - isIdle, - - // 预签名上传计算属性(透传) - isInitializing: presignedUpload.isInitializing, - isConfirming: presignedUpload.isConfirming, - - // 方法 - uploadFile, - uploadText, - resetUpload, - cancelUpload, - validateFile, - formatFileSize, - - // 预签名上传方法(透传) - getPresignStatus: presignedUpload.getStatus - } -} \ No newline at end of file diff --git a/src/composables/useInjectedDarkMode.ts b/src/composables/useInjectedDarkMode.ts new file mode 100644 index 0000000..c6497b8 --- /dev/null +++ b/src/composables/useInjectedDarkMode.ts @@ -0,0 +1,7 @@ +import { computed, inject, unref } from 'vue' +import type { Ref } from 'vue' + +export function useInjectedDarkMode() { + const injectedDarkMode = inject | boolean>('isDarkMode', false) + return computed(() => Boolean(unref(injectedDarkMode))) +} diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts index bd3e153..2ff2035 100644 --- a/src/composables/usePresignedUpload.ts +++ b/src/composables/usePresignedUpload.ts @@ -1,7 +1,5 @@ import { ref, computed, readonly } from 'vue' import { PresignUploadService } from '@/services' -import { useAlertStore } from '@/stores/alertStore' -import { FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' import type { PresignUploadStatus, PresignUploadMode, @@ -10,29 +8,9 @@ import type { PresignStatusResponse, UploadProgress, ExpireStyle, - ConfigState + AlertType } from '@/types' -import axios from 'axios' - -/** - * 获取最大文件大小限制(字节) - * 优先从后端配置获取,否则使用默认值 - */ -function getMaxFileSize(): number { - try { - const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (configStr) { - const config = JSON.parse(configStr) as Partial - if (config.uploadSize && config.uploadSize > 0) { - // uploadSize 单位是字节 - return config.uploadSize - } - } - } catch { - // 解析失败时使用默认值 - } - return FILE_SIZE_LIMITS.MAX_FILE_SIZE -} +import { getErrorMessage } from '@/utils/common' // 预签名上传状态常量 export const PRESIGN_UPLOAD_STATUS = { @@ -48,13 +26,24 @@ export const PRESIGN_UPLOAD_STATUS = { const DEFAULT_EXPIRE_VALUE = 1 const DEFAULT_EXPIRE_STYLE: ExpireStyle = 'day' +type ErrorWithResponse = { + response?: { + status?: number + } +} + +type PresignedUploadNotifier = (message: string, type: AlertType) => void + +type UsePresignedUploadOptions = { + getMaxFileSize?: () => number + notify?: PresignedUploadNotifier +} + /** * 预签名上传 Composable * 支持 S3 直传模式和服务器代理模式 */ -export function usePresignedUpload() { - const alertStore = useAlertStore() - +export function usePresignedUpload(options: UsePresignedUploadOptions = {}) { // 状态管理 const presignStatus = ref(PRESIGN_UPLOAD_STATUS.IDLE) const uploadSession = ref(null) @@ -74,15 +63,23 @@ export function usePresignedUpload() { const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR) const currentMode = computed(() => uploadSession.value?.mode ?? null) + const notify: PresignedUploadNotifier = (message, type) => { + options.notify?.(message, type) + } + /** * 文件大小验证 */ const validateFileSize = (file: File): boolean => { - const maxFileSize = getMaxFileSize() + const maxFileSize = options.getMaxFileSize?.() + if (!maxFileSize) { + return true + } + if (file.size > maxFileSize) { const maxSizeMB = Math.round(maxFileSize / 1024 / 1024) errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB` - alertStore.showAlert(errorMessage.value, 'error') + notify(errorMessage.value, 'error') return false } return true @@ -113,34 +110,16 @@ export function usePresignedUpload() { */ const handleUploadError = (error: unknown): void => { presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR - - if (axios.isAxiosError(error)) { - const status = error.response?.status - const detail = error.response?.data?.detail - - switch (status) { - case 400: - errorMessage.value = detail || '请求参数错误' - break - case 403: - errorMessage.value = detail || '操作被禁止' - break - case 404: - errorMessage.value = '上传会话不存在或已过期' - break - case 500: - errorMessage.value = '服务器错误,请稍后重试' - break - default: - errorMessage.value = detail || '上传失败,请重试' - } - } else if (error instanceof Error) { - errorMessage.value = error.message - } else { - errorMessage.value = '未知错误' - } - - alertStore.showAlert(errorMessage.value, 'error') + const status = (error as ErrorWithResponse)?.response?.status + const fallback = + status === 404 + ? '上传会话不存在或已过期' + : status === 500 + ? '服务器错误,请稍后重试' + : '上传失败,请重试' + + errorMessage.value = getErrorMessage(error, fallback) + notify(errorMessage.value, 'error') } /** @@ -211,7 +190,7 @@ export function usePresignedUpload() { total: file.size, percentage: 100 } - alertStore.showAlert('文件上传成功!', 'success') + notify('文件上传成功!', 'success') return uploadedCode.value } else { throw new Error(confirmResponse.message || '确认上传失败') @@ -249,7 +228,7 @@ export function usePresignedUpload() { total: file.size, percentage: 100 } - alertStore.showAlert('文件上传成功!', 'success') + notify('文件上传成功!', 'success') return uploadedCode.value } else { throw new Error(response.message || '代理上传失败') @@ -290,7 +269,7 @@ export function usePresignedUpload() { try { await PresignUploadService.cancelUpload(uploadSession.value.upload_id) - alertStore.showAlert('上传已取消', 'info') + notify('上传已取消', 'info') } catch (error) { // 取消失败时静默处理,因为会话可能已过期 console.warn('取消上传失败:', error) @@ -313,7 +292,7 @@ export function usePresignedUpload() { // 检查会话是否过期 if (response.detail.is_expired) { errorMessage.value = '上传会话已过期' - alertStore.showAlert(errorMessage.value, 'warning') + notify(errorMessage.value, 'warning') } return response.detail } diff --git a/src/composables/usePublicConfigBootstrap.ts b/src/composables/usePublicConfigBootstrap.ts new file mode 100644 index 0000000..43b5482 --- /dev/null +++ b/src/composables/usePublicConfigBootstrap.ts @@ -0,0 +1,25 @@ +import { ConfigService } from '@/services' +import { useAlertStore } from '@/stores/alertStore' +import { useConfigStore } from '@/stores/configStore' + +export function usePublicConfigBootstrap() { + const alertStore = useAlertStore() + const configStore = useConfigStore() + + const syncPublicConfig = async () => { + const res = await ConfigService.getUserConfig() + + if (res.code !== 200 || !res.detail) { + return + } + + const notifyMessage = configStore.applyRemoteConfig(res.detail) + if (notifyMessage) { + alertStore.showAlert(notifyMessage, 'success') + } + } + + return { + syncPublicConfig + } +} diff --git a/src/composables/useRetrieveFlow.ts b/src/composables/useRetrieveFlow.ts new file mode 100644 index 0000000..60dc4ef --- /dev/null +++ b/src/composables/useRetrieveFlow.ts @@ -0,0 +1,173 @@ +import { ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { storeToRefs } from 'pinia' +import { FileService } from '@/services' +import { useAlertStore } from '@/stores/alertStore' +import { useFileDataStore } from '@/stores/fileData' +import type { ReceivedFileRecord } from '@/types' +import { copyToClipboard } from '@/utils/clipboard' +import { getErrorMessage } from '@/utils/common' +import { renderMarkdownPreview } from '@/utils/content-preview' +import { downloadReceivedRecord } from '@/utils/download-action' + +type InputStatus = { + readonly: boolean + loading: boolean +} + +export function useRetrieveFlow() { + const { t } = useI18n() + const alertStore = useAlertStore() + const fileStore = useFileDataStore() + const { receiveData: records } = storeToRefs(fileStore) + + const code = ref('') + const inputStatus = ref({ + readonly: false, + loading: false + }) + const error = ref('') + const selectedRecord = ref(null) + const showDrawer = ref(false) + const showPreview = ref(false) + const renderedContent = ref('') + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 ' + t('fileSize.bytes') + const k = 1024 + const sizes = [ + t('fileSize.bytes'), + t('fileSize.kb'), + t('fileSize.mb'), + t('fileSize.gb'), + t('fileSize.tb') + ] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const createRecord = (detail: { + code: string + name: string + text: string + size: number + }): ReceivedFileRecord => { + const isFile = detail.text.startsWith('/share/download') || detail.name !== 'Text' + return { + id: Date.now(), + code: detail.code, + filename: detail.name, + size: formatFileSize(detail.size), + downloadUrl: isFile ? detail.text : null, + content: isFile ? null : detail.text, + date: new Date().toLocaleString() + } + } + + const handleSubmit = async () => { + if (code.value.length !== 5) { + alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error') + return + } + + inputStatus.value.readonly = true + inputStatus.value.loading = true + + try { + const res = await FileService.selectFile(code.value) + if (res.code === 200 && res.detail) { + const newFileData = createRecord(res.detail) + if (!fileStore.receiveData.some((file) => file.code === newFileData.code)) { + fileStore.addReceiveData(newFileData) + } + selectedRecord.value = newFileData + if (newFileData.content) { + showPreview.value = true + } + alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success') + } else { + alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error') + } + } catch (err: unknown) { + const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError')) + alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error') + } finally { + inputStatus.value.readonly = false + inputStatus.value.loading = false + code.value = '' + } + } + + const copyContent = async () => { + if (selectedRecord.value?.content) { + await copyToClipboard(selectedRecord.value.content, { + successMsg: t('fileRecord.contentCopied'), + errorMsg: t('fileRecord.copyFailed'), + notify: (message, type) => alertStore.showAlert(message, type) + }) + } + } + + const viewDetails = (record: ReceivedFileRecord) => { + selectedRecord.value = record + } + + const closeDetails = () => { + selectedRecord.value = null + } + + const deleteRecord = (id: number) => { + const index = records.value.findIndex((record) => record.id === id) + if (index !== -1) { + fileStore.deleteReceiveData(index) + } + } + + const toggleDrawer = () => { + showDrawer.value = !showDrawer.value + } + + const downloadRecord = (record: ReceivedFileRecord) => { + downloadReceivedRecord(record) + } + + const showContentPreview = () => { + showPreview.value = true + } + + const closeContentPreview = () => { + showPreview.value = false + } + + watch( + () => selectedRecord.value?.content, + async (content) => { + if (content) { + renderedContent.value = await renderMarkdownPreview(content) + } else { + renderedContent.value = '' + } + }, + { immediate: true } + ) + + return { + code, + inputStatus, + error, + records, + selectedRecord, + showDrawer, + showPreview, + renderedContent, + closeContentPreview, + closeDetails, + copyContent, + deleteRecord, + downloadRecord, + handleSubmit, + showContentPreview, + toggleDrawer, + viewDetails + } +} diff --git a/src/composables/useRouteLoading.ts b/src/composables/useRouteLoading.ts new file mode 100644 index 0000000..6c3592f --- /dev/null +++ b/src/composables/useRouteLoading.ts @@ -0,0 +1,44 @@ +import { onUnmounted, ref } from 'vue' +import type { Router } from 'vue-router' + +const ROUTE_LOADING_DELAY = 200 + +export function useRouteLoading(router: Router) { + const isLoading = ref(false) + let loadingTimer: number | null = null + let cleanupBeforeGuard: (() => void) | null = null + let cleanupAfterHook: (() => void) | null = null + + const clearLoadingTimer = () => { + if (loadingTimer !== null) { + window.clearTimeout(loadingTimer) + loadingTimer = null + } + } + + const setupRouteLoading = () => { + cleanupBeforeGuard = router.beforeEach((to) => { + clearLoadingTimer() + isLoading.value = to.meta.showRouteLoading !== false + }) + + cleanupAfterHook = router.afterEach(() => { + clearLoadingTimer() + loadingTimer = window.setTimeout(() => { + isLoading.value = false + loadingTimer = null + }, ROUTE_LOADING_DELAY) + }) + } + + onUnmounted(() => { + clearLoadingTimer() + cleanupBeforeGuard?.() + cleanupAfterHook?.() + }) + + return { + isLoading, + setupRouteLoading + } +} diff --git a/src/composables/useSendFlow.ts b/src/composables/useSendFlow.ts new file mode 100644 index 0000000..a798816 --- /dev/null +++ b/src/composables/useSendFlow.ts @@ -0,0 +1,337 @@ +import { computed, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { useAlertStore } from '@/stores/alertStore' +import { useAdminStore } from '@/stores/adminStore' +import { useConfigStore } from '@/stores/configStore' +import { useFileDataStore } from '@/stores/fileData' +import type { SendType, SentFileRecord } from '@/types' +import { getClipboardFile, insertTextAtSelection } from '@/utils/clipboard-paste' +import { getErrorMessage } from '@/utils/common' +import { getStorageUnit } from '@/utils/convert' +import { calculateFileHash } from '@/utils/file-processing' +import { buildSentRecord, isExpirationWithinLimit } from '@/utils/send-record' +import { createSentRecordActions } from '@/utils/sent-record-actions' +import { useSendSubmit } from './useSendSubmit' + +export function useSendFlow() { + const { t } = useI18n() + const alertStore = useAlertStore() + const adminStore = useAdminStore() + const configStore = useConfigStore() + const fileDataStore = useFileDataStore() + const config = computed(() => configStore.config) + const sendType = ref('file') + const selectedFile = ref(null) + const selectedFiles = ref([]) + const textContent = ref('') + const expirationMethod = ref(config.value.expireStyle[0] || 'day') + const expirationValue = ref('1') + const uploadProgress = ref(0) + const showDrawer = ref(false) + const selectedRecord = ref(null) + const isSubmitting = ref(false) + const fileHash = ref('') + const sendRecords = computed(() => fileDataStore.shareData) + const uploadDescription = computed( + () => `支持各种常见格式,最大${getStorageUnit(config.value.uploadSize)}` + ) + const expirationOptions = computed(() => + config.value.expireStyle.map((value) => ({ + value, + label: getUnit(value) + })) + ) + watch( + () => config.value.expireStyle, + (expireStyle) => { + if (expireStyle.length > 0 && !expireStyle.includes(expirationMethod.value)) { + expirationMethod.value = expireStyle[0] + } + }, + { immediate: true } + ) + const notifyCopyResult = (message: string, type: 'success' | 'error') => { + alertStore.showAlert(message, type) + } + const sentRecordActions = createSentRecordActions(notifyCopyResult) + const { resetPresignUpload, submitFile, submitText } = useSendSubmit({ + getMaxFileSize: () => configStore.uploadSizeLimit, + notify: (message, type) => alertStore.showAlert(message, type), + translate: t, + onProgress: (progress) => { + uploadProgress.value = progress + }, + onHashCalculated: (hash) => { + fileHash.value = hash + } + }) + + const checkOpenUpload = () => { + if (config.value.openUpload === 0 && !adminStore.hasToken) { + alertStore.showAlert(t('send.messages.guestUploadDisabled'), 'error') + return false + } + return true + } + + const checkFileSize = (file: File) => { + if (file.size > config.value.uploadSize) { + alertStore.showAlert( + t('send.messages.fileSizeExceeded', { size: getStorageUnit(config.value.uploadSize) }), + 'error' + ) + selectedFile.value = null + return false + } + return true + } + + const checkExpirationTime = (method: string, value: string): boolean => + isExpirationWithinLimit(method, value, config.value.max_save_seconds || 0) + + const checkUpload = () => { + if (!selectedFile.value) return false + if (!checkOpenUpload()) return false + if (!checkFileSize(selectedFile.value)) return false + if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) return false + return true + } + + const handleFileSelected = async (file: File) => { + selectedFile.value = file + selectedFiles.value = [] + if (!checkOpenUpload()) return + if (!checkFileSize(file)) return + fileHash.value = await calculateFileHash(file) + } + + const handleFilesSelected = async (files: File[]) => { + if (!checkOpenUpload()) return + selectedFiles.value = files + selectedFile.value = null + fileHash.value = '' + } + + const handleFileDrop = async (event: DragEvent) => { + if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) return + const files = Array.from(event.dataTransfer.files) + if (files.length === 1) { + const file = files[0] + selectedFile.value = file + selectedFiles.value = [] + if (!checkUpload()) return + fileHash.value = await calculateFileHash(file) + } else { + if (!checkOpenUpload()) return + selectedFiles.value = files + selectedFile.value = null + fileHash.value = '' + } + } + + const handlePaste = async (event: ClipboardEvent) => { + const items = event.clipboardData?.items + if (!items) return + + const file = getClipboardFile(items) + if (file) { + if (file.size === 0) { + alertStore.showAlert(t('send.messages.emptyFileError'), 'error') + return + } + + selectedFile.value = file + if (!checkUpload()) return + + try { + fileHash.value = await calculateFileHash(file) + alertStore.showAlert( + t('send.messages.fileAddedFromClipboard', { filename: file.name }), + 'success' + ) + } catch (err) { + alertStore.showAlert(t('send.messages.fileProcessingFailed'), 'error') + console.error('File hash calculation failed:', err) + } + return + } + + const textItem = items[0] + if (!textItem) return + + sendType.value = 'text' + textItem.getAsString((str: string) => { + const trimmedStr = str.trim() + if (!trimmedStr) return + + const textareaElement = document.getElementById('text-content') as HTMLTextAreaElement + if (!textareaElement) { + textContent.value += trimmedStr + return + } + + const insertion = insertTextAtSelection({ + text: textContent.value, + insertText: trimmedStr, + selectionStart: textareaElement.selectionStart, + selectionEnd: textareaElement.selectionEnd + }) + textContent.value = insertion.value + + setTimeout(() => { + textareaElement.setSelectionRange(insertion.cursor, insertion.cursor) + textareaElement.focus() + }, 0) + }) + } + + const getUnit = (value: string = expirationMethod.value) => { + switch (value) { + case 'day': + return t('send.expiration.units.days') + case 'hour': + return t('send.expiration.units.hours') + case 'minute': + return t('send.expiration.units.minutes') + case 'count': + return t('send.expiration.units.times') + case 'forever': + return t('send.expiration.units.forever') + default: + return '' + } + } + + const handleSubmit = async () => { + if (isSubmitting.value) return + isSubmitting.value = true + + try { + if (sendType.value === 'file' && !selectedFile.value && selectedFiles.value.length === 0) { + alertStore.showAlert(t('send.messages.selectFile'), 'error') + return + } + if (sendType.value === 'text' && !textContent.value.trim()) { + alertStore.showAlert(t('send.messages.enterText'), 'error') + return + } + if (!checkOpenUpload()) { + return + } + if (expirationMethod.value !== 'forever' && !expirationValue.value) { + alertStore.showAlert(t('send.messages.enterExpirationValue'), 'error') + return + } + + if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) { + const maxDays = Math.floor(config.value.max_save_seconds / 86400) + alertStore.showAlert(t('send.messages.expirationTooLong', { days: maxDays }), 'error') + return + } + + const expireValue = expirationValue.value ? parseInt(expirationValue.value) : 1 + let response + if (sendType.value === 'file') { + response = await submitFile({ + selectedFile: selectedFile.value, + selectedFiles: selectedFiles.value, + expireValue, + expireStyle: expirationMethod.value, + enableChunk: Boolean(config.value.enableChunk), + validateFileSize: checkFileSize + }) + } else { + response = await submitText({ + text: textContent.value, + expireValue, + expireStyle: expirationMethod.value + }) + } + + if (!response) return + + if (response?.code === 200) { + const newRecord = buildSentRecord({ + response, + sendType: sendType.value, + textContent: textContent.value, + selectedFile: selectedFile.value, + selectedFiles: selectedFiles.value, + expirationMethod: expirationMethod.value, + expirationValue: expirationValue.value, + translate: t, + getUnit + }) + fileDataStore.addShareDataRecord(newRecord) + alertStore.showAlert( + t('send.messages.sendSuccess', { code: newRecord.retrieveCode }), + 'success' + ) + selectedFile.value = null + selectedFiles.value = [] + textContent.value = '' + uploadProgress.value = 0 + resetPresignUpload() + selectedRecord.value = newRecord + await sentRecordActions.copyLink(newRecord) + } else { + throw new Error(t('send.messages.serverError')) + } + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('send.messages.sendFailed')), 'error') + } finally { + uploadProgress.value = 0 + isSubmitting.value = false + } + } + + const toggleDrawer = () => { + showDrawer.value = !showDrawer.value + } + + const viewDetails = (record: SentFileRecord) => { + selectedRecord.value = record + } + + const closeDetails = () => { + selectedRecord.value = null + } + + const deleteRecord = (id: number) => { + const index = fileDataStore.shareData.findIndex((record) => record.id === id) + if (index !== -1) { + fileDataStore.deleteShareData(index) + } + } + + return { + config, + sendType, + selectedFile, + selectedFiles, + textContent, + expirationMethod, + expirationValue, + uploadProgress, + showDrawer, + selectedRecord, + isSubmitting, + sendRecords, + uploadDescription, + expirationOptions, + closeDetails, + deleteRecord, + copySentRecordCode: sentRecordActions.copyCode, + copySentRecordLink: sentRecordActions.copyLink, + copySentRecordWgetCommand: sentRecordActions.copyWgetCommand, + getQRCodeValue: sentRecordActions.getQRCodeValue, + getUnit, + handleFileDrop, + handleFileSelected, + handleFilesSelected, + handlePaste, + handleSubmit, + toggleDrawer, + viewDetails + } +} diff --git a/src/composables/useSendSubmit.ts b/src/composables/useSendSubmit.ts new file mode 100644 index 0000000..958b6ca --- /dev/null +++ b/src/composables/useSendSubmit.ts @@ -0,0 +1,122 @@ +import { FileService, uploadChunkedFile } from '@/services' +import type { AlertType, ApiResponse, ExpireStyle, UploadProgress } from '@/types' +import { calculateFileHash, packFilesAsZip } from '@/utils/file-processing' +import { usePresignedUpload } from './usePresignedUpload' + +type Translate = ( + key: string, + params?: Record +) => string + +type UseSendSubmitOptions = { + getMaxFileSize: () => number + notify: (message: string, type: AlertType) => void + translate: Translate + onProgress: (progress: number) => void + onHashCalculated: (hash: string) => void +} + +type SubmitFileOptions = { + selectedFile: File | null + selectedFiles: File[] + expireValue: number + expireStyle: string + enableChunk: boolean + validateFileSize: (file: File) => boolean +} + +type SubmitTextOptions = { + text: string + expireValue: number + expireStyle: string +} + +export function useSendSubmit(options: UseSendSubmitOptions) { + const { uploadFile: presignUploadFile, reset: resetPresignUpload } = usePresignedUpload({ + getMaxFileSize: options.getMaxFileSize, + notify: options.notify + }) + + const handleChunkUpload = async ( + file: File, + expireValue: number, + expireStyle: string + ): Promise => { + return uploadChunkedFile(file, { + expireValue, + expireStyle, + onHashCalculated: options.onHashCalculated, + onProgress: (progress: UploadProgress) => { + options.onProgress(progress.percentage) + }, + messages: { + initFailed: options.translate('send.messages.initChunkUploadFailed'), + chunkFailed: (index) => options.translate('send.messages.chunkUploadFailed', { index }), + completeFailed: options.translate('send.messages.completeUploadFailed') + } + }) + } + + const handlePresignedUpload = async ( + file: File, + expireValue: number, + expireStyle: string + ): Promise> => { + const code = await presignUploadFile(file, { + expireValue, + expireStyle: expireStyle as ExpireStyle, + onProgress: (progress) => { + options.onProgress(progress.percentage) + } + }) + + if (!code) { + throw new Error(options.translate('send.messages.uploadFailed')) + } + + return { + code: 200, + detail: { + code, + name: file.name + } + } + } + + const submitFile = async ({ + selectedFile, + selectedFiles, + expireValue, + expireStyle, + enableChunk, + validateFileSize + }: SubmitFileOptions): Promise => { + let fileToUpload = selectedFile + + if (selectedFiles.length > 0) { + options.notify('正在打包文件...', 'success') + fileToUpload = await packFilesAsZip(selectedFiles) + if (!validateFileSize(fileToUpload)) { + return null + } + options.onHashCalculated(await calculateFileHash(fileToUpload)) + } + + if (!fileToUpload) { + throw new Error(options.translate('send.messages.selectFile')) + } + + return enableChunk + ? handleChunkUpload(fileToUpload, expireValue, expireStyle) + : handlePresignedUpload(fileToUpload, expireValue, expireStyle) + } + + const submitText = ({ text, expireValue, expireStyle }: SubmitTextOptions) => + FileService.uploadText(text, expireValue, expireStyle) + + return { + resetPresignUpload, + submitFile, + submitText + } +} diff --git a/src/composables/useSystemConfig.ts b/src/composables/useSystemConfig.ts index 0b7fe04..5b8ec87 100644 --- a/src/composables/useSystemConfig.ts +++ b/src/composables/useSystemConfig.ts @@ -1,61 +1,54 @@ import { ref, computed } from 'vue' import { ConfigService } from '@/services' import { useAlertStore } from '@/stores/alertStore' -import type { SystemConfig } from '@/types' -import { STORAGE_KEYS, DEFAULT_CONFIG } from '@/constants' +import { useConfigStore } from '@/stores/configStore' +import type { ConfigState } from '@/types' +import { DEFAULT_CONFIG_STATE, readStoredConfig } from '@/utils/config-storage' +import { getErrorMessage } from '@/utils/common' +import { + buildConfigSubmitPayload, + bytesToFileSizeForm, + secondsToSaveTimeForm, + type FileSizeUnit, + type SaveTimeUnit +} from '@/utils/config-form' + +type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload' export function useSystemConfig() { const alertStore = useAlertStore() + const configStore = useConfigStore() // 状态管理 - const config = ref({ ...DEFAULT_CONFIG }) + const config = ref({ ...DEFAULT_CONFIG_STATE }) const isLoading = ref(false) + const fileSize = ref(1) + const sizeUnit = ref('MB') + const saveTime = ref(1) + const saveTimeUnit = ref('天') // 从本地存储获取配置 - const getStoredConfig = (): SystemConfig | null => { - try { - const storedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (storedConfig) { - return JSON.parse(storedConfig) - } - } catch (error) { - console.error('解析本地配置失败:', error) - } - return null + const getStoredConfig = (): ConfigState | null => { + return readStoredConfig() } // 保存配置到本地存储 - const saveConfigToStorage = (configData: SystemConfig) => { - try { - localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configData)) - } catch (error) { - console.error('保存配置到本地存储失败:', error) - } + const saveConfigToStorage = (configData: ConfigState) => { + configStore.updateConfig(configData) } // 获取系统配置 - const fetchConfig = async (): Promise => { + const fetchConfig = async (): Promise => { try { isLoading.value = true const response = await ConfigService.getConfig() if (response.code === 200 && response.detail) { - config.value = { ...DEFAULT_CONFIG, ...response.detail } - saveConfigToStorage(config.value) - - // 处理通知 - if (response.detail.notify_title && response.detail.notify_content) { - const notifyKey = response.detail.notify_title + response.detail.notify_content - const lastNotify = localStorage.getItem(STORAGE_KEYS.NOTIFY) - - if (lastNotify !== notifyKey) { - localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey) - alertStore.showAlert( - `${response.detail.notify_title}: ${response.detail.notify_content}`, - 'success' - ) - } + config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail } + const notifyMessage = configStore.applyRemoteConfig(config.value) + if (notifyMessage) { + alertStore.showAlert(notifyMessage, 'success') } return config.value @@ -70,8 +63,7 @@ export function useSystemConfig() { return config.value } - const errorMessage = error instanceof Error ? error.message : '获取配置失败' - alertStore.showAlert(errorMessage, 'error') + alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error') return null } finally { isLoading.value = false @@ -79,7 +71,7 @@ export function useSystemConfig() { } // 更新系统配置 - const updateConfig = async (newConfig: Partial): Promise => { + const updateConfig = async (newConfig: Partial): Promise => { try { isLoading.value = true @@ -94,13 +86,42 @@ export function useSystemConfig() { throw new Error(response.message || '更新配置失败') } } catch (error) { - const errorMessage = error instanceof Error ? error.message : '更新配置失败' - alertStore.showAlert(errorMessage, 'error') + alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error') return false } finally { isLoading.value = false } } + + const toggleConfigFlag = (key: ConfigFlagKey) => { + config.value[key] = config.value[key] === 1 ? 0 : 1 + } + + const syncConfigForm = (nextConfig: ConfigState) => { + const sizeForm = bytesToFileSizeForm(nextConfig.uploadSize) + fileSize.value = sizeForm.value + sizeUnit.value = sizeForm.unit + + const saveTimeForm = secondsToSaveTimeForm(nextConfig.max_save_seconds) + saveTime.value = saveTimeForm.value + saveTimeUnit.value = saveTimeForm.unit + } + + const refreshConfig = async () => { + const latestConfig = await fetchConfig() + if (latestConfig) { + syncConfigForm(latestConfig) + } + } + + const submitConfig = () => + updateConfig( + buildConfigSubmitPayload( + config.value, + { value: fileSize.value, unit: sizeUnit.value }, + { value: saveTime.value, unit: saveTimeUnit.value } + ) + ) // 初始化配置 const initConfig = async () => { @@ -116,17 +137,21 @@ export function useSystemConfig() { // 计算属性 const maxFileSizeMB = computed(() => { - return Math.round(config.value.maxFileSize / 1024 / 1024) + return Math.round(config.value.uploadSize / 1024 / 1024) }) const isConfigLoaded = computed(() => { - return config.value.name !== DEFAULT_CONFIG.name || !isLoading.value + return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value }) return { // 状态 config, isLoading, + fileSize, + sizeUnit, + saveTime, + saveTimeUnit, // 计算属性 maxFileSizeMB, @@ -135,8 +160,11 @@ export function useSystemConfig() { // 方法 fetchConfig, updateConfig, + refreshConfig, + submitConfig, + toggleConfigFlag, initConfig, getStoredConfig, saveConfigToStorage } -} \ No newline at end of file +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index aab65aa..63fe823 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -1,6 +1,7 @@ import { ref, computed } from 'vue' -import { STORAGE_KEYS, THEME_MODES } from '@/constants' +import { THEME_MODES } from '@/constants' import type { ThemeMode } from '@/types' +import { readStoredThemeMode, writeStoredThemeMode } from '@/utils/preference-storage' export function useTheme() { // 状态管理 @@ -14,7 +15,7 @@ export function useTheme() { // 从本地存储获取用户之前的选择 const getUserPreference = (): ThemeMode | null => { - const storedPreference = localStorage.getItem(STORAGE_KEYS.COLOR_MODE) + const storedPreference = readStoredThemeMode() if (storedPreference && Object.values(THEME_MODES).includes(storedPreference as ThemeMode)) { return storedPreference as ThemeMode } @@ -24,7 +25,7 @@ export function useTheme() { // 设置颜色模式 const setThemeMode = (mode: ThemeMode) => { themeMode.value = mode - localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode) + writeStoredThemeMode(mode) // 根据模式设置实际的暗色模式状态 if (mode === THEME_MODES.SYSTEM) { @@ -135,4 +136,4 @@ export function useTheme() { initTheme, checkSystemColorScheme } -} \ No newline at end of file +} diff --git a/src/constants/index.ts b/src/constants/index.ts index 9585dc7..498481f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -71,6 +71,16 @@ export const ROUTES = { SETTINGS: '/admin/settings' } as const +export const ROUTE_NAMES = { + RETRIEVE: 'Retrieve', + SEND: 'Send', + ADMIN: 'Manage', + LOGIN: 'Login', + DASHBOARD: 'Dashboard', + FILE_MANAGE: 'FileManage', + SETTINGS: 'Settings' +} as const + // 正则表达式 export const REGEX_PATTERNS = { EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, @@ -85,4 +95,4 @@ export const DEFAULT_CONFIG = { maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE, allowedFileTypes: ['*'] as string[], expireDays: 7 -} \ No newline at end of file +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index d8a416d..61c2821 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,10 +1,11 @@ import { createI18n } from 'vue-i18n' import zhCN from './locales/zh-CN' import enUS from './locales/en-US' +import { readStoredLocale, writeStoredLocale } from '@/utils/preference-storage' // 获取浏览器语言设置 const getDefaultLocale = (): string => { - const savedLocale = localStorage.getItem('locale') + const savedLocale = readStoredLocale() if (savedLocale) { return savedLocale } @@ -34,7 +35,7 @@ export default i18n // 导出切换语言的函数 export const setLocale = (locale: string) => { i18n.global.locale.value = locale as 'zh-CN' | 'en-US' - localStorage.setItem('locale', locale) + writeStoredLocale(locale) document.documentElement.lang = locale } @@ -47,4 +48,4 @@ export const getCurrentLocale = () => { export const availableLocales = [ { code: 'zh-CN', name: '中文' }, { code: 'en-US', name: 'English' } -] \ No newline at end of file +] diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index aa4cf60..c0aa755 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -58,14 +58,42 @@ export default { title: 'Dashboard', totalFiles: 'Total Files', storageSpace: 'Storage Space', - activeUsers: 'Active Users', - systemStatus: 'System Status', - yesterday: 'Yesterday:', - today: 'Today:', - weeklyChange: '↓ 5% from last week', - normal: 'Normal', - serverUptime: 'Server Uptime:', - version: 'Version v2.2.1 Updated: 2025-09-04' + todayShares: 'Today Shares', + totalRetrievals: 'Total Retrievals', + activeFiles: 'Active files: {count}', + todayIncrease: 'Today added: {count}', + yesterdayShares: 'Yesterday: {count}', + serverUptime: 'Uptime', + refresh: 'Refresh', + fileHealth: 'File Health', + fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', + activeFileRatio: 'Active Ratio', + fileShareRatio: 'File Ratio', + textShareRatio: 'Text Ratio', + binaryFiles: '{count} file shares', + textShares: '{count} text shares', + expiredFiles: 'Expired Files', + needCleanup: 'Clean up in file management', + chunkedFiles: 'Chunked Files', + storagePolicy: 'Storage & Upload Policy', + storagePolicyDesc: 'Current settings that affect upload behavior.', + storageBackend: 'Storage Backend', + singleFileLimit: 'Single File Limit', + guestUpload: 'Guest Upload', + maxSaveTime: 'Max Retention', + noSaveLimit: 'Unlimited', + todayCapacityReference: 'Today Size / Single File Limit', + fileTypeDistribution: 'Type Distribution', + textType: 'Text', + recentFiles: 'Recent Shares', + recentFilesDesc: 'Recently created share records for quick status checks.', + available: 'Available', + table: { + file: 'File', + size: 'Size', + usage: 'Retrievals', + status: 'Status' + } }, fileManage: { title: 'File Management' @@ -458,14 +486,42 @@ export default { title: 'Dashboard', totalFiles: 'Total Files', storageSpace: 'Storage Space', - activeUsers: 'Active Users', - systemStatus: 'System Status', - yesterday: 'Yesterday:', - today: 'Today:', - weeklyChange: '↓ 5% from last week', - normal: 'Normal', - serverUptime: 'Server Uptime:', - version: 'Version v2.2.1 Updated: 2025-09-04' + todayShares: 'Today Shares', + totalRetrievals: 'Total Retrievals', + activeFiles: 'Active files: {count}', + todayIncrease: 'Today added: {count}', + yesterdayShares: 'Yesterday: {count}', + serverUptime: 'Uptime', + refresh: 'Refresh', + fileHealth: 'File Health', + fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', + activeFileRatio: 'Active Ratio', + fileShareRatio: 'File Ratio', + textShareRatio: 'Text Ratio', + binaryFiles: '{count} file shares', + textShares: '{count} text shares', + expiredFiles: 'Expired Files', + needCleanup: 'Clean up in file management', + chunkedFiles: 'Chunked Files', + storagePolicy: 'Storage & Upload Policy', + storagePolicyDesc: 'Current settings that affect upload behavior.', + storageBackend: 'Storage Backend', + singleFileLimit: 'Single File Limit', + guestUpload: 'Guest Upload', + maxSaveTime: 'Max Retention', + noSaveLimit: 'Unlimited', + todayCapacityReference: 'Today Size / Single File Limit', + fileTypeDistribution: 'Type Distribution', + textType: 'Text', + recentFiles: 'Recent Shares', + recentFilesDesc: 'Recently created share records for quick status checks.', + available: 'Available', + table: { + file: 'File', + size: 'Size', + usage: 'Retrievals', + status: 'Status' + } }, fileManage: { title: 'File Management', @@ -493,6 +549,7 @@ export default { }, updateFailed: 'Update failed', deleteFailed: 'Delete failed', + deleteConfirm: 'Delete this file? This action cannot be undone.', loadFileListFailed: 'Failed to load file list' }, login: { diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b9c7c47..ca5e9b8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -12,6 +12,7 @@ export default { next: '下一页', previous: '上一页', loading: '加载中...', + noData: '暂无数据', success: '成功', error: '错误', warning: '警告', @@ -57,14 +58,42 @@ export default { title: '仪表盘', totalFiles: '总文件数', storageSpace: '存储空间', - activeUsers: '活跃用户', - systemStatus: '系统状态', - yesterday: '昨天:', - today: '今天:', - weeklyChange: '↓ 5% 较上周', - normal: '正常', - serverUptime: '服务器运行时间:', - version: '版本 v2.2.1 更新时间:2025-09-04' + todayShares: '今日分享', + totalRetrievals: '累计取件', + activeFiles: '有效文件:{count}', + todayIncrease: '今日新增容量:{count}', + yesterdayShares: '昨日分享:{count}', + serverUptime: '运行时间', + refresh: '刷新数据', + fileHealth: '文件健康', + fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', + activeFileRatio: '有效占比', + fileShareRatio: '文件占比', + textShareRatio: '文本占比', + binaryFiles: '文件分享 {count} 个', + textShares: '文本分享 {count} 条', + expiredFiles: '已过期文件', + needCleanup: '可在文件管理中清理', + chunkedFiles: '分片上传文件', + storagePolicy: '存储与上传策略', + storagePolicyDesc: '当前后台配置对上传链路的影响。', + storageBackend: '存储后端', + singleFileLimit: '单文件上限', + guestUpload: '游客上传', + maxSaveTime: '最长保存', + noSaveLimit: '不限制', + todayCapacityReference: '今日容量 / 单文件上限', + fileTypeDistribution: '类型分布', + textType: '文本', + recentFiles: '最近分享', + recentFilesDesc: '最近创建的分享记录,便于快速核对状态。', + available: '可取件', + table: { + file: '文件', + size: '大小', + usage: '取件', + status: '状态' + } }, fileManage: { title: '文件管理' @@ -422,14 +451,42 @@ export default { title: '仪表盘', totalFiles: '总文件数', storageSpace: '存储空间', - activeUsers: '活跃用户', - systemStatus: '系统状态', - yesterday: '昨天:', - today: '今天:', - weeklyChange: '↓ 5% 较上周', - normal: '正常', - serverUptime: '服务器运行时间:', - version: '版本 v2.2.1 更新时间:2025-09-04' + todayShares: '今日分享', + totalRetrievals: '累计取件', + activeFiles: '有效文件:{count}', + todayIncrease: '今日新增容量:{count}', + yesterdayShares: '昨日分享:{count}', + serverUptime: '运行时间', + refresh: '刷新数据', + fileHealth: '文件健康', + fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', + activeFileRatio: '有效占比', + fileShareRatio: '文件占比', + textShareRatio: '文本占比', + binaryFiles: '文件分享 {count} 个', + textShares: '文本分享 {count} 条', + expiredFiles: '已过期文件', + needCleanup: '可在文件管理中清理', + chunkedFiles: '分片上传文件', + storagePolicy: '存储与上传策略', + storagePolicyDesc: '当前后台配置对上传链路的影响。', + storageBackend: '存储后端', + singleFileLimit: '单文件上限', + guestUpload: '游客上传', + maxSaveTime: '最长保存', + noSaveLimit: '不限制', + todayCapacityReference: '今日容量 / 单文件上限', + fileTypeDistribution: '类型分布', + textType: '文本', + recentFiles: '最近分享', + recentFilesDesc: '最近创建的分享记录,便于快速核对状态。', + available: '可取件', + table: { + file: '文件', + size: '大小', + usage: '取件', + status: '状态' + } }, fileManage: { title: '文件管理', @@ -457,6 +514,7 @@ export default { }, updateFailed: '更新失败', deleteFailed: '删除失败', + deleteConfirm: '确认删除这个文件?此操作不可撤销。', loadFileListFailed: '加载文件列表失败' }, systemSettings: { diff --git a/src/layout/AdminLayout/AdminLayout.vue b/src/layout/AdminLayout/AdminLayout.vue index fc6dfda..6eb588f 100644 --- a/src/layout/AdminLayout/AdminLayout.vue +++ b/src/layout/AdminLayout/AdminLayout.vue @@ -44,11 +44,11 @@ @@ -117,8 +117,9 @@ import { LayoutDashboardIcon, LogOutIcon } from 'lucide-vue-next' -import { useRouter } from 'vue-router' +import { RouterLink, useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' +import { ROUTE_NAMES, ROUTES } from '@/constants' import { useAdminStore } from '@/stores/adminStore' interface MenuItem { @@ -129,23 +130,29 @@ interface MenuItem { } const router = useRouter() +const route = useRoute() const { t } = useI18n() const isDarkMode = inject('isDarkMode') const adminStore = useAdminStore() const menuItems: MenuItem[] = [ { - id: 'Dashboard', + id: ROUTE_NAMES.DASHBOARD, name: t('admin.dashboard.title'), icon: LayoutDashboardIcon, - redirect: '/admin/dashboard' + redirect: ROUTES.DASHBOARD }, { - id: 'FileManage', + id: ROUTE_NAMES.FILE_MANAGE, name: t('admin.fileManage.title'), icon: FolderIcon, - redirect: '/admin/files' + redirect: ROUTES.FILE_MANAGE }, - { id: 'Settings', name: t('admin.settings.title'), icon: CogIcon, redirect: '/admin/settings' } + { + id: ROUTE_NAMES.SETTINGS, + name: t('admin.settings.title'), + icon: CogIcon, + redirect: ROUTES.SETTINGS + } ] const isSidebarOpen = ref(true) @@ -171,33 +178,10 @@ onUnmounted(() => { window.removeEventListener('resize', handleResize) }) -// 分页参数 -const params = ref({ - page: 1, - size: 10, - total: 0 -}) - -// 加载文件列表 -const loadFiles = async () => { - try { - params.value.total = 85 - // 更新文件列表数据... - } catch (error) { - console.error('加载文件列表失败:', error) - // 处理错误... - } -} - -// 初始加载 -onMounted(() => { - loadFiles() -}) - // 登出处理 const handleLogout = () => { adminStore.logout() - router.push('/login') + router.push(ROUTES.LOGIN) } diff --git a/src/router/index.ts b/src/router/index.ts index 2891e0a..b918d6e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,50 +1,104 @@ import { createRouter, createWebHashHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' +import { ROUTE_NAMES, ROUTES } from '@/constants' +import { readStoredToken } from '@/utils/auth-storage' -// 预加载 SendFileView 组件 -const SendFileView = () => import('../views/SendFileView.vue') -const router = createRouter({ - history: createWebHashHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'Retrieve', - component: () => import('@/views/RetrievewFileView.vue') - }, - { - path: '/send', - name: 'Send', - component: SendFileView - }, - { - path: '/admin', - name: 'Manage', - component: () => import('@/layout/AdminLayout/AdminLayout.vue'), - redirect: '/admin/dashboard', - children: [ - { - path: '/admin/dashboard', - name: 'Dashboard', - component: () => import('@/views/manage/DashboardView.vue') - }, - { - path: '/admin/files', - name: 'FileManage', - component: () => import('@/views/manage/FileManageView.vue') - }, - { - path: '/admin/settings', - name: 'Settings', - component: () => import('@/views/manage/SystemSettingsView.vue') +const publicPageMeta = { + showGlobalControls: true, + showRouteLoading: true +} + +const adminPageMeta = { + requiresAuth: true, + showGlobalControls: false, + showRouteLoading: true +} + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: ROUTE_NAMES.RETRIEVE, + component: () => import('@/views/RetrievewFileView.vue'), + meta: { + ...publicPageMeta, + title: 'retrieve' + } + }, + { + path: ROUTES.SEND, + name: ROUTE_NAMES.SEND, + component: () => import('@/views/SendFileView.vue'), + meta: { + ...publicPageMeta, + title: 'send' + } + }, + { + path: ROUTES.ADMIN, + name: ROUTE_NAMES.ADMIN, + component: () => import('@/layout/AdminLayout/AdminLayout.vue'), + redirect: ROUTES.DASHBOARD, + meta: adminPageMeta, + children: [ + { + path: 'dashboard', + name: ROUTE_NAMES.DASHBOARD, + component: () => import('@/views/manage/DashboardView.vue'), + meta: { + ...adminPageMeta, + title: 'dashboard' + } + }, + { + path: 'files', + name: ROUTE_NAMES.FILE_MANAGE, + component: () => import('@/views/manage/FileManageView.vue'), + meta: { + ...adminPageMeta, + title: 'files' } - ] - }, - { - path: '/login', - name: 'Login', - component: () => import('@/views/manage/LoginView.vue') + }, + { + path: 'settings', + name: ROUTE_NAMES.SETTINGS, + component: () => import('@/views/manage/SystemSettingsView.vue'), + meta: { + ...adminPageMeta, + title: 'settings' + } + } + ] + }, + { + path: ROUTES.LOGIN, + name: ROUTE_NAMES.LOGIN, + component: () => import('@/views/manage/LoginView.vue'), + meta: { + showGlobalControls: true, + showRouteLoading: true, + title: 'login' } - ] + }, + { + path: '/:pathMatch(.*)*', + redirect: ROUTES.HOME + } +] + +const router = createRouter({ + history: createWebHashHistory(import.meta.env.BASE_URL), + routes }) +router.beforeEach((to) => { + if (to.meta.requiresAuth && !readStoredToken()) { + return { + path: ROUTES.LOGIN, + query: { + redirect: to.fullPath + } + } + } +}) export default router diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..47fb448 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,16 @@ +import api from './client' +import type { AdminUser, ApiResponse } from '@/types' + +export class AuthService { + static async login(password: string): Promise> { + return api.post('/admin/login', { password }) + } + + static async logout(): Promise { + return api.post('/admin/logout') + } + + static async verifyToken(): Promise> { + return api.get('/admin/verify') + } +} diff --git a/src/services/client.ts b/src/services/client.ts new file mode 100644 index 0000000..4a39bfa --- /dev/null +++ b/src/services/client.ts @@ -0,0 +1,61 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios' +import { API_STATUS_CODES, TIME_CONSTANTS } from '@/constants' +import type { ApiErrorPayload } from '@/types' +import { clearStoredToken, readStoredToken } from '@/utils/auth-storage' + +export const AUTH_EVENTS = { + UNAUTHORIZED: 'filecodebox:auth:unauthorized' +} as const + +const rawBaseURL = + import.meta.env.MODE === 'production' + ? import.meta.env.VITE_API_BASE_URL_PROD + : import.meta.env.VITE_API_BASE_URL_DEV + +export const apiBaseURL = typeof rawBaseURL === 'string' ? rawBaseURL.replace(/\/+$/, '') : '' + +const clientOptions = { + baseURL: apiBaseURL, + timeout: TIME_CONSTANTS.REQUEST_TIMEOUT, + headers: { + 'Content-Type': 'application/json' + } +} + +const apiClient = axios.create(clientOptions) +export const rawApiClient = axios.create(clientOptions) + +const attachAuthToken = (config: InternalAxiosRequestConfig) => { + const token = readStoredToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +} + +const handleAuthError = (error: AxiosError) => { + if (error.response?.status === API_STATUS_CODES.UNAUTHORIZED) { + clearStoredToken() + window.dispatchEvent(new CustomEvent(AUTH_EVENTS.UNAUTHORIZED)) + } + return Promise.reject(error) +} + +apiClient.interceptors.request.use( + attachAuthToken, + (error) => Promise.reject(error) +) + +rawApiClient.interceptors.request.use( + attachAuthToken, + (error) => Promise.reject(error) +) + +apiClient.interceptors.response.use( + (response) => response.data, + handleAuthError +) + +rawApiClient.interceptors.response.use((response) => response, handleAuthError) + +export default apiClient diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..9d60b75 --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,16 @@ +import api from './client' +import type { ApiResponse, ConfigState } from '@/types' + +export class ConfigService { + static async getConfig(): Promise> { + return api.get('/admin/config/get') + } + + static async getUserConfig(): Promise> { + return api.post('/') + } + + static async updateConfig(config: Partial): Promise { + return api.patch('/admin/config/update', config) + } +} diff --git a/src/services/file.ts b/src/services/file.ts new file mode 100644 index 0000000..979855b --- /dev/null +++ b/src/services/file.ts @@ -0,0 +1,144 @@ +import api, { rawApiClient } from './client' +import { multipartUploadConfig } from './shared' +import type { + ApiResponse, + ChunkUploadCompleteRequest, + ChunkUploadInitRequest, + ChunkUploadInitResponse, + ChunkUploadResponse, + FileEditForm, + FileInfo, + FileListResponse, + FileUploadResponse, + ShareSelectResponse, + TextSendResponse, + UploadProgress +} from '@/types' + +const urlEncodedConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } +} + +const toUrlEncodedForm = (data: Record) => { + const form = new URLSearchParams() + Object.entries(data).forEach(([key, value]) => { + form.append(key, String(value)) + }) + return form +} + +export class FileService { + static async uploadFile( + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('file', file) + + return api.post('/share/file/', formData, multipartUploadConfig(onProgress)) + } + + static async uploadText( + text: string, + expireValue = 1, + expireStyle = 'day' + ): Promise> { + const formData = new FormData() + formData.append('text', text) + formData.append('expire_value', String(expireValue)) + formData.append('expire_style', expireStyle) + return api.post('/share/text/', formData, multipartUploadConfig()) + } + + static async initChunkUpload( + request: ChunkUploadInitRequest + ): Promise> { + return api.post( + '/chunk/upload/init/', + toUrlEncodedForm({ + file_name: request.file_name, + file_size: request.file_size, + chunk_size: request.chunk_size, + file_hash: request.file_hash + }), + urlEncodedConfig + ) + } + + static async uploadChunk( + uploadId: string, + chunkIndex: number, + chunk: Blob, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('chunk', chunk) + return api.post( + `/chunk/upload/chunk/${uploadId}/${chunkIndex}`, + formData, + multipartUploadConfig(onProgress) + ) + } + + static async completeChunkUpload( + uploadId: string, + request: ChunkUploadCompleteRequest + ): Promise> { + return api.post( + `/chunk/upload/complete/${uploadId}`, + toUrlEncodedForm({ + expire_value: request.expire_value, + expire_style: request.expire_style + }), + urlEncodedConfig + ) + } + + static async selectFile(code: string): Promise> { + return api.post('/share/select/', { code }) + } + + static async getFile(code: string): Promise> { + return api.get(`/file/${code}`) + } + + static async downloadFile(code: string): Promise { + const response = await rawApiClient.get(`/download/${code}`, { + responseType: 'blob' + }) + return response.data + } + + static async getAdminFileList(params: { + page: number + size: number + keyword?: string + }): Promise> { + return api.get('/admin/file/list', { params }) + } + + static async updateFile(data: FileEditForm): Promise { + return api.patch('/admin/file/update', data) + } + + static async deleteAdminFile(id: number): Promise { + return api.delete('/admin/file/delete', { + data: { id } + }) + } + + static async downloadAdminFile( + id: number + ): Promise<{ data: Blob; headers: Record }> { + const response = await rawApiClient.get('/admin/file/download', { + params: { id }, + responseType: 'blob' + }) + return { + data: response.data, + headers: response.headers as Record + } + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 8ed9e4d..38e6d24 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,266 +1,7 @@ -// API 服务层 -import api from '@/utils/api' -import axios from 'axios' -import type { - ApiResponse, - FileInfo, - ConfigState, - AdminUser, - FileUploadResponse, - TextSendResponse, - DashboardData, - FileListResponse, - FileEditForm -} from '@/types' -import type { - UploadProgress, - PresignInitRequest, - PresignInitResponse, - PresignConfirmRequest, - PresignUploadResult, - PresignStatusResponse -} from '@/types' - -// 系统配置服务 -export class ConfigService { - static async getConfig(): Promise> { - return api.get('/admin/config/get') - } - static async getUserConfig(): Promise> { - return api.post('/') - } - static async updateConfig(config: Partial): Promise { - return api.patch('/admin/config/update', config) - } -} - -// 文件服务 -export class FileService { - static async uploadFile( - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise> { - const formData = new FormData() - formData.append('file', file) - - return api.post('/share/file/', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - timeout: 0, // 禁用超时限制 - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - } - - static async uploadText(text: string): Promise> { - return api.post('/share/text/', { content: text }) - } - - static async getFile(code: string): Promise> { - return api.get(`/file/${code}`) - } - - static async downloadFile(code: string): Promise { - const response = await api.get(`/download/${code}`, { - responseType: 'blob' - }) - return response.data - } - - static async deleteFile(fileId: string): Promise { - return api.post('/admin/file/delete', { id: fileId }) - } - - static async getFileList( - page = 1, - limit = 10 - ): Promise< - ApiResponse<{ - files: FileInfo[] - total: number - page: number - limit: number - }> - > { - return api.get('/admin/file/list', { - params: { page, size: limit } - }) - } - - // 文件管理相关方法 - static async getAdminFileList(params: { - page: number - size: number - keyword?: string - }): Promise> { - return api.get('/admin/file/list', { params }) - } - - static async updateFile(data: FileEditForm): Promise { - return api.patch('/admin/file/update', data) - } - - static async deleteAdminFile(id: number): Promise { - return api.delete('/admin/file/delete', { - data: { id } - }) - } - - static async downloadAdminFile( - id: number - ): Promise<{ data: Blob; headers: Record }> { - return api.get('/admin/file/download', { - params: { id }, - responseType: 'blob' - }) - } -} - -// 认证服务 -export class AuthService { - static async login(password: string): Promise> { - return api.post('/admin/login', { password }) - } - - static async logout(): Promise { - return api.post('/admin/logout') - } - - static async verifyToken(): Promise> { - return api.get('/admin/verify') - } -} - -// 统计服务 -export class StatsService { - static async getDashboardStats(): Promise< - ApiResponse<{ - totalFiles: number - totalDownloads: number - todayUploads: number - todayDownloads: number - storageUsed: number - recentFiles: FileInfo[] - }> - > { - return api.get('/admin/dashboard') - } - - static async getDashboard(): Promise> { - return api.get('/admin/dashboard') - } -} - -// 预签名上传服务 -export class PresignUploadService { - /** - * 初始化上传会话 - */ - static async initUpload(request: PresignInitRequest): Promise> { - return api.post('/presign/upload/init', request) - } - - /** - * 代理模式上传 - */ - static async proxyUpload( - uploadId: string, - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise> { - const formData = new FormData() - formData.append('file', file) - - return api.put(`/presign/upload/proxy/${uploadId}`, formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - timeout: 0, - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - } - - /** - * 确认直传上传 - */ - static async confirmUpload( - uploadId: string, - request?: PresignConfirmRequest - ): Promise> { - return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) - } - - /** - * 查询上传状态 - */ - static async getUploadStatus(uploadId: string): Promise> { - return api.get(`/presign/upload/status/${uploadId}`) - } - - /** - * 取消上传 - */ - static async cancelUpload(uploadId: string): Promise> { - return api.delete(`/presign/upload/${uploadId}`) - } - - /** - * S3 直传(不经过后端) - */ - static async directUploadToS3( - uploadUrl: string, - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise { - try { - await axios.put(uploadUrl, file, { - headers: { - 'Content-Type': 'application/octet-stream' - }, - timeout: 0, - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - return true - } catch { - return false - } - } -} - -// 导出所有服务 -export const services = { - config: ConfigService, - file: FileService, - auth: AuthService, - stats: StatsService, - presignUpload: PresignUploadService -} - -export default services +export { AUTH_EVENTS } from './client' +export { AuthService } from './auth' +export { ConfigService } from './config' +export { FileService } from './file' +export { PresignUploadService } from './presign-upload' +export { StatsService } from './stats' +export { uploadChunkedFile } from './upload-strategy' diff --git a/src/services/presign-upload.ts b/src/services/presign-upload.ts new file mode 100644 index 0000000..3d1def8 --- /dev/null +++ b/src/services/presign-upload.ts @@ -0,0 +1,53 @@ +import api from './client' +import { multipartUploadConfig } from './shared' +import { uploadToExternalUrl } from './upload-client' +import type { + ApiResponse, + PresignConfirmRequest, + PresignInitRequest, + PresignInitResponse, + PresignStatusResponse, + PresignUploadResult, + UploadProgress +} from '@/types' + +export class PresignUploadService { + static async initUpload(request: PresignInitRequest): Promise> { + return api.post('/presign/upload/init', request) + } + + static async proxyUpload( + uploadId: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('file', file) + + return api.put(`/presign/upload/proxy/${uploadId}`, formData, multipartUploadConfig(onProgress)) + } + + static async confirmUpload( + uploadId: string, + request?: PresignConfirmRequest + ): Promise> { + return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) + } + + static async getUploadStatus(uploadId: string): Promise> { + return api.get(`/presign/upload/status/${uploadId}`) + } + + static async cancelUpload(uploadId: string): Promise> { + return api.delete(`/presign/upload/${uploadId}`) + } + + static async directUploadToS3( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise { + await uploadToExternalUrl(uploadUrl, file, onProgress) + return true + } +} diff --git a/src/services/shared.ts b/src/services/shared.ts new file mode 100644 index 0000000..58c5535 --- /dev/null +++ b/src/services/shared.ts @@ -0,0 +1,20 @@ +import type { AxiosRequestConfig } from 'axios' +import type { UploadProgress } from '@/types' + +export const multipartUploadConfig = ( + onProgress?: (progress: UploadProgress) => void +): AxiosRequestConfig => ({ + headers: { + 'Content-Type': 'multipart/form-data' + }, + timeout: 0, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + }) + } + } +}) diff --git a/src/services/stats.ts b/src/services/stats.ts new file mode 100644 index 0000000..c275a12 --- /dev/null +++ b/src/services/stats.ts @@ -0,0 +1,8 @@ +import api from './client' +import type { ApiResponse, DashboardData } from '@/types' + +export class StatsService { + static async getDashboard(): Promise> { + return api.get('/admin/dashboard') + } +} diff --git a/src/services/upload-client.ts b/src/services/upload-client.ts new file mode 100644 index 0000000..062fe77 --- /dev/null +++ b/src/services/upload-client.ts @@ -0,0 +1,26 @@ +import axios from 'axios' +import type { UploadProgress } from '@/types' + +export async function uploadToExternalUrl( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void +): Promise { + await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': 'application/octet-stream' + }, + timeout: 0, + onUploadProgress: (progressEvent) => { + if (!onProgress || !progressEvent.total) { + return + } + + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + }) + } + }) +} diff --git a/src/services/upload-strategy.ts b/src/services/upload-strategy.ts new file mode 100644 index 0000000..933a483 --- /dev/null +++ b/src/services/upload-strategy.ts @@ -0,0 +1,97 @@ +import { FileService } from './file' +import type { ApiResponse, ChunkUploadInitResponse, FileUploadResponse, UploadProgress } from '@/types' +import { calculateFileHash } from '@/utils/file-processing' + +const CHUNK_SIZE = 5 * 1024 * 1024 + +type ChunkedUploadOptions = { + expireValue: number + expireStyle: string + onHashCalculated?: (hash: string) => void + onProgress?: (progress: UploadProgress) => void + messages?: { + initFailed?: string + chunkFailed?: (index: number) => string + completeFailed?: string + } +} + +type ChunkedUploadResult = ChunkUploadInitResponse | FileUploadResponse + +const calculateCompletedBytes = (uploadedChunks: Set, chunkSize: number, fileSize: number) => + Array.from(uploadedChunks).reduce((total, index) => { + const chunkStart = index * chunkSize + const chunkEnd = Math.min((index + 1) * chunkSize, fileSize) + return total + Math.max(0, chunkEnd - chunkStart) + }, 0) + +export const uploadChunkedFile = async ( + file: File, + options: ChunkedUploadOptions +): Promise> => { + const fileHash = await calculateFileHash(file) + options.onHashCalculated?.(fileHash) + + const chunks = Math.ceil(file.size / CHUNK_SIZE) + const initResponse = await FileService.initChunkUpload({ + file_name: file.name, + file_size: file.size, + chunk_size: CHUNK_SIZE, + file_hash: fileHash + }) + + if (initResponse.code !== 200) { + throw new Error(options.messages?.initFailed || 'Init chunk upload failed') + } + + if (initResponse.detail?.existed) { + return initResponse + } + + const initDetail = initResponse.detail + const uploadId = initDetail?.upload_id + if (!uploadId) { + throw new Error(options.messages?.initFailed || 'Init chunk upload failed') + } + + const uploadedChunks = new Set(initDetail.uploaded_chunks || []) + for (let index = 0; index < chunks; index++) { + if (uploadedChunks.has(index)) { + continue + } + + const start = index * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + const chunk = file.slice(start, end) + const chunkResponse = await FileService.uploadChunk( + uploadId, + index, + new Blob([chunk], { type: file.type }), + (progress) => { + const completedBytes = calculateCompletedBytes(uploadedChunks, CHUNK_SIZE, file.size) + const percentage = Math.round(((completedBytes + progress.loaded) * 100) / file.size) + options.onProgress?.({ + loaded: completedBytes + progress.loaded, + total: file.size, + percentage: Math.min(percentage, 99) + }) + } + ) + + if (chunkResponse.code !== 200) { + throw new Error(options.messages?.chunkFailed?.(index) || `Chunk upload failed: ${index}`) + } + uploadedChunks.add(index) + } + + const completeResponse = await FileService.completeChunkUpload(uploadId, { + expire_value: options.expireValue, + expire_style: options.expireStyle + }) + + if (completeResponse.code !== 200) { + throw new Error(options.messages?.completeFailed || 'Complete chunk upload failed') + } + + return completeResponse +} diff --git a/src/stores/adminStore.ts b/src/stores/adminStore.ts index 639e9f6..93d2bad 100644 --- a/src/stores/adminStore.ts +++ b/src/stores/adminStore.ts @@ -1,12 +1,18 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { STORAGE_KEYS } from '@/constants' import type { AdminUser } from '@/types' +import { + clearStoredAuth, + readStoredAdminPassword, + readStoredToken, + writeStoredAdminPassword, + writeStoredToken +} from '@/utils/auth-storage' export const useAdminStore = defineStore('admin', () => { // 状态 - const adminPassword = ref(localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '') - const token = ref(localStorage.getItem(STORAGE_KEYS.TOKEN) || '') + const adminPassword = ref(readStoredAdminPassword()) + const token = ref(readStoredToken()) const isLoggedIn = ref(false) const userInfo = ref(null) @@ -14,16 +20,17 @@ export const useAdminStore = defineStore('admin', () => { const isAuthenticated = computed(() => { return isLoggedIn.value && !!token.value }) + const hasToken = computed(() => !!token.value) // 方法 const updateAdminPassword = (pwd: string) => { adminPassword.value = pwd - localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, pwd) + writeStoredAdminPassword(pwd) } const setToken = (newToken: string) => { token.value = newToken - localStorage.setItem(STORAGE_KEYS.TOKEN, newToken) + writeStoredToken(newToken) } const setUserInfo = (user: AdminUser) => { @@ -42,13 +49,11 @@ export const useAdminStore = defineStore('admin', () => { isLoggedIn.value = false userInfo.value = null - // 清除本地存储 - localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD) - localStorage.removeItem(STORAGE_KEYS.TOKEN) + clearStoredAuth() } const initAuth = () => { - const storedToken = localStorage.getItem(STORAGE_KEYS.TOKEN) + const storedToken = readStoredToken() if (storedToken) { token.value = storedToken isLoggedIn.value = true @@ -64,6 +69,7 @@ export const useAdminStore = defineStore('admin', () => { // 计算属性 isAuthenticated, + hasToken, // 方法 updateAdminPassword, @@ -74,6 +80,3 @@ export const useAdminStore = defineStore('admin', () => { initAuth } }) - -// 保持向后兼容 -export const useAdminData = useAdminStore diff --git a/src/stores/alertStore.ts b/src/stores/alertStore.ts index 6eb6ff0..d4318c5 100644 --- a/src/stores/alertStore.ts +++ b/src/stores/alertStore.ts @@ -2,6 +2,8 @@ import { defineStore } from 'pinia' import type { Alert, AlertType } from '@/types' import { TIME_CONSTANTS } from '@/constants' +let progressTimer: ReturnType | null = null + export const useAlertStore = defineStore('alert', { state: () => ({ alerts: [] as Alert[] @@ -33,6 +35,25 @@ export const useAlertStore = defineStore('alert', { this.removeAlert(id) } } + }, + startProgressTimer() { + if (progressTimer) { + return + } + + progressTimer = setInterval(() => { + this.alerts.forEach((alert) => { + this.updateAlertProgress(alert.id) + }) + }, TIME_CONSTANTS.PROGRESS_UPDATE_INTERVAL) + }, + stopProgressTimer() { + if (!progressTimer) { + return + } + + clearInterval(progressTimer) + progressTimer = null } } }) diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts new file mode 100644 index 0000000..6cd4d68 --- /dev/null +++ b/src/stores/configStore.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import type { ConfigState } from '@/types' +import { + DEFAULT_PUBLIC_CONFIG, + readNotifyKey, + readStoredConfig, + toPublicConfig, + writeNotifyKey, + writeStoredConfig, + type PublicConfig +} from '@/utils/config-storage' + +export const useConfigStore = defineStore('config', () => { + const config = ref({ + ...DEFAULT_PUBLIC_CONFIG, + ...toPublicConfig(readStoredConfig>()) + }) + + const uploadSizeLimit = computed(() => config.value.uploadSize) + + const updateConfig = (nextConfig: Partial) => { + config.value = { + ...DEFAULT_PUBLIC_CONFIG, + ...config.value, + ...toPublicConfig(nextConfig) + } + writeStoredConfig(config.value) + } + + const applyRemoteConfig = (nextConfig: Partial): string | null => { + updateConfig(nextConfig) + + const { notify_title: notifyTitle, notify_content: notifyContent } = nextConfig + if (!notifyTitle || !notifyContent) { + return null + } + + const notifyKey = notifyTitle + notifyContent + if (readNotifyKey() === notifyKey) { + return null + } + + writeNotifyKey(notifyKey) + return `${notifyTitle}: ${notifyContent}` + } + + const reloadStoredConfig = () => { + config.value = { + ...DEFAULT_PUBLIC_CONFIG, + ...toPublicConfig(readStoredConfig>()) + } + } + + return { + config, + uploadSizeLimit, + applyRemoteConfig, + updateConfig, + reloadStoredConfig + } +}) diff --git a/src/stores/fileData.ts b/src/stores/fileData.ts index a03519c..a4836fe 100644 --- a/src/stores/fileData.ts +++ b/src/stores/fileData.ts @@ -1,197 +1,24 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { FileInfo, UploadProgress, UploadStatus } from '@/types' -import { UPLOAD_STATUS } from '@/constants' +import { ref } from 'vue' +import type { ReceivedFileRecord, SentFileRecord } from '@/types' export const useFileDataStore = defineStore('fileData', () => { - // 上传相关状态 - const uploadStatus = ref(UPLOAD_STATUS.IDLE) - const uploadProgress = ref({ - loaded: 0, - total: 0, - percentage: 0 - }) - const uploadedCode = ref('') - const currentFile = ref(null) - - // 下载相关状态 - const downloadCode = ref('') - const fileInfo = ref(null) - const isDownloading = ref(false) - - // 文件列表状态(管理页面使用) - const fileList = ref([]) - const totalFiles = ref(0) - const currentPage = ref(1) - const pageSize = ref(10) - const isLoadingList = ref(false) - - // 接收数据状态(取件记录) - const receiveData = ref>([]) - - // 分享数据状态(发送记录) - const shareData = ref>([]) - - // 计算属性 - const isUploading = computed(() => uploadStatus.value === UPLOAD_STATUS.UPLOADING) - const isUploadSuccess = computed(() => uploadStatus.value === UPLOAD_STATUS.SUCCESS) - const isUploadError = computed(() => uploadStatus.value === UPLOAD_STATUS.ERROR) - const hasFileInfo = computed(() => fileInfo.value !== null) - const canDownload = computed(() => hasFileInfo.value && !isDownloading.value) - - const totalPages = computed(() => { - return Math.ceil(totalFiles.value / pageSize.value) - }) - - // 上传相关方法 - const setUploadStatus = (status: UploadStatus) => { - uploadStatus.value = status - } - - const setUploadProgress = (progress: UploadProgress) => { - uploadProgress.value = progress - } - - const setUploadedCode = (code: string) => { - uploadedCode.value = code - } - - const setCurrentFile = (file: File | null) => { - currentFile.value = file - } - - const resetUpload = () => { - uploadStatus.value = UPLOAD_STATUS.IDLE - uploadProgress.value = { - loaded: 0, - total: 0, - percentage: 0 - } - uploadedCode.value = '' - currentFile.value = null - } - - // 下载相关方法 - const setDownloadCode = (code: string) => { - downloadCode.value = code - } - - const setFileInfo = (info: FileInfo | null) => { - fileInfo.value = info - } - - const setDownloading = (loading: boolean) => { - isDownloading.value = loading - } - - const resetDownload = () => { - downloadCode.value = '' - fileInfo.value = null - isDownloading.value = false - } - - // 文件列表相关方法 - const setFileList = (files: FileInfo[]) => { - fileList.value = files - } - - const addFile = (file: FileInfo) => { - fileList.value.unshift(file) - totalFiles.value += 1 - } - - const removeFile = (fileId: string) => { - const index = fileList.value.findIndex(file => file.id === fileId) - if (index > -1) { - fileList.value.splice(index, 1) - totalFiles.value -= 1 - } - } - - const updateFile = (fileId: string, updates: Partial) => { - const index = fileList.value.findIndex(file => file.id === fileId) - if (index > -1) { - fileList.value[index] = { ...fileList.value[index], ...updates } - } - } - - const setTotalFiles = (total: number) => { - totalFiles.value = total - } - - const setCurrentPage = (page: number) => { - currentPage.value = page - } - - const setPageSize = (size: number) => { - pageSize.value = size - } - - const setLoadingList = (loading: boolean) => { - isLoadingList.value = loading - } - - const resetFileList = () => { - fileList.value = [] - totalFiles.value = 0 - currentPage.value = 1 - isLoadingList.value = false - } - - // 添加分享数据方法 - const addShareData = (data: { code: string; name?: string }) => { - setUploadedCode(data.code) - if (data.name) { - // 如果有文件名,可以创建一个临时的 FileInfo 对象 - const fileInfo: FileInfo = { - id: data.code, - name: data.name, - size: 0, - type: '', - uploadTime: new Date().toISOString(), - downloadCount: 0 - } - addFile(fileInfo) - } - } + const receiveData = ref([]) + const shareData = ref([]) - // 接收数据相关方法 - const addReceiveData = (data: { - id: number - code: string - filename: string - size: string - downloadUrl: string | null - content: string | null - date: string - }) => { - receiveData.value.push(data) + const addReceiveData = (record: ReceivedFileRecord) => { + receiveData.value.push(record) } const removeReceiveData = (id: number) => { - const index = receiveData.value.findIndex(item => item.id === id) - if (index > -1) { + const index = receiveData.value.findIndex((record) => record.id === id) + if (index !== -1) { receiveData.value.splice(index, 1) } } - + const deleteReceiveData = (index: number) => { - if (index > -1 && index < receiveData.value.length) { + if (index >= 0 && index < receiveData.value.length) { receiveData.value.splice(index, 1) } } @@ -199,90 +26,28 @@ export const useFileDataStore = defineStore('fileData', () => { const clearReceiveData = () => { receiveData.value = [] } - - // 分享数据相关方法 - const addShareDataRecord = (data: { - id: number - filename: string - date: string - size: string - expiration: string - retrieveCode: string - }) => { - shareData.value.push(data) + + const addShareDataRecord = (record: SentFileRecord) => { + shareData.value.push(record) } - + const deleteShareData = (index: number) => { - if (index > -1 && index < shareData.value.length) { + if (index >= 0 && index < shareData.value.length) { shareData.value.splice(index, 1) } } - + const clearShareData = () => { shareData.value = [] } return { - // 上传状态 - uploadStatus, - uploadProgress, - uploadedCode, - currentFile, - - // 下载状态 - downloadCode, - fileInfo, - isDownloading, - - // 文件列表状态 - fileList, - totalFiles, - currentPage, - pageSize, - isLoadingList, - - // 计算属性 - isUploading, - isUploadSuccess, - isUploadError, - hasFileInfo, - canDownload, - totalPages, - - // 上传方法 - setUploadStatus, - setUploadProgress, - setUploadedCode, - setCurrentFile, - resetUpload, - - // 下载方法 - setDownloadCode, - setFileInfo, - setDownloading, - resetDownload, - - // 文件列表方法 - setFileList, - addFile, - removeFile, - updateFile, - setTotalFiles, - setCurrentPage, - setPageSize, - setLoadingList, - resetFileList, - addShareData, - - // 接收数据状态和方法 receiveData, + shareData, addReceiveData, removeReceiveData, deleteReceiveData, clearReceiveData, - - // 分享数据状态和方法 - shareData, addShareDataRecord, deleteShareData, clearShareData diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..3ae1779 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,10 @@ +export interface ApiResponse { + code: number + message?: string + detail?: T +} + +export interface ApiErrorPayload { + detail?: string + message?: string +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..8558757 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,5 @@ +export interface AdminUser { + id: string + username: string + token: string +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..1272c0d --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,55 @@ +export interface SystemConfig { + name: string + description?: string + maxFileSize: number + allowedFileTypes: string[] + expireDays: number + notify_title?: string + notify_content?: string +} + +export interface ThemeChoice { + key: string + name: string + author: string + version: string +} + +export interface ConfigState { + name: string + description: string + file_storage: string + themesChoices: ThemeChoice[] + expireStyle: string[] + admin_token: string + robotsText: string + keywords: string + notify_title: string + notify_content: string + openUpload: number + uploadSize: number + storage_path: string + uploadMinute: number + max_save_seconds: number + opacity: number + enableChunk: number + s3_access_key_id: string + background: string + showAdminAddr: number + page_explain: string + s3_secret_access_key: string + aws_session_token: string + s3_signature_version: string + s3_region_name: string + s3_bucket_name: string + s3_endpoint_url: string + s3_hostname: string + uploadCount: number + errorMinute: number + errorCount: number + s3_proxy: number + themesSelect: string + webdav_url: string + webdav_username: string + webdav_password: string +} diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts new file mode 100644 index 0000000..5d402d3 --- /dev/null +++ b/src/types/dashboard.ts @@ -0,0 +1,54 @@ +export interface DashboardData { + totalFiles: number + storageUsed: number + yesterdayCount: number + todayCount: number + yesterdaySize: number + todaySize: number + sysUptime: number | null + activeCount: number + expiredCount: number + textCount: number + fileCount: number + chunkedCount: number + usedCount: number + storageBackend: string + uploadSizeLimit: number + openUpload: number + enableChunk: number + maxSaveSeconds: number + topSuffixes: DashboardSuffixStat[] + recentFiles: DashboardRecentFile[] +} + +export interface DashboardSuffixStat { + suffix: string + count: number +} + +export interface DashboardRecentFile { + id: number + code: string + name: string + suffix: string + size: number + text: boolean + expiredAt: string | null + expiredCount: number + usedCount: number + createdAt: string | null + isExpired: boolean +} + +export interface DashboardViewData extends DashboardData { + hasExtendedStats: boolean + storageUsedText: string + yesterdaySizeText: string + todaySizeText: string + uploadSizeLimitText: string + sysUptimeText: string + activeRatio: number + textRatio: number + fileRatio: number + todaySizeRatio: number +} diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 0000000..4102f0d --- /dev/null +++ b/src/types/file.ts @@ -0,0 +1,107 @@ +export interface FileInfo { + id: string + name: string + size: number + type: string + uploadTime: string + downloadCount: number + expireTime?: string +} + +export interface FileListItem { + id: number + code: string + prefix: string + suffix: string + size: number + text?: string + description?: string + expired_at: string + expired_count: number | null + created_at: string +} + +export interface AdminFileViewItem extends FileListItem { + displaySize: string + displayExpiredAt: string + canPreviewText: boolean +} + +export interface FileEditForm { + id: number | null + code: string + prefix: string + suffix: string + expired_at: string + expired_count: number | null +} + +export interface FileListResponse { + data: FileListItem[] + total: number + page: number + size: number +} + +export interface FileUploadResponse { + code: string + name: string +} + +export interface TextSendResponse { + code: string +} + +export interface ShareSelectResponse { + code: string + name: string + text: string + size: number +} + +export interface ReceivedFileRecord { + id: number + code: string + filename: string + size: string + downloadUrl: string | null + content: string | null + date: string +} + +export interface SentFileRecord { + id: number + filename: string + date: string + size: string + expiration: string + retrieveCode: string +} + +export interface UploadProgress { + loaded: number + total: number + percentage: number +} + +export interface ChunkUploadInitRequest { + file_name: string + file_size: number + chunk_size: number + file_hash: string +} + +export interface ChunkUploadInitResponse { + code?: string + name?: string + upload_id?: string + existed?: boolean + uploaded_chunks?: number[] +} + +export interface ChunkUploadCompleteRequest { + expire_value: number + expire_style: string +} + +export type ChunkUploadResponse = null diff --git a/src/types/index.ts b/src/types/index.ts index 00169ab..9553f6d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,243 +1,7 @@ -// 通用类型定义 -export interface ApiResponse { - code: number - message?: string - detail?: T -} - -// 文件相关类型 -export interface FileInfo { - id: string - name: string - size: number - type: string - uploadTime: string - downloadCount: number - expireTime?: string -} - -// 文件管理相关类型 -export interface FileListItem { - id: number - code: string - prefix: string - suffix: string - size: number - text?: string - description?: string - expired_at: string - expired_count: number | null - created_at: string -} - -export interface FileEditForm { - id: number | null - code: string - prefix: string - suffix: string - expired_at: string - expired_count: number | null -} - -export interface FileListResponse { - data: FileListItem[] - total: number - page: number - size: number -} - -// 文件上传响应类型 -export interface FileUploadResponse { - code: number - name: string -} - -// 文本发送响应类型 -export interface TextSendResponse { - code: number -} - -export interface UploadProgress { - loaded: number - total: number - percentage: number -} - -// 系统配置类型 -export interface SystemConfig { - name: string - description?: string - maxFileSize: number - allowedFileTypes: string[] - expireDays: number - notify_title?: string - notify_content?: string -} - -// 主题选择项类型 -export interface ThemeChoice { - key: string - name: string - author: string - version: string -} - -// 完整的系统配置状态类型 -export interface ConfigState { - name: string - description: string - file_storage: string - themesChoices: ThemeChoice[] - expireStyle: string[] - admin_token: string - robotsText: string - keywords: string - notify_title: string - notify_content: string - openUpload: number - uploadSize: number - storage_path: string - uploadMinute: number - max_save_seconds: number - opacity: number - enableChunk: number - s3_access_key_id: string - background: string - showAdminAddr: number - page_explain: string - s3_secret_access_key: string - aws_session_token: string - s3_signature_version: string - s3_region_name: string - s3_bucket_name: string - s3_endpoint_url: string - s3_hostname: string - uploadCount: number - errorMinute: number - errorCount: number - s3_proxy: number - themesSelect: string - webdav_url: string - webdav_username: string - webdav_password: string -} - -// 系统配置API响应类型 -export interface ConfigResponse { - code: number - message?: string - detail?: ConfigState -} - -// 用户相关类型 -export interface AdminUser { - id: string - username: string - token: string -} - -// Dashboard 数据类型 -export interface DashboardData { - totalFiles: number - storageUsed: number | string - yesterdayCount: number - todayCount: number - yesterdaySize: number | string - todaySize: number | string - sysUptime: number | string -} - -// 主题相关类型 -export type ThemeMode = 'light' | 'dark' | 'system' - -// 发送类型 -export type SendType = 'file' | 'text' - -// 警告类型 -export type AlertType = 'success' | 'error' | 'warning' | 'info' - -export interface Alert { - id: number - message: string - type: AlertType - progress: number - duration: number - startTime: number -} - -// 文件上传状态 -export type UploadStatus = 'idle' | 'uploading' | 'success' | 'error' - -// 路由相关类型 -export interface RouteConfig { - path: string - name: string - component: () => Promise<{ default: object }> - meta?: { - requiresAuth?: boolean - title?: string - } -} - -// ==================== 预签名上传相关类型 ==================== - -// 预签名上传模式 -export type PresignUploadMode = 'direct' | 'proxy' - -// 预签名上传状态 -export type PresignUploadStatus = - | 'idle' - | 'initializing' - | 'uploading' - | 'confirming' - | 'success' - | 'error' - -// 过期类型 -export type ExpireStyle = 'day' | 'hour' | 'minute' | 'forever' | 'count' - -// 初始化上传请求 -export interface PresignInitRequest { - file_name: string - file_size: number - expire_value?: number - expire_style?: ExpireStyle -} - -// 初始化上传响应 -export interface PresignInitResponse { - upload_id: string - upload_url: string - mode: PresignUploadMode - expires_in: number -} - -// 确认上传请求 -export interface PresignConfirmRequest { - expire_value?: number - expire_style?: ExpireStyle -} - -// 上传结果 -export interface PresignUploadResult { - code: string - name: string -} - -// 上传状态查询响应 -export interface PresignStatusResponse { - upload_id: string - file_name: string - file_size: number - mode: PresignUploadMode - created_at: string - expires_at: string - is_expired: boolean -} - -// 上传配置选项 -export interface PresignUploadOptions { - expireValue?: number - expireStyle?: ExpireStyle - onProgress?: (progress: UploadProgress) => void -} +export * from './api' +export * from './auth' +export * from './config' +export * from './dashboard' +export * from './file' +export * from './presign-upload' +export * from './ui' diff --git a/src/types/presign-upload.ts b/src/types/presign-upload.ts new file mode 100644 index 0000000..8f38144 --- /dev/null +++ b/src/types/presign-upload.ts @@ -0,0 +1,53 @@ +import type { UploadProgress } from './file' + +export type PresignUploadMode = 'direct' | 'proxy' + +export type PresignUploadStatus = + | 'idle' + | 'initializing' + | 'uploading' + | 'confirming' + | 'success' + | 'error' + +export type ExpireStyle = 'day' | 'hour' | 'minute' | 'forever' | 'count' + +export interface PresignInitRequest { + file_name: string + file_size: number + expire_value?: number + expire_style?: ExpireStyle +} + +export interface PresignInitResponse { + upload_id: string + upload_url: string + mode: PresignUploadMode + expires_in: number +} + +export interface PresignConfirmRequest { + expire_value?: number + expire_style?: ExpireStyle +} + +export interface PresignUploadResult { + code: string + name: string +} + +export interface PresignStatusResponse { + upload_id: string + file_name: string + file_size: number + mode: PresignUploadMode + created_at: string + expires_at: string + is_expired: boolean +} + +export interface PresignUploadOptions { + expireValue?: number + expireStyle?: ExpireStyle + onProgress?: (progress: UploadProgress) => void +} diff --git a/src/types/ui.ts b/src/types/ui.ts new file mode 100644 index 0000000..8a8e21f --- /dev/null +++ b/src/types/ui.ts @@ -0,0 +1,26 @@ +export type ThemeMode = 'light' | 'dark' | 'system' + +export type SendType = 'file' | 'text' + +export type AlertType = 'success' | 'error' | 'warning' | 'info' + +export interface Alert { + id: number + message: string + type: AlertType + progress: number + duration: number + startTime: number +} + +export type UploadStatus = 'idle' | 'uploading' | 'success' | 'error' + +export interface RouteConfig { + path: string + name: string + component: () => Promise<{ default: object }> + meta?: { + requiresAuth?: boolean + title?: string + } +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 5ccf4e1..f4e76cb 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,74 +1,2 @@ -import axios from 'axios' -import { TIME_CONSTANTS } from '@/constants' - -// 从环境变量中获取 API 基础 URL -const baseURL = - import.meta.env.MODE === 'production' - ? import.meta.env.VITE_API_BASE_URL_PROD - : import.meta.env.VITE_API_BASE_URL_DEV -// 确保 baseURL 是一个有效的字符串 -const sanitizedBaseURL = typeof baseURL === 'string' ? baseURL : '' - -// 创建 axios 实例 -const api = axios.create({ - baseURL: sanitizedBaseURL, - timeout: TIME_CONSTANTS.REQUEST_TIMEOUT, // 30秒超时 - headers: { - 'Content-Type': 'application/json' - } -}) - -// 请求拦截器 -api.interceptors.request.use( - (config) => { - // 从 localStorage 获取 token - const token = localStorage.getItem('token') - if (token) { - config.headers['Authorization'] = `Bearer ${token}` - } - - // 确保 URL 是有效的 - if (config.url && !config.url.startsWith('http')) { - config.url = `${sanitizedBaseURL}/${config.url.replace(/^\//, '')}` - } - - return config - }, - (error) => { - return Promise.reject(error) - } -) -// 响应拦截器 -api.interceptors.response.use( - (response) => { - return response.data - }, - (error) => { - // 处理错误响应 - if (error.response) { - const { status } = error.response - switch (status) { - case 401: - localStorage.removeItem('token') - // 使用 router 进行导航而不是直接修改 location - if (window.location.hash !== '#/login') { - window.location.href = '/#/login' - } - break - case 403: - case 404: - case 500: - default: - // 错误信息通过Promise.reject传递给调用方处理 - break - } - } else if (error.request) { - // 网络错误,通过Promise.reject传递给调用方处理 - } else { - // 请求配置错误,通过Promise.reject传递给调用方处理 - } - return Promise.reject(error) - } -) - -export default api +export { apiBaseURL, rawApiClient } from '@/services/client' +export { default } from '@/services/client' diff --git a/src/utils/auth-storage.ts b/src/utils/auth-storage.ts new file mode 100644 index 0000000..ff59afb --- /dev/null +++ b/src/utils/auth-storage.ts @@ -0,0 +1,26 @@ +import { STORAGE_KEYS } from '@/constants' + +export function readStoredAdminPassword(): string { + return localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '' +} + +export function writeStoredAdminPassword(password: string) { + localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, password) +} + +export function readStoredToken(): string { + return localStorage.getItem(STORAGE_KEYS.TOKEN) || '' +} + +export function writeStoredToken(token: string) { + localStorage.setItem(STORAGE_KEYS.TOKEN, token) +} + +export function clearStoredAuth() { + localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD) + localStorage.removeItem(STORAGE_KEYS.TOKEN) +} + +export function clearStoredToken() { + localStorage.removeItem(STORAGE_KEYS.TOKEN) +} diff --git a/src/utils/clipboard-paste.ts b/src/utils/clipboard-paste.ts new file mode 100644 index 0000000..4a44e0a --- /dev/null +++ b/src/utils/clipboard-paste.ts @@ -0,0 +1,40 @@ +export const getClipboardFile = (items: DataTransferItemList): File | null => { + for (let index = 0; index < items.length; index++) { + const item = items[index] + if (item.kind !== 'file') { + continue + } + + const file = item.getAsFile() + if (file) { + return file + } + } + return null +} + +export type TextInsertionInput = { + text: string + insertText: string + selectionStart: number + selectionEnd: number +} + +export type TextInsertionResult = { + value: string + cursor: number +} + +export const insertTextAtSelection = ({ + text, + insertText, + selectionStart, + selectionEnd +}: TextInsertionInput): TextInsertionResult => { + const beforeSelection = text.substring(0, selectionStart) + const afterSelection = text.substring(selectionEnd) + return { + value: beforeSelection + insertText + afterSelection, + cursor: selectionStart + insertText.length + } +} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index ddea5dd..1f38d21 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -2,11 +2,15 @@ * 剪贴板工具函数 */ -import { useAlertStore } from '@/stores/alertStore' +import { buildRetrieveUrl, buildWgetCommand } from '@/utils/share-url' + +type CopyNotifyType = 'success' | 'error' + interface CopyOptions { successMsg?: string errorMsg?: string showMsg?: boolean + notify?: (message: string, type: CopyNotifyType) => void } /** @@ -19,13 +23,24 @@ export const copyToClipboard = async ( text: string, options: CopyOptions = {} ): Promise => { - const { successMsg = '复制成功', errorMsg = '复制失败,请手动复制', showMsg = true } = options - const alertStore = useAlertStore() + const { + successMsg = '复制成功', + errorMsg = '复制失败,请手动复制', + showMsg = true, + notify + } = options + + const showCopyMessage = (message: string, type: CopyNotifyType) => { + if (showMsg) { + notify?.(message, type) + } + } + try { // 优先使用 Clipboard API if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text) - if (showMsg) alertStore.showAlert(successMsg, 'success') + showCopyMessage(successMsg, 'success') return true } // 后备方案:使用传统的复制方法 @@ -38,14 +53,14 @@ export const copyToClipboard = async ( const success = document.execCommand('copy') document.body.removeChild(textarea) if (success) { - if (showMsg) alertStore.showAlert(successMsg, 'success') + showCopyMessage(successMsg, 'success') return true } else { throw new Error('execCommand copy failed') } } catch (err) { console.error('复制失败:', err) - if (showMsg) alertStore.showAlert(errorMsg, 'error') + showCopyMessage(errorMsg, 'error') return false } } @@ -55,11 +70,15 @@ export const copyToClipboard = async ( * @param code 取件码 * @returns Promise 是否复制成功 */ -export const copyRetrieveLink = async (code: string): Promise => { - const link = `${window.location.origin}/#/?code=${code}` +export const copyRetrieveLink = async ( + code: string, + options: Pick = {} +): Promise => { + const link = buildRetrieveUrl(code) return copyToClipboard(link, { successMsg: '取件链接已复制到剪贴板', - errorMsg: '复制失败,请手动复制取件链接' + errorMsg: '复制失败,请手动复制取件链接', + ...options }) } @@ -68,50 +87,26 @@ export const copyRetrieveLink = async (code: string): Promise => { * @param code 取件码 * @returns Promise 是否复制成功 */ -export const copyRetrieveCode = async (code: string): Promise => { +export const copyRetrieveCode = async ( + code: string, + options: Pick = {} +): Promise => { return copyToClipboard(code, { successMsg: '取件码已复制到剪贴板', - errorMsg: '复制失败,请手动复制取件码' + errorMsg: '复制失败,请手动复制取件码', + ...options }) } -const baseUrl = window.location.origin + '/' - -export const copyWgetCommand = (retrieveCode: string, fileName: string) => { - const command = `wget ${baseUrl}share/select?code=${retrieveCode} -O "${fileName}"` - - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard - .writeText(command) - .then(() => { - console.log('命令已复制到剪贴板!') - }) - .catch((err) => { - console.error('复制失败,使用回退方法:', err) - fallbackCopyTextToClipboard(command) - }) - } else { - console.warn('Clipboard API 不可用,使用回退方法。') - fallbackCopyTextToClipboard(command) - } -} -function fallbackCopyTextToClipboard(command: string) { - const textArea = document.createElement('textarea') - textArea.value = command - textArea.style.position = 'fixed' // 避免滚动 - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - try { - const successful = document.execCommand('copy') - console.log('回退复制操作成功:', successful) - if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(command) - } else { - console.error('回退复制操作失败') - } - } catch (err) { - console.error('回退复制操作失败:', err) - } - document.body.removeChild(textArea) +export const copyWgetCommand = ( + retrieveCode: string, + fileName: string, + options: Pick = {} +) => { + const command = buildWgetCommand(retrieveCode, fileName) + void copyToClipboard(command, { + successMsg: '命令已复制到剪贴板', + errorMsg: '复制失败,请手动复制命令', + ...options + }) } diff --git a/src/utils/common.ts b/src/utils/common.ts index cd5c150..f818fe6 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,7 @@ /** * 通用工具函数 */ +import type { ApiErrorPayload } from '@/types' /** * 格式化时间戳为可读格式 @@ -74,36 +75,6 @@ export function formatDuration(seconds: number, t?: (key: string) => string): st return `${seconds}${secondName}` } -/** - * 复制文本到剪贴板 - * @param text 要复制的文本 - * @returns Promise 是否复制成功 - */ -export async function copyToClipboard(text: string): Promise { - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text) - return true - } else { - // 降级方案 - const textArea = document.createElement('textarea') - textArea.value = text - textArea.style.position = 'fixed' - textArea.style.left = '-999999px' - textArea.style.top = '-999999px' - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - const result = document.execCommand('copy') - textArea.remove() - return result - } - } catch (error) { - console.error('Copy failed:', error) - return false - } -} - /** * 防抖函数 * @param func 要防抖的函数 @@ -241,4 +212,25 @@ export function isMobile(): boolean { */ export function formatNumber(num: number): string { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') -} \ No newline at end of file +} + +type ErrorWithResponse = { + response?: { + data?: ApiErrorPayload + } + message?: string +} + +export function getErrorMessage(error: unknown, fallback: string): string { + if (!error || typeof error !== 'object') { + return fallback + } + + const errorWithResponse = error as ErrorWithResponse + return ( + errorWithResponse.response?.data?.detail || + errorWithResponse.response?.data?.message || + errorWithResponse.message || + fallback + ) +} diff --git a/src/utils/config-form.ts b/src/utils/config-form.ts new file mode 100644 index 0000000..4f14636 --- /dev/null +++ b/src/utils/config-form.ts @@ -0,0 +1,107 @@ +import type { ConfigState } from '@/types' + +export type FileSizeUnit = 'KB' | 'MB' | 'GB' +export type SaveTimeUnit = '秒' | '分' | '时' | '天' + +export type FileSizeForm = { + value: number + unit: FileSizeUnit +} + +export type SaveTimeForm = { + value: number + unit: SaveTimeUnit +} + +const FILE_SIZE_UNITS: Record = { + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024 +} + +const SAVE_TIME_UNITS: Record = { + 秒: 1, + 分: 60, + 时: 3600, + 天: 86400 +} + +export function bytesToFileSizeForm(bytes: number): FileSizeForm { + if (bytes >= FILE_SIZE_UNITS.GB) { + return { + value: Math.round(bytes / FILE_SIZE_UNITS.GB), + unit: 'GB' + } + } + + if (bytes >= FILE_SIZE_UNITS.MB) { + return { + value: Math.round(bytes / FILE_SIZE_UNITS.MB), + unit: 'MB' + } + } + + return { + value: Math.round(bytes / FILE_SIZE_UNITS.KB), + unit: 'KB' + } +} + +export function fileSizeFormToBytes(value: number, unit: FileSizeUnit): number { + return value * FILE_SIZE_UNITS[unit] +} + +export function secondsToSaveTimeForm(seconds: number): SaveTimeForm { + if (seconds === 0) { + return { + value: 7, + unit: '天' + } + } + + if (seconds % SAVE_TIME_UNITS.天 === 0 && seconds >= SAVE_TIME_UNITS.天) { + return { + value: seconds / SAVE_TIME_UNITS.天, + unit: '天' + } + } + + if (seconds % SAVE_TIME_UNITS.时 === 0 && seconds >= SAVE_TIME_UNITS.时) { + return { + value: seconds / SAVE_TIME_UNITS.时, + unit: '时' + } + } + + if (seconds % SAVE_TIME_UNITS.分 === 0 && seconds >= SAVE_TIME_UNITS.分) { + return { + value: seconds / SAVE_TIME_UNITS.分, + unit: '分' + } + } + + return { + value: seconds, + unit: '秒' + } +} + +export function saveTimeFormToSeconds(value: number, unit: SaveTimeUnit): number { + if (value === 0) { + return 7 * SAVE_TIME_UNITS.天 + } + + return value * SAVE_TIME_UNITS[unit] +} + +export function buildConfigSubmitPayload( + config: ConfigState, + fileSize: FileSizeForm, + saveTime: SaveTimeForm +): ConfigState { + return { + ...config, + uploadSize: fileSizeFormToBytes(fileSize.value, fileSize.unit), + max_save_seconds: saveTimeFormToSeconds(saveTime.value, saveTime.unit) + } +} diff --git a/src/utils/config-storage.ts b/src/utils/config-storage.ts new file mode 100644 index 0000000..2c03801 --- /dev/null +++ b/src/utils/config-storage.ts @@ -0,0 +1,107 @@ +import { DEFAULT_CONFIG, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' +import type { ConfigState, SystemConfig } from '@/types' + +export type PublicConfig = SystemConfig & { + uploadSize: number + expireStyle: string[] + openUpload: number + max_save_seconds: number + enableChunk: number + notify_title?: string + notify_content?: string + page_explain?: string + showAdminAddr?: number + themesSelect?: string + background?: string + opacity?: number +} + +export const DEFAULT_PUBLIC_CONFIG: PublicConfig = { + ...DEFAULT_CONFIG, + uploadSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE, + expireStyle: ['day'], + openUpload: 1, + max_save_seconds: 0, + enableChunk: 0 +} + +export const DEFAULT_CONFIG_STATE: ConfigState = { + name: DEFAULT_PUBLIC_CONFIG.name, + description: DEFAULT_PUBLIC_CONFIG.description || '', + file_storage: '', + themesChoices: [], + expireStyle: DEFAULT_PUBLIC_CONFIG.expireStyle, + admin_token: '', + robotsText: '', + keywords: '', + notify_title: '', + notify_content: '', + openUpload: DEFAULT_PUBLIC_CONFIG.openUpload, + uploadSize: DEFAULT_PUBLIC_CONFIG.uploadSize, + storage_path: '', + uploadMinute: 1, + max_save_seconds: DEFAULT_PUBLIC_CONFIG.max_save_seconds, + opacity: 0.9, + enableChunk: DEFAULT_PUBLIC_CONFIG.enableChunk, + s3_access_key_id: '', + background: '', + showAdminAddr: 0, + page_explain: '', + s3_secret_access_key: '', + aws_session_token: '', + s3_signature_version: '', + s3_region_name: '', + s3_bucket_name: '', + s3_endpoint_url: '', + s3_hostname: '', + uploadCount: 1, + errorMinute: 1, + errorCount: 1, + s3_proxy: 0, + themesSelect: '', + webdav_url: '', + webdav_username: '', + webdav_password: '' +} + +export function readStoredConfig>(): T | null { + try { + const rawConfig = localStorage.getItem(STORAGE_KEYS.CONFIG) + return rawConfig ? (JSON.parse(rawConfig) as T) : null + } catch { + return null + } +} + +export function toPublicConfig(config: Partial | null | undefined): Partial { + if (!config) return {} + + return { + name: config.name, + description: config.description, + uploadSize: config.uploadSize, + expireStyle: config.expireStyle, + openUpload: config.openUpload, + max_save_seconds: config.max_save_seconds, + enableChunk: config.enableChunk, + notify_title: config.notify_title, + notify_content: config.notify_content, + page_explain: config.page_explain, + showAdminAddr: config.showAdminAddr, + themesSelect: config.themesSelect, + background: config.background, + opacity: config.opacity + } +} + +export function writeStoredConfig(config: object) { + localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(toPublicConfig(config as Partial))) +} + +export function readNotifyKey(): string | null { + return localStorage.getItem(STORAGE_KEYS.NOTIFY) +} + +export function writeNotifyKey(notifyKey: string) { + localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey) +} diff --git a/src/utils/content-preview.ts b/src/utils/content-preview.ts new file mode 100644 index 0000000..7d172b8 --- /dev/null +++ b/src/utils/content-preview.ts @@ -0,0 +1,41 @@ +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +const MARKDOWN_ALLOWED_TAGS = [ + 'p', + 'br', + 'strong', + 'em', + 'u', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'blockquote', + 'code', + 'pre', + 'a', + 'img' +] + +const MARKDOWN_ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'class'] + +export async function renderMarkdownPreview(content: string): Promise { + try { + const rawHtml = await marked(content) + return DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: MARKDOWN_ALLOWED_TAGS, + ALLOWED_ATTR: MARKDOWN_ALLOWED_ATTR, + ALLOWED_URI_REGEXP: + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i + }) + } catch (error) { + console.error('Markdown 渲染失败:', error) + return content + } +} diff --git a/src/utils/download-action.ts b/src/utils/download-action.ts new file mode 100644 index 0000000..be34bef --- /dev/null +++ b/src/utils/download-action.ts @@ -0,0 +1,15 @@ +import { saveAs } from 'file-saver' +import type { ReceivedFileRecord } from '@/types' +import { buildDownloadUrl } from '@/utils/share-url' + +export function downloadReceivedRecord(record: ReceivedFileRecord): void { + if (record.downloadUrl) { + window.open(buildDownloadUrl(record.downloadUrl), '_blank') + return + } + + if (record.content) { + const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' }) + saveAs(blob, `${record.filename}.txt`) + } +} diff --git a/src/utils/file-processing.ts b/src/utils/file-processing.ts new file mode 100644 index 0000000..ba91f76 --- /dev/null +++ b/src/utils/file-processing.ts @@ -0,0 +1,60 @@ +import JSZip from 'jszip' + +const SMALL_FILE_HASH_LIMIT = 10 * 1024 * 1024 +const LARGE_FILE_HASH_CHUNK_SIZE = 5 * 1024 * 1024 + +const generateFallbackHash = (file: File): string => { + const fileInfo = `${file.name}-${file.size}-${file.lastModified}` + let hash = 0 + for (let i = 0; i < fileInfo.length; i++) { + const char = fileInfo.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return Math.abs(hash).toString(16).padStart(64, '0') +} + +const createSha256Hash = async (data: BufferSource): Promise => { + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export const calculateFileHash = async (file: File): Promise => { + try { + if (file.size <= SMALL_FILE_HASH_LIMIT) { + const buffer = await file.arrayBuffer() + return window.isSecureContext ? createSha256Hash(buffer) : generateFallbackHash(file) + } + + const firstChunk = file.slice(0, LARGE_FILE_HASH_CHUNK_SIZE) + const lastChunk = file.slice(-LARGE_FILE_HASH_CHUNK_SIZE) + const [firstBuffer, lastBuffer] = await Promise.all([ + firstChunk.arrayBuffer(), + lastChunk.arrayBuffer() + ]) + const combined = new Uint8Array(firstBuffer.byteLength + lastBuffer.byteLength + 16) + combined.set(new Uint8Array(firstBuffer), 0) + combined.set(new Uint8Array(lastBuffer), firstBuffer.byteLength) + const sizeBytes = new TextEncoder().encode(file.size.toString()) + combined.set(sizeBytes, firstBuffer.byteLength + lastBuffer.byteLength) + + return window.isSecureContext ? createSha256Hash(combined) : generateFallbackHash(file) + } catch (error) { + console.error('File hash calculation failed:', error) + return generateFallbackHash(file) + } +} + +export const packFilesAsZip = async (files: File[]): Promise => { + const zip = new JSZip() + for (const file of files) { + zip.file(file.name, file) + } + const blob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }) + return new File([blob], `files_${Date.now()}.zip`, { type: 'application/zip' }) +} diff --git a/src/utils/preference-storage.ts b/src/utils/preference-storage.ts new file mode 100644 index 0000000..6582123 --- /dev/null +++ b/src/utils/preference-storage.ts @@ -0,0 +1,20 @@ +import { STORAGE_KEYS } from '@/constants' +import type { ThemeMode } from '@/types' + +const LOCALE_STORAGE_KEY = 'locale' + +export function readStoredThemeMode(): string | null { + return localStorage.getItem(STORAGE_KEYS.COLOR_MODE) +} + +export function writeStoredThemeMode(mode: ThemeMode) { + localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode) +} + +export function readStoredLocale(): string | null { + return localStorage.getItem(LOCALE_STORAGE_KEY) +} + +export function writeStoredLocale(locale: string) { + localStorage.setItem(LOCALE_STORAGE_KEY, locale) +} diff --git a/src/utils/send-record.ts b/src/utils/send-record.ts new file mode 100644 index 0000000..00fe046 --- /dev/null +++ b/src/utils/send-record.ts @@ -0,0 +1,99 @@ +import type { ApiResponse, SendType, SentFileRecord } from '@/types' + +type Translate = (key: string, params?: Record) => string + +type BuildSentRecordInput = { + response: ApiResponse + sendType: SendType + textContent: string + selectedFile: File | null + selectedFiles: File[] + expirationMethod: string + expirationValue: string + translate: Translate + getUnit: (method: string) => string +} + +const expirationSecondsByMethod: Record = { + minute: 60, + hour: 3600, + day: 86400 +} + +export function isExpirationWithinLimit( + method: string, + value: string, + maxSaveSeconds: number +): boolean { + if (method === 'forever' || method === 'count') return true + if (maxSaveSeconds === 0) return true + + const multiplier = expirationSecondsByMethod[method] + if (!multiplier) return false + + return parseInt(value) * multiplier <= maxSaveSeconds +} + +export function formatExpirationTime( + method: string, + value: string, + translate: Translate, + getUnit: (method: string) => string +): string { + if (method === 'forever') return translate('send.expiration.units.forever') + if (method === 'count') return translate('send.messages.expiresAfterCount', { count: value }) + + const now = new Date() + const expireValue = parseInt(value) + + switch (method) { + case 'minute': + now.setMinutes(now.getMinutes() + expireValue) + break + case 'hour': + now.setHours(now.getHours() + expireValue) + break + case 'day': + now.setDate(now.getDate() + expireValue) + break + default: + return translate('send.messages.expiresAfter', { value, unit: getUnit(method) }) + } + + const year = now.getFullYear() + const month = (now.getMonth() + 1).toString().padStart(2, '0') + const day = now.getDate().toString().padStart(2, '0') + const hours = now.getHours().toString().padStart(2, '0') + const minutes = now.getMinutes().toString().padStart(2, '0') + return translate('send.messages.expiresAt', { date: `${year}-${month}-${day} ${hours}:${minutes}` }) +} + +export function buildSentRecord(input: BuildSentRecordInput): SentFileRecord { + const retrieveCode = (input.response.detail as { code?: string } | undefined)?.code || '' + const fileName = (input.response.detail as { name?: string } | undefined)?.name || '' + + const totalSelectedSize = input.selectedFiles.reduce((total, file) => total + file.size, 0) + const displaySize = + input.sendType === 'text' + ? `${(input.textContent.length / 1024).toFixed(2)} KB` + : input.selectedFiles.length > 0 + ? `${(totalSelectedSize / (1024 * 1024)).toFixed(1)} MB` + : `${((input.selectedFile?.size || 0) / (1024 * 1024)).toFixed(1)} MB` + + return { + id: Date.now(), + filename: fileName, + date: new Date().toISOString().split('T')[0], + size: displaySize, + expiration: + input.expirationMethod === 'forever' + ? input.translate('send.expiration.forever') + : formatExpirationTime( + input.expirationMethod, + input.expirationValue, + input.translate, + input.getUnit + ), + retrieveCode + } +} diff --git a/src/utils/sent-record-actions.ts b/src/utils/sent-record-actions.ts new file mode 100644 index 0000000..944cbb0 --- /dev/null +++ b/src/utils/sent-record-actions.ts @@ -0,0 +1,17 @@ +import type { SentFileRecord } from '@/types' +import { copyRetrieveCode, copyRetrieveLink, copyWgetCommand } from '@/utils/clipboard' +import { buildSentRecordQrValue } from '@/utils/share-url' + +type CopyNotify = (message: string, type: 'success' | 'error') => void + +export function createSentRecordActions(notify: CopyNotify) { + return { + copyLink: (record: SentFileRecord) => + copyRetrieveLink(record.retrieveCode, { notify }), + copyCode: (record: SentFileRecord) => + copyRetrieveCode(record.retrieveCode, { notify }), + copyWgetCommand: (record: SentFileRecord) => + copyWgetCommand(record.retrieveCode, record.filename, { notify }), + getQRCodeValue: (record: SentFileRecord) => buildSentRecordQrValue(record) + } +} diff --git a/src/utils/share-url.ts b/src/utils/share-url.ts new file mode 100644 index 0000000..b361ab5 --- /dev/null +++ b/src/utils/share-url.ts @@ -0,0 +1,38 @@ +import { apiBaseURL } from '@/services/client' + +const getApiOrigin = () => { + if (!apiBaseURL) return window.location.origin + return new URL(apiBaseURL, window.location.origin).origin +} + +export function buildAbsoluteUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + return path + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${getApiOrigin()}${normalizedPath}` +} + +export function buildRetrieveUrl(code: string): string { + return `${window.location.origin}/#/?code=${code}` +} + +export function buildDownloadUrl(downloadUrl: string | null): string { + return downloadUrl ? buildAbsoluteUrl(downloadUrl) : '' +} + +export function buildReceivedRecordQrValue(record: { + code: string + downloadUrl: string | null +}): string { + return record.downloadUrl ? buildDownloadUrl(record.downloadUrl) : buildRetrieveUrl(record.code) +} + +export function buildSentRecordQrValue(record: { retrieveCode: string }): string { + return buildRetrieveUrl(record.retrieveCode) +} + +export function buildWgetCommand(retrieveCode: string, fileName: string): string { + return `wget ${buildAbsoluteUrl(`/share/select?code=${retrieveCode}`)} -O "${fileName}"` +} diff --git a/src/views/RetrievewFileView.vue b/src/views/RetrievewFileView.vue index 49f7c69..9430646 100644 --- a/src/views/RetrievewFileView.vue +++ b/src/views/RetrievewFileView.vue @@ -42,24 +42,23 @@ - - diff --git a/src/views/SendFileView.vue b/src/views/SendFileView.vue index 7d0e9c0..a65be2c 100644 --- a/src/views/SendFileView.vue +++ b/src/views/SendFileView.vue @@ -15,7 +15,7 @@
@@ -25,7 +25,7 @@ :selected-file="selectedFile" :selected-files="selectedFiles" :progress="uploadProgress" - :description="`支持各种常见格式,最大${getStorageUnit(config.uploadSize)}`" + :description="uploadDescription" @file-selected="handleFileSelected" @files-selected="handleFilesSelected" @file-drop="handleFileDrop" @@ -35,135 +35,11 @@ - -
- -
-
- - -
- - - -
-
-
-
+ - -
- -
-
- -
-
-

- {{ record.filename ? record.filename : 'Text' }} -

-

- {{ record.date }} · {{ record.size }} -

-
-
- - - -
-
-
-
- - - - - -
-
- -
-
-

- {{ t('send.fileDetails') }} -

- -
-
- - -
- -
-
-
- -
-
-

- {{ selectedRecord.filename }} -

-

- {{ selectedRecord.size }} · {{ selectedRecord.date }} -

-
-
-
-
- - - {{ selectedRecord.expiration }} - -
-
- - - 安全加密 - -
-
-
- - -
- -
-
-
-

取件码

- -
-

- {{ selectedRecord.retrieveCode }} -

-
- -
-
-

- - wget下载 -

- -
-

- 点击复制wget命令 -

-
-
- - -
-
- -
-

- 扫描二维码快速取件 -

-
-
-
- - -
- -
-
-
-
+ + + + + From bef0d2ce3f3d6fd3d1ddb7e9c74f9b04fcb26c25 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 02:38:29 +0800 Subject: [PATCH 19/66] fix: localize upload status text --- src/components/common/FileUploadArea.vue | 15 ++++++++------- src/composables/useSendFlow.ts | 6 ++++-- src/i18n/locales/en-US.ts | 11 ++++++++++- src/i18n/locales/zh-CN.ts | 11 ++++++++++- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/components/common/FileUploadArea.vue b/src/components/common/FileUploadArea.vue index 40d95bf..cff37d5 100644 --- a/src/components/common/FileUploadArea.vue +++ b/src/components/common/FileUploadArea.vue @@ -119,7 +119,7 @@ : 'bg-indigo-500 hover:bg-indigo-600 text-white' ]" > - {{ retryText }} + {{ retryLabel }} @@ -177,7 +177,7 @@ const props = withDefaults(defineProps(), { totalBytes: 0, errorMessage: '', allowRetry: true, - retryText: '重试', + retryText: '', showProgressDetails: true }) @@ -188,6 +188,7 @@ const isDarkMode = useInjectedDarkMode() // 使用computed属性处理多语言文本 const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder')) const descriptionText = computed(() => props.description || t('send.uploadArea.description')) +const retryLabel = computed(() => props.retryText || t('send.uploadArea.retry')) const fileInput = ref(null) const isDragActive = ref(false) @@ -204,7 +205,7 @@ const displayText = computed(() => { return props.selectedFiles[0].name } if (props.selectedFiles && props.selectedFiles.length > 1) { - return `已选择 ${props.selectedFiles.length} 个文件` + return t('send.uploadArea.selectedFiles', { count: props.selectedFiles.length }) } if (props.selectedFile) { return props.selectedFile.name @@ -249,16 +250,16 @@ const statusDescription = computed(() => { return props.errorMessage } if (props.uploadStatus === 'initializing') { - return '正在初始化上传...' + return t('send.uploadArea.status.initializing') } if (props.uploadStatus === 'uploading') { - return '正在上传文件...' + return t('send.uploadArea.status.uploading') } if (props.uploadStatus === 'confirming') { - return '正在确认上传...' + return t('send.uploadArea.status.confirming') } if (isSuccess.value) { - return '上传成功!' + return t('send.uploadArea.status.success') } return descriptionText.value }) diff --git a/src/composables/useSendFlow.ts b/src/composables/useSendFlow.ts index a798816..3f6a6b8 100644 --- a/src/composables/useSendFlow.ts +++ b/src/composables/useSendFlow.ts @@ -32,8 +32,10 @@ export function useSendFlow() { const isSubmitting = ref(false) const fileHash = ref('') const sendRecords = computed(() => fileDataStore.shareData) - const uploadDescription = computed( - () => `支持各种常见格式,最大${getStorageUnit(config.value.uploadSize)}` + const uploadDescription = computed(() => + t('send.uploadArea.descriptionWithLimit', { + size: getStorageUnit(config.value.uploadSize) + }) ) const expirationOptions = computed(() => config.value.expireStyle.map((value) => ({ diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 8f91b08..39150eb 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -215,7 +215,16 @@ export default { clickText: 'click to select files', textInput: 'Enter text to send here...', placeholder: 'Click or drag files here to upload', - description: 'Supports various common formats' + description: 'Supports various common formats', + descriptionWithLimit: 'Supports common formats, up to {size}', + retry: 'Retry', + selectedFiles: '{count} files selected', + status: { + initializing: 'Preparing upload...', + uploading: 'Uploading files...', + confirming: 'Confirming upload...', + success: 'Upload complete!' + } }, submit: 'Secure Send', submitting: 'Sending...', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 8ff63c0..680b1ca 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -217,7 +217,16 @@ export default { clickText: '点击选择文件', textInput: '在此输入要发送的文本...', placeholder: '点击或拖放文件到此处上传', - description: '支持各种常见格式' + description: '支持各种常见格式', + descriptionWithLimit: '支持各种常见格式,最大{size}', + retry: '重试', + selectedFiles: '已选择 {count} 个文件', + status: { + initializing: '正在初始化上传...', + uploading: '正在上传文件...', + confirming: '正在确认上传...', + success: '上传成功!' + } }, submit: '安全寄送', submitting: '发送中...', From 32cbc10cd8d7eab845422c2ad4f3f59c21636445 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 02:39:30 +0800 Subject: [PATCH 20/66] chore: ignore local verification output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8ee54e8..2d29e0a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules dist dist-ssr coverage +output *.local /cypress/videos/ From c6f33096be936cb092408dd95e1583d2736b693b Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 02:52:38 +0800 Subject: [PATCH 21/66] feat: connect admin session and public config endpoints --- src/composables/index.ts | 1 + src/composables/useAdminLogin.ts | 8 +++++- src/composables/useAdminSession.ts | 35 ++++++++++++++++++++++++++ src/layout/AdminLayout/AdminLayout.vue | 15 +++++++---- src/services/config.ts | 28 ++++++++++++++++++++- src/types/api.ts | 1 + src/types/auth.ts | 2 ++ 7 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 src/composables/useAdminSession.ts diff --git a/src/composables/index.ts b/src/composables/index.ts index 5086761..c894854 100644 --- a/src/composables/index.ts +++ b/src/composables/index.ts @@ -1,5 +1,6 @@ export { useAdminFiles } from './useAdminFiles' export { useAdminLogin } from './useAdminLogin' +export { useAdminSession } from './useAdminSession' export { useAppShell } from './useAppShell' export { useDashboardStats } from './useDashboardStats' export { useInjectedDarkMode } from './useInjectedDarkMode' diff --git a/src/composables/useAdminLogin.ts b/src/composables/useAdminLogin.ts index 7c81627..ce6a219 100644 --- a/src/composables/useAdminLogin.ts +++ b/src/composables/useAdminLogin.ts @@ -35,7 +35,13 @@ export function useAdminLogin() { return false } - adminStore.setToken(response.detail.token) + adminStore.login({ + id: response.detail.id || 'admin', + username: response.detail.username || 'admin', + token: response.detail.token, + token_type: response.detail.token_type, + expires_at: response.detail.expires_at + }) return true } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error') diff --git a/src/composables/useAdminSession.ts b/src/composables/useAdminSession.ts new file mode 100644 index 0000000..9333146 --- /dev/null +++ b/src/composables/useAdminSession.ts @@ -0,0 +1,35 @@ +import { AuthService } from '@/services' +import { useAdminStore } from '@/stores/adminStore' + +export function useAdminSession() { + const adminStore = useAdminStore() + + const verifySession = async () => { + if (!adminStore.hasToken) return false + + try { + const response = await AuthService.verifyToken() + if (response.code === 200 && response.detail?.token) { + adminStore.login(response.detail) + return true + } + } catch { + adminStore.logout() + } + + return false + } + + const logout = async () => { + try { + await AuthService.logout() + } finally { + adminStore.logout() + } + } + + return { + verifySession, + logout + } +} diff --git a/src/layout/AdminLayout/AdminLayout.vue b/src/layout/AdminLayout/AdminLayout.vue index 6eb588f..24c3a7d 100644 --- a/src/layout/AdminLayout/AdminLayout.vue +++ b/src/layout/AdminLayout/AdminLayout.vue @@ -120,7 +120,7 @@ import { import { RouterLink, useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { ROUTE_NAMES, ROUTES } from '@/constants' -import { useAdminStore } from '@/stores/adminStore' +import { useAdminSession } from '@/composables' interface MenuItem { id: string @@ -133,7 +133,7 @@ const router = useRouter() const route = useRoute() const { t } = useI18n() const isDarkMode = inject('isDarkMode') -const adminStore = useAdminStore() +const { verifySession, logout } = useAdminSession() const menuItems: MenuItem[] = [ { id: ROUTE_NAMES.DASHBOARD, @@ -172,6 +172,11 @@ const handleResize = () => { onMounted(() => { handleResize() window.addEventListener('resize', handleResize) + void verifySession().then((isValid) => { + if (!isValid) { + void router.push(ROUTES.LOGIN) + } + }) }) onUnmounted(() => { @@ -179,9 +184,9 @@ onUnmounted(() => { }) // 登出处理 -const handleLogout = () => { - adminStore.logout() - router.push(ROUTES.LOGIN) +const handleLogout = async () => { + await logout() + await router.push(ROUTES.LOGIN) } diff --git a/src/services/config.ts b/src/services/config.ts index 9d60b75..0f183bf 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,13 +1,39 @@ import api from './client' import type { ApiResponse, ConfigState } from '@/types' +type PublicConfigEnvelope = { + config?: Partial + meta?: unknown +} + +const isPublicConfigEnvelope = ( + detail: ConfigState | PublicConfigEnvelope | null | undefined +): detail is PublicConfigEnvelope => { + return !!detail && typeof detail === 'object' && 'config' in detail +} + export class ConfigService { static async getConfig(): Promise> { return api.get('/admin/config/get') } static async getUserConfig(): Promise> { - return api.post('/') + try { + const response = (await api.get('/api/v1/config')) as ApiResponse< + ConfigState | PublicConfigEnvelope + > + + if (isPublicConfigEnvelope(response.detail) && response.detail.config) { + return { + ...response, + detail: response.detail.config as ConfigState + } + } + + return response as ApiResponse + } catch { + return api.post('/') + } } static async updateConfig(config: Partial): Promise { diff --git a/src/types/api.ts b/src/types/api.ts index 3ae1779..ebb6f43 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,6 +1,7 @@ export interface ApiResponse { code: number message?: string + msg?: string detail?: T } diff --git a/src/types/auth.ts b/src/types/auth.ts index 8558757..800740b 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -2,4 +2,6 @@ export interface AdminUser { id: string username: string token: string + token_type?: string + expires_at?: number } From 46496812451413e11cd286046d17115ebe1a5a33 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 03:01:40 +0800 Subject: [PATCH 22/66] fix: honor presign proxy upload URLs --- src/composables/usePresignedUpload.ts | 5 ++++- src/services/presign-upload.ts | 9 +++++++-- src/types/presign-upload.ts | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts index 2ff2035..97630af 100644 --- a/src/composables/usePresignedUpload.ts +++ b/src/composables/usePresignedUpload.ts @@ -213,10 +213,13 @@ export function usePresignedUpload(options: UsePresignedUploadOptions = {}) { presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING const progressHandler = createProgressHandler(options?.onProgress) + const uploadUrl = + session.proxy_upload_url || session.upload_url || session.legacy_proxy_upload_url const response = await PresignUploadService.proxyUpload( session.upload_id, file, - progressHandler + progressHandler, + uploadUrl ) if (response.code === 200 && response.detail?.code) { diff --git a/src/services/presign-upload.ts b/src/services/presign-upload.ts index 3d1def8..47c6684 100644 --- a/src/services/presign-upload.ts +++ b/src/services/presign-upload.ts @@ -19,12 +19,17 @@ export class PresignUploadService { static async proxyUpload( uploadId: string, file: File, - onProgress?: (progress: UploadProgress) => void + onProgress?: (progress: UploadProgress) => void, + uploadUrl?: string ): Promise> { const formData = new FormData() formData.append('file', file) - return api.put(`/presign/upload/proxy/${uploadId}`, formData, multipartUploadConfig(onProgress)) + return api.put( + uploadUrl || `/presign/upload/proxy/${uploadId}`, + formData, + multipartUploadConfig(onProgress) + ) } static async confirmUpload( diff --git a/src/types/presign-upload.ts b/src/types/presign-upload.ts index 8f38144..abb09ea 100644 --- a/src/types/presign-upload.ts +++ b/src/types/presign-upload.ts @@ -22,6 +22,8 @@ export interface PresignInitRequest { export interface PresignInitResponse { upload_id: string upload_url: string + proxy_upload_url?: string + legacy_proxy_upload_url?: string mode: PresignUploadMode expires_in: number } From 7a9ad94a1a6b861dfa3341fd20a8308d01d48d77 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 03:13:04 +0800 Subject: [PATCH 23/66] feat: improve system settings save workflow --- src/composables/useSystemConfig.ts | 116 +++++-- src/i18n/locales/en-US.ts | 8 +- src/i18n/locales/zh-CN.ts | 8 +- src/views/manage/SystemSettingsView.vue | 422 ++++++++++++++++++------ 4 files changed, 413 insertions(+), 141 deletions(-) diff --git a/src/composables/useSystemConfig.ts b/src/composables/useSystemConfig.ts index 5b8ec87..1d27030 100644 --- a/src/composables/useSystemConfig.ts +++ b/src/composables/useSystemConfig.ts @@ -18,39 +18,78 @@ type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload' export function useSystemConfig() { const alertStore = useAlertStore() const configStore = useConfigStore() - + // 状态管理 const config = ref({ ...DEFAULT_CONFIG_STATE }) - const isLoading = ref(false) + const isRefreshing = ref(false) + const isSaving = ref(false) + const savedPayloadSnapshot = ref('') const fileSize = ref(1) const sizeUnit = ref('MB') const saveTime = ref(1) const saveTimeUnit = ref('天') - + + const isLoading = computed(() => isRefreshing.value || isSaving.value) + + const buildSubmitPayload = (): Partial => { + const payload: Partial = buildConfigSubmitPayload( + config.value, + { value: fileSize.value, unit: sizeUnit.value }, + { value: saveTime.value, unit: saveTimeUnit.value } + ) + + if (!payload.admin_token) { + delete payload.admin_token + } + + return payload + } + + const snapshotPayload = (payload: Partial) => JSON.stringify(payload) + + const normalizeEditableConfig = (nextConfig: Partial): ConfigState => ({ + ...DEFAULT_CONFIG_STATE, + ...nextConfig, + admin_token: '' + }) + + const markConfigSaved = () => { + savedPayloadSnapshot.value = snapshotPayload(buildSubmitPayload()) + } + + const isDirty = computed(() => { + if (!savedPayloadSnapshot.value) { + return false + } + + return snapshotPayload(buildSubmitPayload()) !== savedPayloadSnapshot.value + }) + // 从本地存储获取配置 const getStoredConfig = (): ConfigState | null => { - return readStoredConfig() + const storedConfig = readStoredConfig>() + return storedConfig ? normalizeEditableConfig(storedConfig) : null } - + // 保存配置到本地存储 const saveConfigToStorage = (configData: ConfigState) => { configStore.updateConfig(configData) } - + // 获取系统配置 const fetchConfig = async (): Promise => { try { - isLoading.value = true - + isRefreshing.value = true + const response = await ConfigService.getConfig() - + if (response.code === 200 && response.detail) { - config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail } + config.value = normalizeEditableConfig(response.detail) const notifyMessage = configStore.applyRemoteConfig(config.value) if (notifyMessage) { alertStore.showAlert(notifyMessage, 'success') } - + return config.value } else { throw new Error(response.message || '获取配置失败') @@ -62,23 +101,25 @@ export function useSystemConfig() { config.value = storedConfig return config.value } - + alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error') return null } finally { - isLoading.value = false + isRefreshing.value = false } } - + // 更新系统配置 const updateConfig = async (newConfig: Partial): Promise => { try { - isLoading.value = true - + isSaving.value = true + const response = await ConfigService.updateConfig(newConfig) - + if (response.code === 200) { - config.value = { ...config.value, ...newConfig } + config.value = normalizeEditableConfig({ ...config.value, ...newConfig }) + syncConfigForm(config.value) + markConfigSaved() saveConfigToStorage(config.value) alertStore.showAlert('配置更新成功!', 'success') return true @@ -89,7 +130,7 @@ export function useSystemConfig() { alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error') return false } finally { - isLoading.value = false + isSaving.value = false } } @@ -111,52 +152,57 @@ export function useSystemConfig() { const latestConfig = await fetchConfig() if (latestConfig) { syncConfigForm(latestConfig) + markConfigSaved() } } - const submitConfig = () => - updateConfig( - buildConfigSubmitPayload( - config.value, - { value: fileSize.value, unit: sizeUnit.value }, - { value: saveTime.value, unit: saveTimeUnit.value } - ) - ) - + const submitConfig = () => { + if (!isDirty.value || isSaving.value) { + return Promise.resolve(false) + } + + return updateConfig(buildSubmitPayload()) + } + // 初始化配置 const initConfig = async () => { // 先尝试从本地存储加载 const storedConfig = getStoredConfig() if (storedConfig) { config.value = storedConfig + syncConfigForm(storedConfig) + markConfigSaved() } - + // 然后从服务器获取最新配置 - await fetchConfig() + await refreshConfig() } - + // 计算属性 const maxFileSizeMB = computed(() => { return Math.round(config.value.uploadSize / 1024 / 1024) }) - + const isConfigLoaded = computed(() => { return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value }) - + return { // 状态 config, isLoading, + isRefreshing, + isSaving, + isDirty, fileSize, sizeUnit, saveTime, saveTimeUnit, - + // 计算属性 maxFileSizeMB, isConfigLoaded, - + // 方法 fetchConfig, updateConfig, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 39150eb..7a1d55d 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -446,7 +446,13 @@ export default { mb: 'MB', gb: 'GB' }, - saveChanges: 'Save Settings' + saveChanges: 'Save Settings', + refreshConfig: 'Refresh Config', + refreshing: 'Refreshing', + saving: 'Saving', + unsavedChanges: 'Unsaved configuration changes', + allChangesSaved: 'All configuration changes are saved', + refreshBlocked: 'Save current changes before refreshing' }, systemSettings: { title: 'System Settings', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 680b1ca..0e5d958 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -477,7 +477,13 @@ export default { errorLimits: '访问保护', errorPerMinute: '检测时间窗口(在此时间内统计错误次数)', errorCountLimit: '允许错误次数(超过后临时封禁)', - saveChanges: '保存设置' + saveChanges: '保存设置', + refreshConfig: '刷新配置', + refreshing: '刷新中', + saving: '保存中', + unsavedChanges: '有未保存的配置变更', + allChangesSaved: '所有配置已保存', + refreshBlocked: '请先保存当前变更再刷新' }, dashboard: { title: '仪表盘', diff --git a/src/views/manage/SystemSettingsView.vue b/src/views/manage/SystemSettingsView.vue index e2b7153..317ed4c 100644 --- a/src/views/manage/SystemSettingsView.vue +++ b/src/views/manage/SystemSettingsView.vue @@ -1,6 +1,8 @@ From 060e77d343ca0daca6718c0be5c0f956a18cd857 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 03:39:13 +0800 Subject: [PATCH 25/66] feat: improve dashboard refresh state --- src/composables/useDashboardStats.ts | 130 +++++++++++++++++---------- src/i18n/locales/en-US.ts | 3 + src/i18n/locales/zh-CN.ts | 3 + src/types/dashboard.ts | 48 ++++++---- src/views/manage/DashboardView.vue | 107 +++++++++++++++++----- 5 files changed, 204 insertions(+), 87 deletions(-) diff --git a/src/composables/useDashboardStats.ts b/src/composables/useDashboardStats.ts index 452fc92..b28fa75 100644 --- a/src/composables/useDashboardStats.ts +++ b/src/composables/useDashboardStats.ts @@ -1,7 +1,11 @@ -import { reactive } from 'vue' +import { computed, ref, reactive } from 'vue' import { StatsService } from '@/services' import type { DashboardViewData } from '@/types' -import { formatFileSize } from '@/utils/common' +import { formatFileSize, getErrorMessage } from '@/utils/common' + +type UseDashboardStatsOptions = { + loadFailedMessage?: string +} const emptyDashboardData = (): DashboardViewData => ({ hasExtendedStats: false, @@ -50,59 +54,91 @@ const formatDuration = (startTimestamp: number | null) => { return `${days}天${hours}小时` } -export function useDashboardStats() { +const normalizeRecentFiles = (recentFiles: DashboardViewData['recentFiles']) => + recentFiles.map((file) => ({ + ...file, + size: toNumber(file.size), + expiredCount: toNumber(file.expiredCount), + usedCount: toNumber(file.usedCount) + })) + +export function useDashboardStats(options: UseDashboardStatsOptions = {}) { const dashboardData = reactive(emptyDashboardData()) + const isLoading = ref(false) + const errorMessage = ref('') + const lastUpdatedAt = ref(null) + const lastUpdatedText = computed(() => + lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-' + ) const fetchDashboardData = async () => { - const response = await StatsService.getDashboard() - if (!response.detail) return + isLoading.value = true + errorMessage.value = '' + + try { + const response = await StatsService.getDashboard() + if (!response.detail) { + throw new Error('No dashboard data') + } - const detail = response.detail - dashboardData.totalFiles = toNumber(detail.totalFiles) - dashboardData.storageUsed = toNumber(detail.storageUsed) - dashboardData.yesterdayCount = toNumber(detail.yesterdayCount) - dashboardData.todayCount = toNumber(detail.todayCount) - dashboardData.yesterdaySize = toNumber(detail.yesterdaySize) - dashboardData.todaySize = toNumber(detail.todaySize) - dashboardData.sysUptime = detail.sysUptime - dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount') - dashboardData.activeCount = dashboardData.hasExtendedStats - ? toNumber(detail.activeCount) - : dashboardData.totalFiles - dashboardData.expiredCount = toNumber(detail.expiredCount) - dashboardData.textCount = toNumber(detail.textCount) - dashboardData.fileCount = toNumber(detail.fileCount) - dashboardData.chunkedCount = toNumber(detail.chunkedCount) - dashboardData.usedCount = toNumber(detail.usedCount) - dashboardData.storageBackend = detail.storageBackend || '-' - dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit) - dashboardData.openUpload = toNumber(detail.openUpload) - dashboardData.enableChunk = toNumber(detail.enableChunk) - dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds) - dashboardData.topSuffixes = detail.topSuffixes || [] - dashboardData.recentFiles = detail.recentFiles || [] + const detail = response.detail + dashboardData.totalFiles = toNumber(detail.totalFiles) + dashboardData.storageUsed = toNumber(detail.storageUsed) + dashboardData.yesterdayCount = toNumber(detail.yesterdayCount) + dashboardData.todayCount = toNumber(detail.todayCount) + dashboardData.yesterdaySize = toNumber(detail.yesterdaySize) + dashboardData.todaySize = toNumber(detail.todaySize) + dashboardData.sysUptime = detail.sysUptime + dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount') + dashboardData.activeCount = dashboardData.hasExtendedStats + ? toNumber(detail.activeCount) + : dashboardData.totalFiles + dashboardData.expiredCount = toNumber(detail.expiredCount) + dashboardData.textCount = toNumber(detail.textCount) + dashboardData.fileCount = toNumber(detail.fileCount) + dashboardData.chunkedCount = toNumber(detail.chunkedCount) + dashboardData.usedCount = toNumber(detail.usedCount) + dashboardData.storageBackend = detail.storageBackend || '-' + dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit) + dashboardData.openUpload = toNumber(detail.openUpload) + dashboardData.enableChunk = toNumber(detail.enableChunk) + dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds) + dashboardData.topSuffixes = detail.topSuffixes || [] + dashboardData.recentFiles = normalizeRecentFiles(detail.recentFiles || []) - dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) - dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) - dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) - dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) - dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) - dashboardData.activeRatio = dashboardData.totalFiles - ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) - : 0 - dashboardData.textRatio = dashboardData.totalFiles - ? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100) - : 0 - dashboardData.fileRatio = dashboardData.totalFiles - ? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100) - : 0 - dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit - ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) - : 0 + dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) + dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) + dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) + dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) + dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) + dashboardData.activeRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.textRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.fileRatio = dashboardData.totalFiles + ? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100) + : 0 + dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit + ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) + : 0 + lastUpdatedAt.value = new Date() + } catch (error) { + errorMessage.value = getErrorMessage( + error, + options.loadFailedMessage || 'Failed to load dashboard data' + ) + } finally { + isLoading.value = false + } } return { dashboardData, - fetchDashboardData + errorMessage, + fetchDashboardData, + isLoading, + lastUpdatedText } } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 91d328f..3edb985 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -65,6 +65,9 @@ export default { yesterdayShares: 'Yesterday: {count}', serverUptime: 'Uptime', refresh: 'Refresh', + refreshing: 'Refreshing', + lastUpdated: 'Last updated: {time}', + loadFailed: 'Failed to load dashboard data', fileHealth: 'File Health', fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', activeFileRatio: 'Active Ratio', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 51d6610..2e59ff2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -65,6 +65,9 @@ export default { yesterdayShares: '昨日分享:{count}', serverUptime: '运行时间', refresh: '刷新数据', + refreshing: '刷新中', + lastUpdated: '最近更新:{time}', + loadFailed: '仪表盘数据加载失败', fileHealth: '文件健康', fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', activeFileRatio: '有效占比', diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index 5d402d3..a200857 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -1,24 +1,24 @@ export interface DashboardData { totalFiles: number - storageUsed: number + storageUsed: number | string yesterdayCount: number todayCount: number - yesterdaySize: number - todaySize: number + yesterdaySize: number | string + todaySize: number | string sysUptime: number | null - activeCount: number - expiredCount: number - textCount: number - fileCount: number - chunkedCount: number - usedCount: number - storageBackend: string - uploadSizeLimit: number - openUpload: number - enableChunk: number - maxSaveSeconds: number - topSuffixes: DashboardSuffixStat[] - recentFiles: DashboardRecentFile[] + activeCount?: number + expiredCount?: number + textCount?: number + fileCount?: number + chunkedCount?: number + usedCount?: number + storageBackend?: string + uploadSizeLimit?: number + openUpload?: number + enableChunk?: number + maxSaveSeconds?: number + topSuffixes?: DashboardSuffixStat[] + recentFiles?: DashboardRecentFile[] } export interface DashboardSuffixStat { @@ -42,6 +42,22 @@ export interface DashboardRecentFile { export interface DashboardViewData extends DashboardData { hasExtendedStats: boolean + activeCount: number + expiredCount: number + textCount: number + fileCount: number + chunkedCount: number + usedCount: number + storageBackend: string + uploadSizeLimit: number + openUpload: number + enableChunk: number + maxSaveSeconds: number + topSuffixes: DashboardSuffixStat[] + recentFiles: DashboardRecentFile[] + storageUsed: number + yesterdaySize: number + todaySize: number storageUsedText: string yesterdaySizeText: string todaySizeText: string diff --git a/src/views/manage/DashboardView.vue b/src/views/manage/DashboardView.vue index e5cf014..b00bfde 100644 --- a/src/views/manage/DashboardView.vue +++ b/src/views/manage/DashboardView.vue @@ -6,22 +6,38 @@

{{ t('admin.dashboard.title') }}

+

+ {{ t('admin.dashboard.lastUpdated', { time: lastUpdatedText }) }} +

+
+ {{ errorMessage }} +
+
- +
@@ -157,10 +176,7 @@ :label="t('admin.dashboard.guestUpload')" :value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')" /> - +
@@ -168,7 +184,10 @@ {{ t('admin.dashboard.todayCapacityReference') }} {{ dashboardData.todaySizeRatio }}%
-
+
-
+
{{ t('common.noData') }}
- {{ item.suffix || t('admin.dashboard.textType') }} + {{ + item.suffix || t('admin.dashboard.textType') + }} {{ item.count }}
-
+
-
- +
+
- - - - @@ -241,7 +287,10 @@
+ {{ t('admin.dashboard.table.file') }} + {{ t('admin.dashboard.table.size') }} + {{ t('admin.dashboard.table.usage') }} + {{ t('admin.dashboard.table.status') }}
-
+
@@ -306,7 +355,10 @@ import { formatFileSize, formatTimestamp } from '@/utils/common' const isDarkMode = useInjectedDarkMode() const { t } = useI18n() -const { dashboardData, fetchDashboardData } = useDashboardStats() +const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } = + useDashboardStats({ + loadFailedMessage: t('admin.dashboard.loadFailed') + }) const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900')) const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500')) @@ -381,10 +433,17 @@ const PolicyRow = defineComponent({ }, setup(props) { return () => - h('div', { class: 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' }, [ - h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label), - h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value) - ]) + h( + 'div', + { + class: + 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' + }, + [ + h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label), + h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value) + ] + ) } }) From e243f1597b9983b81565087e0a1ec25850cafe44 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 04:01:28 +0800 Subject: [PATCH 26/66] feat: upgrade admin file workspace --- src/composables/useAdminFiles.ts | 195 +++++++++- src/i18n/locales/en-US.ts | 35 +- src/i18n/locales/zh-CN.ts | 35 +- src/services/file.ts | 9 +- src/types/file.ts | 50 ++- src/views/manage/FileManageView.vue | 584 ++++++++++++++++++++-------- 6 files changed, 730 insertions(+), 178 deletions(-) diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index 936441a..d399c68 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -2,11 +2,36 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { FileService } from '@/services' import { useAlertStore } from '@/stores/alertStore' -import type { AdminFileViewItem, FileEditForm, FileListItem } from '@/types' +import type { + AdminFileListParams, + AdminFileStatusFilter, + AdminFileSummary, + AdminFileTypeFilter, + AdminFileViewItem, + FileEditForm, + FileListItem +} from '@/types' import { copyToClipboard } from '@/utils/clipboard' import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common' -const TEXT_PREVIEW_THRESHOLD = 30 +const emptySummary = (): AdminFileSummary => ({ + totalFiles: 0, + activeCount: 0, + expiredCount: 0, + textCount: 0, + fileCount: 0, + chunkedCount: 0, + storageUsed: 0, + usedCount: 0 +}) + +const normalizeCount = (value: number | string | null | undefined) => Number(value || 0) + +const isExpiredByDate = (value: string | null | undefined) => { + if (!value) return false + const timestamp = new Date(value).getTime() + return Number.isFinite(timestamp) && timestamp < Date.now() +} export function useAdminFiles() { const { t } = useI18n() @@ -14,11 +39,18 @@ export function useAdminFiles() { const tableData = ref([]) const hasLoadError = ref(false) - const params = ref({ + const isLoading = ref(false) + const isSaving = ref(false) + const summary = ref(emptySummary()) + const params = ref({ page: 1, size: 10, total: 0, - keyword: '' + keyword: '', + status: 'all', + type: 'all', + sortBy: 'created_at', + sortOrder: 'desc' }) const showEditModal = ref(false) @@ -33,17 +65,98 @@ export function useAdminFiles() { const showTextPreview = ref(false) const previewText = ref('') - const totalPages = computed(() => Math.ceil(params.value.total / params.value.size)) - - const createFileViewItem = (file: FileListItem): AdminFileViewItem => ({ - ...file, - displaySize: formatFileSize(file.size), - displayExpiredAt: file.expired_at - ? formatTimestamp(file.expired_at) - : t('send.expiration.units.forever'), - canPreviewText: Boolean(file.text && file.text.length > TEXT_PREVIEW_THRESHOLD) + const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1)) + const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed)) + + const requestParams = computed(() => ({ + page: params.value.page, + size: params.value.size, + keyword: params.value.keyword, + status: params.value.status === 'all' ? undefined : params.value.status, + type: params.value.type === 'all' ? undefined : params.value.type, + sortBy: params.value.sortBy, + sortOrder: params.value.sortOrder + })) + + const hasActiveFilters = computed( + () => + Boolean(params.value.keyword?.trim()) || + params.value.status !== 'all' || + params.value.type !== 'all' + ) + + const inferIsText = (file: FileListItem) => { + if (typeof file.isText === 'boolean') return file.isText + if (typeof file.is_text === 'boolean') return file.is_text + if (file.type) return file.type === 'text' + return Boolean(file.text) + } + + const inferIsExpired = (file: FileListItem) => { + if (typeof file.isExpired === 'boolean') return file.isExpired + if (typeof file.is_expired === 'boolean') return file.is_expired + if ( + file.expired_count !== null && + file.expired_count !== undefined && + file.expired_count <= 0 + ) { + return true + } + return isExpiredByDate(file.expired_at) + } + + const inferIsChunked = (file: FileListItem) => Boolean(file.isChunked ?? file.is_chunked) + + const getRemainingDownloads = (file: FileListItem) => { + if (file.remainingDownloads !== undefined) return file.remainingDownloads + if (file.remaining_downloads !== undefined) return file.remaining_downloads + if ( + file.expired_count !== null && + file.expired_count !== undefined && + file.expired_count >= 0 + ) { + return Math.max(file.expired_count, 0) + } + return null + } + + const buildFallbackSummary = (files: AdminFileViewItem[], total: number): AdminFileSummary => ({ + totalFiles: total, + activeCount: files.filter((file) => !file.isExpiredFile).length, + expiredCount: files.filter((file) => file.isExpiredFile).length, + textCount: files.filter((file) => file.isTextFile).length, + fileCount: files.filter((file) => !file.isTextFile).length, + chunkedCount: files.filter((file) => file.isChunkedFile).length, + storageUsed: files.reduce((totalSize, file) => totalSize + normalizeCount(file.size), 0), + usedCount: files.reduce( + (totalUsed, file) => totalUsed + normalizeCount(file.usedCount ?? file.used_count), + 0 + ) }) + const createFileViewItem = (file: FileListItem): AdminFileViewItem => { + const isTextFile = inferIsText(file) + const isExpiredFile = inferIsExpired(file) + const isChunkedFile = inferIsChunked(file) + const remainingDownloadsValue = getRemainingDownloads(file) + const usedCount = normalizeCount(file.usedCount ?? file.used_count) + + return { + ...file, + displayName: file.name || `${file.prefix}${file.suffix}` || file.code, + displaySize: formatFileSize(file.size), + displayExpiredAt: file.expired_at + ? formatTimestamp(file.expired_at) + : t('send.expiration.units.forever'), + displayUsage: `${usedCount} ${t('common.times')}`, + isTextFile, + isExpiredFile, + isChunkedFile, + remainingDownloadsValue, + canPreviewText: Boolean(file.text) + } + } + const resetEditForm = () => { editForm.value = { id: null, @@ -56,16 +169,23 @@ export function useAdminFiles() { } const loadFiles = async () => { + isLoading.value = true try { hasLoadError.value = false - const res = await FileService.getAdminFileList(params.value) + const res = await FileService.getAdminFileList(requestParams.value) if (!res.detail) return tableData.value = res.detail.data.map(createFileViewItem) params.value.total = res.detail.total + summary.value = res.detail.summary || buildFallbackSummary(tableData.value, res.detail.total) } catch (error) { hasLoadError.value = true - alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), 'error') + alertStore.showAlert( + getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), + 'error' + ) + } finally { + isLoading.value = false } } @@ -74,6 +194,32 @@ export function useAdminFiles() { await loadFiles() } + const refreshFiles = async () => { + await loadFiles() + } + + const resetFilters = async () => { + params.value.page = 1 + params.value.keyword = '' + params.value.status = 'all' + params.value.type = 'all' + params.value.sortBy = 'created_at' + params.value.sortOrder = 'desc' + await loadFiles() + } + + const setStatusFilter = async (status: AdminFileStatusFilter) => { + params.value.status = status + params.value.page = 1 + await loadFiles() + } + + const setTypeFilter = async (type: AdminFileTypeFilter) => { + params.value.type = type + params.value.page = 1 + await loadFiles() + } + const handlePageChange = async (page: number | string) => { if (typeof page === 'string') return if (page < 1 || page > totalPages.value) return @@ -100,12 +246,17 @@ export function useAdminFiles() { } const handleUpdate = async () => { + if (isSaving.value) return + + isSaving.value = true try { await FileService.updateFile(editForm.value) await loadFiles() closeEditModal() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error') + } finally { + isSaving.value = false } } @@ -116,6 +267,9 @@ export function useAdminFiles() { try { await FileService.deleteAdminFile(id) + if (tableData.value.length === 1 && params.value.page > 1) { + params.value.page -= 1 + } await loadFiles() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error') @@ -143,7 +297,12 @@ export function useAdminFiles() { return { tableData, hasLoadError, + hasActiveFilters, + isLoading, + isSaving, params, + storageUsedText, + summary, showEditModal, editForm, showTextPreview, @@ -158,6 +317,10 @@ export function useAdminFiles() { handleUpdate, loadFiles, openEditModal, - openTextPreview + openTextPreview, + refreshFiles, + resetFilters, + setStatusFilter, + setTypeFilter } } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 3edb985..9dfe151 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -378,10 +378,30 @@ export default { // File Management fileManage: { title: 'File Management', - searchPlaceholder: 'Search file name, description...', + subtitle: '{count} share records. Filter by status, type, and usage.', + searchPlaceholder: 'Search code, name, or text...', allFiles: 'All Files', editFileInfo: 'Edit File Information', saveChanges: 'Save Changes', + refresh: 'Refresh List', + resetFilters: 'Reset Filters', + totalFiles: 'All Records', + activeFiles: 'Available', + expiredFiles: 'Expired', + storageUsed: 'Storage Used', + statusLabel: 'Status', + typeLabel: 'Type', + all: 'All', + active: 'Available', + expired: 'Expired', + fileType: 'File', + textType: 'Text', + chunkedType: 'Chunked', + sortBy: 'Sort', + unlimited: 'Unlimited', + remaining: '{count} left', + loadError: 'Failed to load file list', + noMatches: 'No matching files', viewText: 'View', textPreview: 'Text Preview', copyText: 'Copy Text', @@ -391,11 +411,24 @@ export default { headers: { code: 'Retrieve Code', name: 'Name', + type: 'Type', size: 'Size', + usage: 'Retrievals', + status: 'Status', description: 'Description', expiration: 'Expiration', actions: 'Actions' }, + sort: { + createdAt: 'Created Time', + expiredAt: 'Expiration', + name: 'Name', + size: 'Size', + usedCount: 'Retrievals', + code: 'Retrieve Code', + desc: 'Descending', + asc: 'Ascending' + }, form: { code: 'Retrieve Code', codePlaceholder: 'Enter retrieve code', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 2e59ff2..cda7910 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -376,10 +376,30 @@ export default { // 文件管理 fileManage: { title: '文件管理', - searchPlaceholder: '搜索文件名称、描述...', + subtitle: '共 {count} 条分享记录,可按状态、类型和使用情况快速筛选。', + searchPlaceholder: '搜索取件码、名称或文本...', allFiles: '所有文件', editFileInfo: '编辑文件信息', saveChanges: '保存更改', + refresh: '刷新列表', + resetFilters: '重置筛选', + totalFiles: '全部记录', + activeFiles: '可取件', + expiredFiles: '已过期', + storageUsed: '占用空间', + statusLabel: '状态', + typeLabel: '类型', + all: '全部', + active: '可取件', + expired: '已过期', + fileType: '文件', + textType: '文本', + chunkedType: '分片', + sortBy: '排序', + unlimited: '不限次数', + remaining: '剩余 {count} 次', + loadError: '文件列表加载失败', + noMatches: '没有匹配的文件', viewText: '查看', textPreview: '文本预览', copyText: '复制文本', @@ -389,11 +409,24 @@ export default { headers: { code: '取件码', name: '名称', + type: '类型', size: '大小', + usage: '取件', + status: '状态', description: '描述', expiration: '过期时间', actions: '操作' }, + sort: { + createdAt: '创建时间', + expiredAt: '过期时间', + name: '名称', + size: '大小', + usedCount: '取件次数', + code: '取件码', + desc: '降序', + asc: '升序' + }, form: { code: '取件码', codePlaceholder: '输入取件码', diff --git a/src/services/file.ts b/src/services/file.ts index f88ae9e..9b469b4 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -7,6 +7,7 @@ import type { ChunkUploadInitResponse, ChunkUploadResponse, FileEditForm, + AdminFileListParams, FileInfo, FileListResponse, FileUploadResponse, @@ -116,11 +117,9 @@ export class FileService { return response.data } - static async getAdminFileList(params: { - page: number - size: number - keyword?: string - }): Promise> { + static async getAdminFileList( + params: AdminFileListParams + ): Promise> { return api.get('/admin/file/list', { params }) } diff --git a/src/types/file.ts b/src/types/file.ts index 662d465..b3cdfb0 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -16,17 +16,64 @@ export interface FileListItem { size: number text?: string description?: string - expired_at: string + expired_at: string | null expired_count: number | null created_at: string + name?: string + type?: 'text' | 'file' + status?: 'active' | 'expired' + isText?: boolean + is_text?: boolean + isExpired?: boolean + is_expired?: boolean + isChunked?: boolean + is_chunked?: boolean + remainingDownloads?: number | null + remaining_downloads?: number | null + usedCount?: number + used_count?: number + fileHash?: string | null + file_hash?: string | null } export interface AdminFileViewItem extends FileListItem { + displayName: string displaySize: string displayExpiredAt: string + displayUsage: string + isTextFile: boolean + isExpiredFile: boolean + isChunkedFile: boolean + remainingDownloadsValue: number | null canPreviewText: boolean } +export interface AdminFileSummary { + totalFiles: number + activeCount: number + expiredCount: number + textCount: number + fileCount: number + chunkedCount: number + storageUsed: number + usedCount: number +} + +export type AdminFileStatusFilter = 'all' | 'active' | 'expired' +export type AdminFileTypeFilter = 'all' | 'file' | 'text' | 'chunked' +export type AdminFileSortBy = 'created_at' | 'expired_at' | 'name' | 'size' | 'used_count' | 'code' +export type AdminFileSortOrder = 'asc' | 'desc' + +export interface AdminFileListParams { + page: number + size: number + keyword?: string + status?: AdminFileStatusFilter + type?: AdminFileTypeFilter + sortBy?: AdminFileSortBy + sortOrder?: AdminFileSortOrder +} + export interface FileEditForm { id: number | null code: string @@ -41,6 +88,7 @@ export interface FileListResponse { total: number page: number size: number + summary?: AdminFileSummary } export interface FileUploadResponse { diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 2921a55..799b054 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -1,168 +1,315 @@