更新前后端代码
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# 开发环境配置(本地调试)
|
||||
VITE_API_BASE_URL=http://localhost:8100/api/v1
|
||||
VITE_APP_TITLE=云泽文件存储云平台 (开发)
|
||||
VITE_APP_ENV=development
|
||||
@@ -0,0 +1,4 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://file.yunzer.cn/api/v1
|
||||
VITE_APP_TITLE=云泽文件存储云平台
|
||||
VITE_APP_ENV=production
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>backend</title>
|
||||
<title>云泽文件存储云平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+549
-1
@@ -8,7 +8,11 @@
|
||||
"name": "backend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.40"
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.19.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
@@ -65,6 +69,49 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@element-plus/icons-vue": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
|
||||
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -81,6 +128,17 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
|
||||
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
|
||||
@@ -343,6 +401,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
|
||||
"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -353,6 +426,12 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
|
||||
@@ -449,6 +528,12 @@
|
||||
"@vue/shared": "3.5.41"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.11.tgz",
|
||||
@@ -532,6 +617,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
|
||||
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.4.0",
|
||||
"@vueuse/shared": "14.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
|
||||
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
|
||||
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
|
||||
@@ -539,12 +674,93 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.19.0.tgz",
|
||||
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -555,6 +771,46 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.14.5",
|
||||
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz",
|
||||
"integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@vueuse/core": "14.4.0",
|
||||
"async-validator": "^4.2.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash-unified": "^1.0.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-wheel-es": "^1.2.0",
|
||||
"vue-component-type-helpers": "^3.3.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||
@@ -567,6 +823,51 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
@@ -591,6 +892,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -606,6 +943,116 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -867,6 +1314,29 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
|
||||
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/lodash-es": "*",
|
||||
"lodash": "*",
|
||||
"lodash-es": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -876,6 +1346,48 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -901,6 +1413,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel-es": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
||||
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -955,6 +1473,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.5.tgz",
|
||||
@@ -1142,6 +1669,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-component-type-helpers": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
|
||||
"integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz",
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.40"
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.19.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelloWorld />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import http from './request'
|
||||
import type { ApiResp, PageData } from './request'
|
||||
|
||||
// ---------- 类型定义 ----------
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
is_system: boolean
|
||||
status: number
|
||||
permissions?: Permission[]
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
module: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role_id: number
|
||||
role?: Role
|
||||
status: number
|
||||
storage_used: number
|
||||
storage_limit: number
|
||||
last_login_at?: string
|
||||
last_login_ip?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
description: string
|
||||
storage_used: number
|
||||
storage_limit: number
|
||||
status: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
id: number
|
||||
project_id: number
|
||||
user_id: number
|
||||
filename: string
|
||||
size: number
|
||||
mime_type: string
|
||||
md5: string
|
||||
visibility: number
|
||||
download_count: number
|
||||
status: number
|
||||
created_at?: string
|
||||
project?: Project
|
||||
}
|
||||
|
||||
export interface TempLink {
|
||||
token: string
|
||||
url: string
|
||||
expires_at: string
|
||||
has_password: boolean
|
||||
max_count: number
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: number
|
||||
access_key: string
|
||||
name: string
|
||||
status: number
|
||||
last_used_at?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: number
|
||||
project_id?: number
|
||||
url: string
|
||||
events: string
|
||||
status: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface OperationLog {
|
||||
id: number
|
||||
user_id?: number
|
||||
username: string
|
||||
action: string
|
||||
resource_type: string
|
||||
resource_id: number
|
||||
resource_name: string
|
||||
ip: string
|
||||
request_path: string
|
||||
request_method: string
|
||||
status: number
|
||||
error_message: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface SystemLog {
|
||||
id: number
|
||||
level: string
|
||||
module: string
|
||||
message: string
|
||||
request_id: string
|
||||
ip: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface SystemSetting {
|
||||
id: number
|
||||
key: string
|
||||
value: string
|
||||
description: string
|
||||
group_name: string
|
||||
}
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
export const authApi = {
|
||||
register(data: { username: string; email: string; password: string }) {
|
||||
return http.post<any, ApiResp>('/auth/register', data)
|
||||
},
|
||||
login(data: { account: string; password: string }) {
|
||||
return http.post<any, ApiResp<{ token: string; user: User }>>('/auth/login', data)
|
||||
},
|
||||
createAPIKey(name: string) {
|
||||
return http.post<any, ApiResp<APIKey & { secret_key: string }>>('/auth/apikeys', { name })
|
||||
},
|
||||
listAPIKeys() {
|
||||
return http.get<any, ApiResp<APIKey[]>>('/auth/apikeys')
|
||||
},
|
||||
deleteAPIKey(id: number) {
|
||||
return http.delete<any, ApiResp>(`/auth/apikeys/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- 用户资料 ----------
|
||||
|
||||
export const userApi = {
|
||||
getProfile() {
|
||||
return http.get<any, ApiResp<User>>('/user/profile')
|
||||
},
|
||||
updateProfile(email: string) {
|
||||
return http.put<any, ApiResp>('/user/profile', { email })
|
||||
},
|
||||
changePassword(oldPassword: string, newPassword: string) {
|
||||
return http.put<any, ApiResp>('/user/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- 项目 ----------
|
||||
|
||||
export const projectApi = {
|
||||
create(data: { name: string; description?: string; storage_limit?: number }) {
|
||||
return http.post<any, ApiResp<Project>>('/projects', data)
|
||||
},
|
||||
list(params: { page?: number; page_size?: number; keyword?: string }) {
|
||||
return http.get<any, ApiResp<PageData<Project>>>('/projects', { params })
|
||||
},
|
||||
get(id: number) {
|
||||
return http.get<any, ApiResp<Project>>(`/projects/${id}`)
|
||||
},
|
||||
update(id: number, data: { name?: string; description?: string; storage_limit?: number }) {
|
||||
return http.put<any, ApiResp<Project>>(`/projects/${id}`, data)
|
||||
},
|
||||
remove(id: number) {
|
||||
return http.delete<any, ApiResp>(`/projects/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- 文件 ----------
|
||||
|
||||
export interface FileListParams {
|
||||
project_id?: number
|
||||
filename?: string
|
||||
prefix?: string
|
||||
trash?: boolean
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const fileApi = {
|
||||
upload(formData: FormData) {
|
||||
return http.post<any, ApiResp<FileItem>>('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 300000,
|
||||
})
|
||||
},
|
||||
list(params: FileListParams) {
|
||||
return http.get<any, ApiResp<PageData<FileItem>>>('/files', { params })
|
||||
},
|
||||
info(id: number) {
|
||||
return http.get<any, ApiResp<FileItem>>(`/files/${id}`)
|
||||
},
|
||||
// 下载为blob(需认证头)
|
||||
download(id: number) {
|
||||
return http.get(`/files/${id}/download`, { responseType: 'blob', timeout: 300000 })
|
||||
},
|
||||
remove(id: number, permanent = false) {
|
||||
return http.delete<any, ApiResp>(`/files/${id}`, { params: permanent ? { permanent: 'true' } : {} })
|
||||
},
|
||||
restore(id: number) {
|
||||
return http.post<any, ApiResp>(`/files/${id}/restore`)
|
||||
},
|
||||
createTempLink(id: number, data: { password?: string; max_count?: number; expires_in?: number }) {
|
||||
return http.post<any, ApiResp<TempLink>>(`/files/${id}/temp-link`, data)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- 统计 ----------
|
||||
|
||||
export const statsApi = {
|
||||
user() {
|
||||
return http.get<any, ApiResp>('/stats/user')
|
||||
},
|
||||
project(id: number) {
|
||||
return http.get<any, ApiResp>(`/stats/project/${id}`)
|
||||
},
|
||||
traffic(days = 30) {
|
||||
return http.get<any, ApiResp>('/stats/traffic', { params: { days } })
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- Webhook ----------
|
||||
|
||||
export const webhookApi = {
|
||||
create(data: { url: string; events: string; project_id?: number; secret?: string }) {
|
||||
return http.post<any, ApiResp<Webhook>>('/webhooks', data)
|
||||
},
|
||||
list() {
|
||||
return http.get<any, ApiResp<Webhook[]>>('/webhooks')
|
||||
},
|
||||
update(id: number, data: { url?: string; events?: string; status?: number }) {
|
||||
return http.put<any, ApiResp<Webhook>>(`/webhooks/${id}`, data)
|
||||
},
|
||||
remove(id: number) {
|
||||
return http.delete<any, ApiResp>(`/webhooks/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- 管理后台 ----------
|
||||
|
||||
export const adminApi = {
|
||||
// 用户管理
|
||||
listUsers(params: { page?: number; page_size?: number; keyword?: string; role_id?: number; status?: string }) {
|
||||
return http.get<any, ApiResp<PageData<User>>>('/admin/users', { params })
|
||||
},
|
||||
createUser(data: { username: string; email: string; password: string; role_id?: number; storage_limit?: number }) {
|
||||
return http.post<any, ApiResp<User>>('/admin/users', data)
|
||||
},
|
||||
updateUser(id: number, data: { email?: string; role_id?: number; storage_limit?: number }) {
|
||||
return http.put<any, ApiResp<User>>(`/admin/users/${id}`, data)
|
||||
},
|
||||
deleteUser(id: number) {
|
||||
return http.delete<any, ApiResp>(`/admin/users/${id}`)
|
||||
},
|
||||
setUserStatus(id: number, status: number) {
|
||||
return http.put<any, ApiResp>(`/admin/users/${id}/status`, { status })
|
||||
},
|
||||
assignRole(id: number, roleId: number) {
|
||||
return http.put<any, ApiResp>(`/admin/users/${id}/role`, { role_id: roleId })
|
||||
},
|
||||
resetPassword(id: number, password: string) {
|
||||
return http.post<any, ApiResp>(`/admin/users/${id}/reset-password`, { password })
|
||||
},
|
||||
// 角色权限
|
||||
listRoles() {
|
||||
return http.get<any, ApiResp<Role[]>>('/admin/roles')
|
||||
},
|
||||
createRole(data: { name: string; code: string; description?: string; permissions?: number[] }) {
|
||||
return http.post<any, ApiResp<Role>>('/admin/roles', data)
|
||||
},
|
||||
updateRole(id: number, data: { name?: string; description?: string; permissions?: number[] }) {
|
||||
return http.put<any, ApiResp<Role>>(`/admin/roles/${id}`, data)
|
||||
},
|
||||
deleteRole(id: number) {
|
||||
return http.delete<any, ApiResp>(`/admin/roles/${id}`)
|
||||
},
|
||||
setRolePermissions(id: number, permissions: number[]) {
|
||||
return http.put<any, ApiResp>(`/admin/roles/${id}/permissions`, { permissions })
|
||||
},
|
||||
listPermissions() {
|
||||
return http.get<any, ApiResp<{ module: string; permissions: Permission[] }[]>>('/admin/permissions')
|
||||
},
|
||||
// 系统设置
|
||||
getSettings() {
|
||||
return http.get<any, ApiResp<Record<string, SystemSetting[]>>>('/admin/settings')
|
||||
},
|
||||
updateSettings(values: Record<string, string>) {
|
||||
return http.put<any, ApiResp>('/admin/settings', values)
|
||||
},
|
||||
// 日志
|
||||
listOperationLogs(params: { page?: number; page_size?: number; username?: string; action?: string; days?: number }) {
|
||||
return http.get<any, ApiResp<PageData<OperationLog>>>('/admin/logs/operation', { params })
|
||||
},
|
||||
listSystemLogs(params: { page?: number; page_size?: number; level?: string; module?: string; days?: number }) {
|
||||
return http.get<any, ApiResp<PageData<SystemLog>>>('/admin/logs/system', { params })
|
||||
},
|
||||
cleanupLogs(days: number) {
|
||||
return http.delete<any, ApiResp>('/admin/logs/cleanup', { params: { days } })
|
||||
},
|
||||
// 备份
|
||||
backup() {
|
||||
return http.post<any, ApiResp<{ name: string }>>('/admin/backup')
|
||||
},
|
||||
listBackups() {
|
||||
return http.get<any, ApiResp<{ name: string; size: number; time: string }[]>>('/admin/backups')
|
||||
},
|
||||
downloadBackup(name: string) {
|
||||
return http.get('/admin/backups/download', { params: { name }, responseType: 'blob', timeout: 300000 })
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- 站点状态(公开) ----------
|
||||
|
||||
export const siteApi = {
|
||||
status() {
|
||||
return http.get<any, ApiResp>('/status')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { clearSession } from '../store/user'
|
||||
|
||||
// 后端统一响应结构
|
||||
export interface ApiResp<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface PageData<T = any> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 请求拦截:注入JWT
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截:统一错误提示,401跳转登录
|
||||
http.interceptors.response.use(
|
||||
(resp): any => {
|
||||
// 文件下载等二进制响应直接返回
|
||||
if (resp.config.responseType === 'blob') {
|
||||
return resp
|
||||
}
|
||||
const body = resp.data as ApiResp
|
||||
if (body.code !== 0) {
|
||||
ElMessage.error(body.message || '请求失败')
|
||||
return Promise.reject(new Error(body.message))
|
||||
}
|
||||
return body
|
||||
},
|
||||
(error: AxiosError<ApiResp>) => {
|
||||
const msg = error.response?.data?.message || error.message || '网络错误'
|
||||
if (error.response?.status === 401) {
|
||||
clearSession()
|
||||
if (location.hash !== '#/login') {
|
||||
location.hash = '#/login'
|
||||
}
|
||||
ElMessage.error(msg || '登录已过期,请重新登录')
|
||||
} else {
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default http
|
||||
+15
-1
@@ -1,5 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册全部Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { isLoggedIn, isAdmin } from '../store/user'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: () => import('../views/Login.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/Layout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: 'projects', name: 'projects', component: () => import('../views/Projects.vue') },
|
||||
{ path: 'files', name: 'files', component: () => import('../views/Files.vue') },
|
||||
{ path: 'profile', name: 'profile', component: () => import('../views/Profile.vue') },
|
||||
{
|
||||
path: 'admin/users',
|
||||
name: 'admin-users',
|
||||
component: () => import('../views/admin/Users.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/roles',
|
||||
name: 'admin-roles',
|
||||
component: () => import('../views/admin/Roles.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/settings',
|
||||
name: 'admin-settings',
|
||||
component: () => import('../views/admin/Settings.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/logs',
|
||||
name: 'admin-logs',
|
||||
component: () => import('../views/admin/Logs.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/backups',
|
||||
name: 'admin-backups',
|
||||
component: () => import('../views/admin/Backups.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/docs',
|
||||
name: 'admin-docs',
|
||||
component: () => import('../views/admin/Docs.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 全局守卫:登录态与管理员权限
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth && !isLoggedIn.value) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.meta.requiresAdmin && !isAdmin.value) {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
if (to.path === '/login' && isLoggedIn.value) {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,34 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import type { User } from '../api'
|
||||
|
||||
// 轻量全局会话状态
|
||||
export const session = reactive<{ token: string; user: User | null }>({
|
||||
token: localStorage.getItem('token') || '',
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
})
|
||||
|
||||
export const isLoggedIn = computed(() => !!session.token)
|
||||
export const isAdmin = computed(() => {
|
||||
const code = session.user?.role?.code
|
||||
return code === 'super_admin' || code === 'admin'
|
||||
})
|
||||
export const isSuperAdmin = computed(() => session.user?.role?.code === 'super_admin')
|
||||
|
||||
export function setSession(token: string, user: User) {
|
||||
session.token = token
|
||||
session.user = user
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function updateUser(user: User) {
|
||||
session.user = user
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
session.token = ''
|
||||
session.user = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
+26
-288
@@ -1,296 +1,34 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
.mono {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 字节数转可读大小
|
||||
export function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes < 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
let n = bytes
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024
|
||||
i++
|
||||
}
|
||||
return `${n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
export function formatTime(s?: string): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
// 下载blob响应
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 文件名基础名(去除虚拟目录)
|
||||
export function baseName(filename: string): string {
|
||||
const idx = filename.lastIndexOf('/')
|
||||
return idx >= 0 ? filename.slice(idx + 1) : filename
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { statsApi } from '../api'
|
||||
import { formatSize } from '../utils/format'
|
||||
|
||||
const loading = ref(false)
|
||||
const userStats = ref<any>(null)
|
||||
const traffic = ref<any>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [us, tr] = await Promise.all([statsApi.user(), statsApi.traffic(30)])
|
||||
userStats.value = us.data
|
||||
traffic.value = tr.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="page-card stat-card">
|
||||
<div class="stat-icon" style="background: #ecf5ff; color: #409eff"><el-icon :size="26"><Box /></el-icon></div>
|
||||
<div>
|
||||
<div class="stat-value">{{ formatSize(userStats?.storage_used || 0) }}</div>
|
||||
<div class="stat-label">已用存储 / {{ formatSize(userStats?.storage_limit || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="page-card stat-card">
|
||||
<div class="stat-icon" style="background: #f0f9eb; color: #67c23a"><el-icon :size="26"><Files /></el-icon></div>
|
||||
<div>
|
||||
<div class="stat-value">{{ userStats?.file_count || 0 }}</div>
|
||||
<div class="stat-label">文件总数({{ formatSize(userStats?.total_size || 0) }})</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="page-card stat-card">
|
||||
<div class="stat-icon" style="background: #fdf6ec; color: #e6a23c"><el-icon :size="26"><FolderOpened /></el-icon></div>
|
||||
<div>
|
||||
<div class="stat-value">{{ userStats?.project_count || 0 }}</div>
|
||||
<div class="stat-label">项目数量</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="page-card stat-card">
|
||||
<div class="stat-icon" style="background: #fef0f0; color: #f56c6c"><el-icon :size="26"><TrendCharts /></el-icon></div>
|
||||
<div>
|
||||
<div class="stat-value">{{ formatSize(userStats?.traffic?.upload_30d || 0) }}</div>
|
||||
<div class="stat-label">30天上传 / {{ formatSize(userStats?.traffic?.download_30d || 0) }} 下载</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<div class="page-card">
|
||||
<h3 class="card-title">项目存储分布</h3>
|
||||
<el-table :data="userStats?.projects || []" empty-text="暂无项目" size="small">
|
||||
<el-table-column prop="name" label="项目" min-width="120" />
|
||||
<el-table-column label="文件数" width="90">
|
||||
<template #default="{ row }">{{ row.file_count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用 / 配额" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="row.storage_limit ? Math.min(100, Math.round(row.storage_used / row.storage_limit * 100)) : 0"
|
||||
:stroke-width="10"
|
||||
>
|
||||
<span class="mono">{{ formatSize(row.storage_used) }} / {{ formatSize(row.storage_limit) }}</span>
|
||||
</el-progress>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<div class="page-card">
|
||||
<h3 class="card-title">近30天流量明细</h3>
|
||||
<el-table :data="traffic?.daily || []" empty-text="暂无流量记录" size="small" max-height="320">
|
||||
<el-table-column label="日期" min-width="110">
|
||||
<template #default="{ row }">{{ row.date }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 1 ? 'success' : 'primary'" size="small">
|
||||
{{ row.type === 1 ? '上传' : '下载' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="次数" width="80">
|
||||
<template #default="{ row }">{{ row.count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流量" min-width="100">
|
||||
<template #default="{ row }">{{ formatSize(Number(row.size)) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="traffic-summary">
|
||||
汇总:上传 {{ formatSize(traffic?.upload || 0) }},下载 {{ formatSize(traffic?.download || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.traffic-summary {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fileApi, projectApi, type FileItem, type Project, type TempLink } from '../api'
|
||||
import { formatSize, formatTime, saveBlob, baseName } from '../utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const projects = ref<Project[]>([])
|
||||
const projectId = ref<number>(0)
|
||||
const tab = ref<'files' | 'trash'>('files')
|
||||
const currentPath = ref('')
|
||||
const loading = ref(false)
|
||||
const allFiles = ref<FileItem[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 面包屑段
|
||||
const pathSegments = computed(() => (currentPath.value ? currentPath.value.split('/') : []))
|
||||
|
||||
// 按当前目录分组:子目录 + 直属文件
|
||||
interface DirEntry { type: 'dir'; name: string }
|
||||
interface FileEntry { type: 'file'; file: FileItem }
|
||||
type Entry = DirEntry | FileEntry
|
||||
|
||||
const entries = computed<Entry[]>(() => {
|
||||
const dirs = new Map<string, DirEntry>()
|
||||
const files: FileEntry[] = []
|
||||
for (const f of allFiles.value) {
|
||||
let rel = f.filename
|
||||
if (currentPath.value) {
|
||||
if (!rel.startsWith(currentPath.value + '/')) continue
|
||||
rel = rel.slice(currentPath.value.length + 1)
|
||||
}
|
||||
const idx = rel.indexOf('/')
|
||||
if (idx > 0) {
|
||||
const dirName = rel.slice(0, idx)
|
||||
if (!dirs.has(dirName)) dirs.set(dirName, { type: 'dir', name: dirName })
|
||||
} else {
|
||||
files.push({ type: 'file', file: f })
|
||||
}
|
||||
}
|
||||
return [...dirs.values(), ...files]
|
||||
})
|
||||
|
||||
const pagedEntries = computed(() =>
|
||||
entries.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value),
|
||||
)
|
||||
|
||||
// ---------- 加载 ----------
|
||||
async function loadProjects() {
|
||||
const r = await projectApi.list({ page: 1, page_size: 100 })
|
||||
projects.value = r.data!.list || []
|
||||
const qid = Number(route.query.project_id)
|
||||
if (qid && projects.value.some((p) => p.id === qid)) {
|
||||
projectId.value = qid
|
||||
} else if (!projectId.value && projects.value.length) {
|
||||
projectId.value = projects.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
if (!projectId.value) {
|
||||
allFiles.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await fileApi.list({
|
||||
project_id: projectId.value,
|
||||
prefix: currentPath.value || undefined,
|
||||
trash: tab.value === 'trash',
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
})
|
||||
allFiles.value = r.data!.list || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, () => {
|
||||
currentPath.value = ''
|
||||
page.value = 1
|
||||
loadFiles()
|
||||
})
|
||||
watch(tab, () => {
|
||||
currentPath.value = ''
|
||||
page.value = 1
|
||||
loadFiles()
|
||||
})
|
||||
|
||||
function enterDir(name: string) {
|
||||
currentPath.value = currentPath.value ? `${currentPath.value}/${name}` : name
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function goRoot() {
|
||||
currentPath.value = ''
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function goSegment(index: number) {
|
||||
currentPath.value = pathSegments.value.slice(0, index + 1).join('/')
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
// ---------- 上传 ----------
|
||||
const uploadVisible = ref(false)
|
||||
const uploadVisibility = ref(1)
|
||||
const uploadList = ref<File[]>([])
|
||||
const uploading = ref(false)
|
||||
|
||||
function openUpload() {
|
||||
if (!projectId.value) {
|
||||
ElMessage.warning('请先创建项目')
|
||||
return
|
||||
}
|
||||
uploadList.value = []
|
||||
uploadVisibility.value = 1
|
||||
uploadVisible.value = true
|
||||
}
|
||||
|
||||
function onPickFiles(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
uploadList.value = [...(input.files || [])]
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
if (!uploadList.value.length) {
|
||||
ElMessage.warning('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
let ok = 0
|
||||
for (const f of uploadList.value) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', f)
|
||||
fd.append('project_id', String(projectId.value))
|
||||
if (currentPath.value) fd.append('path', currentPath.value)
|
||||
fd.append('visibility', String(uploadVisibility.value))
|
||||
try {
|
||||
await fileApi.upload(fd)
|
||||
ok++
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
}
|
||||
uploading.value = false
|
||||
uploadVisible.value = false
|
||||
if (ok) {
|
||||
ElMessage.success(`成功上传 ${ok} 个文件`)
|
||||
loadFiles()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 文件操作 ----------
|
||||
async function download(f: FileItem) {
|
||||
const resp: any = await fileApi.download(f.id)
|
||||
saveBlob(resp.data, baseName(f.filename))
|
||||
}
|
||||
|
||||
async function softDelete(f: FileItem) {
|
||||
await fileApi.remove(f.id)
|
||||
ElMessage.success('已移入回收站')
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
async function restore(f: FileItem) {
|
||||
await fileApi.restore(f.id)
|
||||
ElMessage.success('文件已恢复')
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
async function hardDelete(f: FileItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`彻底删除「${baseName(f.filename)}」?此操作不可恢复。`, '彻底删除', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await fileApi.remove(f.id, true)
|
||||
ElMessage.success('已彻底删除')
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// ---------- 临时链接 ----------
|
||||
const linkVisible = ref(false)
|
||||
const linkForm = ref({ password: '', max_count: 10, expires_in: 3600 })
|
||||
const linkTarget = ref<FileItem | null>(null)
|
||||
const linkResult = ref<TempLink | null>(null)
|
||||
const linkSubmitting = ref(false)
|
||||
|
||||
function openLinkDialog(f: FileItem) {
|
||||
linkTarget.value = f
|
||||
linkResult.value = null
|
||||
linkForm.value = { password: '', max_count: 10, expires_in: 3600 }
|
||||
linkVisible.value = true
|
||||
}
|
||||
|
||||
async function createLink() {
|
||||
if (!linkTarget.value) return
|
||||
linkSubmitting.value = true
|
||||
try {
|
||||
const r = await fileApi.createTempLink(linkTarget.value.id, {
|
||||
password: linkForm.value.password || undefined,
|
||||
max_count: linkForm.value.max_count || 0,
|
||||
expires_in: linkForm.value.expires_in,
|
||||
})
|
||||
linkResult.value = r.data!
|
||||
} finally {
|
||||
linkSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fullLinkUrl = computed(() => (linkResult.value ? location.origin + linkResult.value.url : ''))
|
||||
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(fullLinkUrl.value).then(
|
||||
() => ElMessage.success('链接已复制'),
|
||||
() => ElMessage.warning('复制失败,请手动复制'),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 详情 ----------
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref<FileItem | null>(null)
|
||||
|
||||
function openDetail(f: FileItem) {
|
||||
detail.value = f
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjects()
|
||||
loadFiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="page-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-select v-model="projectId" placeholder="选择项目" style="width: 220px">
|
||||
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
<el-radio-group v-model="tab" style="margin-left: 12px">
|
||||
<el-radio-button value="files">文件</el-radio-button>
|
||||
<el-radio-button value="trash">回收站</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-button v-if="tab === 'files'" type="primary" @click="openUpload">
|
||||
<el-icon><Upload /></el-icon>上传文件
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb v-if="tab === 'files'" separator="/" style="margin-bottom: 12px">
|
||||
<el-breadcrumb-item>
|
||||
<el-link :underline="false" type="primary" @click="goRoot">根目录</el-link>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-for="(seg, i) in pathSegments" :key="i">
|
||||
<el-link :underline="false" type="primary" @click="goSegment(i)">{{ seg }}</el-link>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<el-table :data="pagedEntries" v-loading="loading" empty-text="暂无内容" row-key="name">
|
||||
<el-table-column label="名称" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.type === 'dir'" class="dir-entry" @click="enterDir(row.name)">
|
||||
<el-icon color="#e6a23c"><Folder /></el-icon>{{ row.name }}
|
||||
</span>
|
||||
<span v-else class="file-entry" @click="openDetail(row.file)">
|
||||
<el-icon color="#409eff"><Document /></el-icon>{{ baseName(row.file.filename) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="110">
|
||||
<template #default="{ row }">{{ row.type === 'file' ? formatSize(row.file.size) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可见性" width="90">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.type === 'file'">
|
||||
<el-tag size="small" :type="row.file.visibility === 2 ? 'success' : 'info'">
|
||||
{{ row.file.visibility === 2 ? '公开' : '私有' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下载次数" width="90">
|
||||
<template #default="{ row }">{{ row.type === 'file' ? row.file.download_count : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传时间" width="160">
|
||||
<template #default="{ row }">{{ row.type === 'file' ? formatTime(row.file.created_at) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.type === 'file'">
|
||||
<template v-if="tab === 'files'">
|
||||
<el-button link type="primary" @click="download(row.file)">下载</el-button>
|
||||
<el-button link type="primary" @click="openLinkDialog(row.file)">临时链接</el-button>
|
||||
<el-button link type="danger" @click="softDelete(row.file)">删除</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" @click="restore(row.file)">恢复</el-button>
|
||||
<el-button link type="danger" @click="hardDelete(row.file)">彻底删除</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="entries.length > pageSize"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
layout="total, prev, pager, next"
|
||||
:total="entries.length"
|
||||
:page-size="pageSize"
|
||||
v-model:current-page="page"
|
||||
/>
|
||||
|
||||
<!-- 上传对话框 -->
|
||||
<el-dialog v-model="uploadVisible" title="上传文件" width="500px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="目标目录">
|
||||
<el-input :model-value="currentPath || '根目录'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择文件">
|
||||
<input type="file" multiple @change="onPickFiles" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="uploadList.length" label="已选择">
|
||||
<div class="picked-list">
|
||||
<div v-for="(f, i) in uploadList" :key="i" class="mono">
|
||||
{{ f.name }}({{ formatSize(f.size) }})
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="可见性">
|
||||
<el-radio-group v-model="uploadVisibility">
|
||||
<el-radio :value="1">私有</el-radio>
|
||||
<el-radio :value="2">公开</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="doUpload">开始上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 临时链接对话框 -->
|
||||
<el-dialog v-model="linkVisible" title="生成临时访问链接" width="520px">
|
||||
<template v-if="!linkResult">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="访问密码">
|
||||
<el-input v-model="linkForm.password" placeholder="留空则无需密码" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大访问次数">
|
||||
<el-input-number v-model="linkForm.max_count" :min="0" :max="10000" />
|
||||
<span style="margin-left: 8px; color: #909399">0 为不限次数</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(秒)">
|
||||
<el-input-number v-model="linkForm.expires_in" :min="60" :max="604800" :step="3600" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert type="success" :closable="false" title="链接已生成" style="margin-bottom: 14px" />
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="访问链接">
|
||||
<el-input :model-value="fullLinkUrl" readonly class="mono" />
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间">
|
||||
<span>{{ formatTime(linkResult.expires_at) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="linkResult.has_password" label="访问密码">
|
||||
<span class="mono">{{ linkForm.password }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="!linkResult">
|
||||
<el-button @click="linkVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="linkSubmitting" @click="createLink">生成</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="copyLink">复制链接</el-button>
|
||||
<el-button type="primary" @click="linkVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 文件详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="文件详情" size="420px">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="文件名">{{ detail.filename }}</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ formatSize(detail.size) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MIME类型">{{ detail.mime_type || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MD5">
|
||||
<span class="mono">{{ detail.md5 }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="可见性">{{ detail.visibility === 2 ? '公开' : '私有' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下载次数">{{ detail.download_count }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上传时间">{{ formatTime(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.dir-entry,
|
||||
.file-entry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dir-entry:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
.picked-list {
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { session, isAdmin, clearSession } from '../store/user'
|
||||
import { formatSize } from '../utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
const u = session.user
|
||||
if (!u || !u.storage_limit) return 0
|
||||
return Math.min(100, Math.round((u.storage_used / u.storage_limit) * 100))
|
||||
})
|
||||
|
||||
function logout() {
|
||||
clearSession()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="220px" class="aside">
|
||||
<div class="logo">
|
||||
<el-icon :size="24"><Box /></el-icon>
|
||||
<span>云泽文件存储云平台</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" router class="menu" background-color="#1f2d3d" text-color="#bfcbd9" active-text-color="#409eff">
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>概览</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/projects">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>项目管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/files">
|
||||
<el-icon><Files /></el-icon>
|
||||
<span>文件管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/profile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人中心</span>
|
||||
</el-menu-item>
|
||||
|
||||
<template v-if="isAdmin">
|
||||
<el-divider style="border-color: #3a4a5d; margin: 10px 16px" />
|
||||
<div class="menu-group-title">系统管理</div>
|
||||
<el-menu-item index="/admin/users">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/roles">
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>角色权限</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/logs">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>日志管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/backups">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>数据备份</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/docs">
|
||||
<el-icon><Notebook /></el-icon>
|
||||
<span>使用说明和开发说明</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<span class="env-tag">{{ session.user?.role?.name || '用户' }}</span>
|
||||
</div>
|
||||
<el-dropdown @command="(cmd: string) => cmd === 'logout' ? logout() : router.push('/profile')">
|
||||
<span class="user-info">
|
||||
<el-avatar :size="30">{{ session.user?.username?.charAt(0)?.toUpperCase() }}</el-avatar>
|
||||
<span class="username">{{ session.user?.username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main">
|
||||
<div class="usage-bar" v-if="session.user">
|
||||
<span class="usage-label">存储空间</span>
|
||||
<el-progress
|
||||
:percentage="usagePercent"
|
||||
:stroke-width="8"
|
||||
style="flex: 1; margin: 0 12px"
|
||||
:color="usagePercent > 90 ? '#f56c6c' : '#409eff'"
|
||||
/>
|
||||
<span class="usage-text">
|
||||
{{ formatSize(session.user.storage_used) }} / {{ formatSize(session.user.storage_limit) }}
|
||||
</span>
|
||||
</div>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100%;
|
||||
}
|
||||
.aside {
|
||||
background-color: #1f2d3d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
}
|
||||
.menu-group-title {
|
||||
color: #6b7d92;
|
||||
font-size: 12px;
|
||||
padding: 4px 24px 6px;
|
||||
}
|
||||
.header {
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
.env-tag {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: #303133;
|
||||
}
|
||||
.username {
|
||||
font-size: 14px;
|
||||
}
|
||||
.main {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.usage-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.usage-label {
|
||||
color: #606266;
|
||||
}
|
||||
.usage-text {
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { authApi, siteApi } from '../api'
|
||||
import { setSession } from '../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const loading = ref(false)
|
||||
const siteName = ref('云泽文件存储云平台')
|
||||
const registerEnabled = ref(true)
|
||||
|
||||
siteApi.status().then((r) => {
|
||||
if (r.data?.site_name) siteName.value = r.data.site_name
|
||||
registerEnabled.value = r.data?.register_enabled !== false
|
||||
}).catch(() => {})
|
||||
|
||||
const loginForm = reactive({ account: '', password: '' })
|
||||
const regForm = reactive({ username: '', email: '', password: '', confirm: '' })
|
||||
|
||||
async function doLogin() {
|
||||
if (!loginForm.account || !loginForm.password) {
|
||||
ElMessage.warning('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await authApi.login({ account: loginForm.account, password: loginForm.password })
|
||||
setSession(r.data!.token, r.data!.user)
|
||||
ElMessage.success(`欢迎,${r.data!.user.username}`)
|
||||
router.push((route.query.redirect as string) || '/dashboard')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
if (!regForm.username || !regForm.email || !regForm.password) {
|
||||
ElMessage.warning('请填写完整注册信息')
|
||||
return
|
||||
}
|
||||
if (regForm.password !== regForm.confirm) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await authApi.register({
|
||||
username: regForm.username,
|
||||
email: regForm.email,
|
||||
password: regForm.password,
|
||||
})
|
||||
ElMessage.success('注册成功,请登录')
|
||||
mode.value = 'login'
|
||||
loginForm.account = regForm.username
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<el-icon :size="40" color="#409eff"><Box /></el-icon>
|
||||
<h1>{{ siteName }}</h1>
|
||||
<p>安全、高效的私有云文件存储服务</p>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="mode" class="login-tabs">
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form @submit.prevent="doLogin">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="loginForm.account"
|
||||
placeholder="用户名或邮箱"
|
||||
size="large"
|
||||
:prefix-icon="'User'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
show-password
|
||||
:prefix-icon="'Lock'"
|
||||
@keyup.enter="doLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" style="width: 100%" @click="doLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="registerEnabled" label="注册" name="register">
|
||||
<el-form @submit.prevent="doRegister">
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.username" placeholder="用户名(3-50位字母数字)" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.email" placeholder="邮箱" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="regForm.password"
|
||||
type="password"
|
||||
placeholder="密码(至少8位,含字母和数字)"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="regForm.confirm"
|
||||
type="password"
|
||||
placeholder="确认密码"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="doRegister"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" style="width: 100%" @click="doRegister">
|
||||
注 册
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f3b73 0%, #2d5aa0 50%, #409eff 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px 36px 28px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.login-header h1 {
|
||||
margin: 10px 0 4px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.login-header p {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi, authApi, webhookApi, type User, type APIKey, type Webhook, type Project } from '../api'
|
||||
import { projectApi } from '../api'
|
||||
import { formatSize, formatTime } from '../utils/format'
|
||||
import { session, updateUser, clearSession } from '../store/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const tab = ref('info')
|
||||
|
||||
// ---------- 基本资料 ----------
|
||||
const profile = ref<User | null>(null)
|
||||
const email = ref('')
|
||||
const savingProfile = ref(false)
|
||||
|
||||
async function loadProfile() {
|
||||
const r = await userApi.getProfile()
|
||||
profile.value = r.data!
|
||||
email.value = r.data!.email
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!email.value.trim()) {
|
||||
ElMessage.warning('请输入邮箱')
|
||||
return
|
||||
}
|
||||
savingProfile.value = true
|
||||
try {
|
||||
await userApi.updateProfile(email.value.trim())
|
||||
ElMessage.success('资料已更新')
|
||||
await loadProfile()
|
||||
if (session.user && profile.value) {
|
||||
updateUser({ ...session.user, email: profile.value.email })
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 修改密码 ----------
|
||||
const pwdForm = reactive({ old: '', new: '', confirm: '' })
|
||||
const savingPwd = ref(false)
|
||||
|
||||
async function changePassword() {
|
||||
if (!pwdForm.old || !pwdForm.new) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
if (pwdForm.new !== pwdForm.confirm) {
|
||||
ElMessage.warning('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
savingPwd.value = true
|
||||
try {
|
||||
await userApi.changePassword(pwdForm.old, pwdForm.new)
|
||||
ElMessage.success('密码已修改,请重新登录')
|
||||
pwdForm.old = pwdForm.new = pwdForm.confirm = ''
|
||||
clearSession()
|
||||
router.push('/login')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
savingPwd.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API Key ----------
|
||||
const keys = ref<APIKey[]>([])
|
||||
const loadingKeys = ref(false)
|
||||
const newKeyDialog = ref(false)
|
||||
const newKeyName = ref('')
|
||||
const createdKey = ref<{ access_key: string; secret_key: string } | null>(null)
|
||||
const creatingKey = ref(false)
|
||||
|
||||
async function loadKeys() {
|
||||
loadingKeys.value = true
|
||||
try {
|
||||
const r = await authApi.listAPIKeys()
|
||||
keys.value = r.data || []
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
if (!newKeyName.value.trim()) {
|
||||
ElMessage.warning('请输入密钥名称')
|
||||
return
|
||||
}
|
||||
creatingKey.value = true
|
||||
try {
|
||||
const r = await authApi.createAPIKey(newKeyName.value.trim())
|
||||
createdKey.value = { access_key: r.data!.access_key, secret_key: r.data!.secret_key }
|
||||
newKeyName.value = ''
|
||||
loadKeys()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
creatingKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeKey(k: APIKey) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除密钥「${k.name}」?使用该密钥的客户端将无法访问。`, '删除 API Key', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await authApi.deleteAPIKey(k.id)
|
||||
ElMessage.success('密钥已删除')
|
||||
loadKeys()
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => ElMessage.success('已复制到剪贴板'),
|
||||
() => ElMessage.warning('复制失败,请手动复制'),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Webhook ----------
|
||||
const hooks = ref<Webhook[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const loadingHooks = ref(false)
|
||||
const hookDialog = ref(false)
|
||||
const hookForm = reactive({ url: '', events: [] as string[], project_id: undefined as number | undefined })
|
||||
const savingHook = ref(false)
|
||||
const eventOptions = ['file.upload', 'file.delete', 'file.restore', 'project.create', 'project.delete']
|
||||
|
||||
async function loadHooks() {
|
||||
loadingHooks.value = true
|
||||
try {
|
||||
const r = await webhookApi.list()
|
||||
hooks.value = r.data || []
|
||||
} finally {
|
||||
loadingHooks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openHookDialog() {
|
||||
hookForm.url = ''
|
||||
hookForm.events = ['file.upload', 'file.delete']
|
||||
hookForm.project_id = undefined
|
||||
hookDialog.value = true
|
||||
}
|
||||
|
||||
async function saveHook() {
|
||||
if (!hookForm.url.startsWith('http')) {
|
||||
ElMessage.warning('请输入合法的 http(s) 回调地址')
|
||||
return
|
||||
}
|
||||
if (hookForm.events.length === 0) {
|
||||
ElMessage.warning('请至少选择一个事件')
|
||||
return
|
||||
}
|
||||
savingHook.value = true
|
||||
try {
|
||||
await webhookApi.create({
|
||||
url: hookForm.url,
|
||||
events: hookForm.events.join(','),
|
||||
project_id: hookForm.project_id,
|
||||
})
|
||||
ElMessage.success('Webhook 已创建')
|
||||
hookDialog.value = false
|
||||
loadHooks()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
savingHook.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHook(h: Webhook) {
|
||||
await webhookApi.update(h.id, { status: h.status === 1 ? 0 : 1 })
|
||||
ElMessage.success(h.status === 1 ? '已停用' : '已启用')
|
||||
loadHooks()
|
||||
}
|
||||
|
||||
async function removeHook(h: Webhook) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除该 Webhook?`, '删除 Webhook', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await webhookApi.remove(h.id)
|
||||
ElMessage.success('已删除')
|
||||
loadHooks()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProfile()
|
||||
loadKeys()
|
||||
loadHooks()
|
||||
projectApi.list({ page: 1, page_size: 100 }).then((r) => (projects.value = r.data!.list || []))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<el-tabs v-model="tab">
|
||||
<!-- 基本资料 -->
|
||||
<el-tab-pane label="基本资料" name="info">
|
||||
<el-descriptions :column="1" border style="max-width: 560px; margin-bottom: 20px">
|
||||
<el-descriptions-item label="用户名">{{ profile?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
<el-tag size="small">{{ profile?.role?.name || '-' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="存储用量">
|
||||
{{ formatSize(profile?.storage_used || 0) }} / {{ formatSize(profile?.storage_limit || 0) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">
|
||||
{{ formatTime(profile?.last_login_at) }}({{ profile?.last_login_ip || '-' }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatTime(profile?.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-form label-width="80px" style="max-width: 480px">
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="savingProfile" @click="saveProfile">保存修改</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<el-tab-pane label="修改密码" name="password">
|
||||
<el-form label-width="100px" style="max-width: 480px">
|
||||
<el-form-item label="当前密码" required>
|
||||
<el-input v-model="pwdForm.old" type="password" show-password placeholder="请输入当前密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="pwdForm.new" type="password" show-password placeholder="8位以上,含大小写字母和数字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" required>
|
||||
<el-input v-model="pwdForm.confirm" type="password" show-password placeholder="再次输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="savingPwd" @click="changePassword">确认修改</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- API Key -->
|
||||
<el-tab-pane label="API Key" name="apikeys">
|
||||
<div class="page-toolbar">
|
||||
<span class="tab-tip">用于客户端通过 HMAC 签名方式调用 API,替代 JWT 令牌</span>
|
||||
<el-button type="primary" @click="newKeyDialog = true">
|
||||
<el-icon><Plus /></el-icon>新建密钥
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="keys" v-loading="loadingKeys" empty-text="暂无 API Key">
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column label="Access Key" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ row.access_key }}</span>
|
||||
<el-button link type="primary" size="small" style="margin-left: 6px" @click="copyText(row.access_key)">复制</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近使用" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.last_used_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeKey(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Webhook -->
|
||||
<el-tab-pane label="Webhook" name="webhook">
|
||||
<div class="page-toolbar">
|
||||
<span class="tab-tip">文件/项目事件发生时,系统将 POST 通知回调地址</span>
|
||||
<el-button type="primary" @click="openHookDialog">
|
||||
<el-icon><Plus /></el-icon>新建 Webhook
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="hooks" v-loading="loadingHooks" empty-text="暂无 Webhook">
|
||||
<el-table-column prop="url" label="回调地址" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }"><span class="mono">{{ row.url }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订阅事件" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="e in row.events.split(',')" :key="e" size="small" style="margin-right: 4px">{{ e }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="toggleHook(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button>
|
||||
<el-button link type="danger" @click="removeHook(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 新建 API Key -->
|
||||
<el-dialog v-model="newKeyDialog" title="新建 API Key" width="480px" @closed="createdKey = null">
|
||||
<template v-if="!createdKey">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="密钥名称" required>
|
||||
<el-input v-model="newKeyName" placeholder="例如:ci-deploy" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
|
||||
Secret Key 仅本次显示,请立即保存!
|
||||
</el-alert>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="Access Key">
|
||||
<el-input :model-value="createdKey.access_key" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyText(createdKey!.access_key)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="Secret Key">
|
||||
<el-input :model-value="createdKey.secret_key" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyText(createdKey!.secret_key)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="newKeyDialog = false">{{ createdKey ? '已保存,关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="!createdKey" type="primary" :loading="creatingKey" @click="createKey">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建 Webhook -->
|
||||
<el-dialog v-model="hookDialog" title="新建 Webhook" width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="回调地址" required>
|
||||
<el-input v-model="hookForm.url" placeholder="https://example.com/callback" />
|
||||
</el-form-item>
|
||||
<el-form-item label="订阅事件" required>
|
||||
<el-checkbox-group v-model="hookForm.events">
|
||||
<el-checkbox v-for="e in eventOptions" :key="e" :value="e">{{ e }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="限定项目">
|
||||
<el-select v-model="hookForm.project_id" placeholder="全部项目(可选)" clearable style="width: 100%">
|
||||
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="hookDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingHook" @click="saveHook">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { projectApi, type Project } from '../api'
|
||||
import { formatSize, formatTime } from '../utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const list = ref<Project[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const keyword = ref('')
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<Project | null>(null)
|
||||
const form = reactive({ name: '', description: '', storage_limit: 0 })
|
||||
const submitting = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await projectApi.list({ page: page.value, page_size: pageSize.value, keyword: keyword.value || undefined })
|
||||
list.value = r.data!.list || []
|
||||
total.value = r.data!.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.storage_limit = 0
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(p: Project) {
|
||||
editing.value = p
|
||||
form.name = p.name
|
||||
form.description = p.description
|
||||
form.storage_limit = p.storage_limit / (1024 * 1024) // 转为MB编辑
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning('请输入项目名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const limitBytes = form.storage_limit > 0 ? Math.round(form.storage_limit * 1024 * 1024) : 0
|
||||
try {
|
||||
if (editing.value) {
|
||||
await projectApi.update(editing.value.id, {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
storage_limit: limitBytes,
|
||||
})
|
||||
ElMessage.success('项目已更新')
|
||||
} else {
|
||||
await projectApi.create({
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
storage_limit: limitBytes || undefined,
|
||||
})
|
||||
ElMessage.success('项目已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p: Project) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除项目「${p.name}」?项目下所有文件将被一并删除且不可恢复。`,
|
||||
'删除项目',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await projectApi.remove(p.id)
|
||||
ElMessage.success('项目已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
function goFiles(p: Project) {
|
||||
router.push({ path: '/files', query: { project_id: p.id } })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="page-toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索项目名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="page = 1; load()"
|
||||
@clear="page = 1; load()"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon>新建项目
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" empty-text="暂无项目,点击右上角新建">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="项目名称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="goFiles(row)">{{ row.name }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="存储用量" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="row.storage_limit ? Math.min(100, Math.round(row.storage_used / row.storage_limit * 100)) : 0"
|
||||
:stroke-width="10"
|
||||
>
|
||||
<span class="mono">{{ formatSize(row.storage_used) }} / {{ formatSize(row.storage_limit) }}</span>
|
||||
</el-progress>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="goFiles(row)">文件</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
v-model:current-page="page"
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑项目' : '新建项目'" width="460px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="项目名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入项目名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储配额">
|
||||
<el-input-number v-model="form.storage_limit" :min="0" :step="512" style="width: 180px" />
|
||||
<span style="margin-left: 8px; color: #909399">MB(0 表示使用系统默认)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
import { formatSize, formatTime, saveBlob } from '../../utils/format'
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const list = ref<{ name: string; size: number; time: string }[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await adminApi.listBackups()
|
||||
list.value = r.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
creating.value = true
|
||||
try {
|
||||
const r = await adminApi.backup()
|
||||
ElMessage.success(`备份完成:${r.data!.name}`)
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function download(b: { name: string }) {
|
||||
try {
|
||||
const resp = await adminApi.downloadBackup(b.name)
|
||||
saveBlob(resp.data, b.name)
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="page-toolbar">
|
||||
<span class="page-tip">备份文件保存在服务器 storage/backups 目录,建议定期备份</span>
|
||||
<div style="display: flex; gap: 10px">
|
||||
<el-button @click="load">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="create">
|
||||
<el-icon><FolderAdd /></el-icon>立即备份
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" empty-text="暂无备份记录">
|
||||
<el-table-column type="index" label="#" width="70" />
|
||||
<el-table-column label="备份文件" min-width="280">
|
||||
<template #default="{ row }"><span class="mono">{{ row.name }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="140">
|
||||
<template #default="{ row }">{{ formatSize(row.size) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备份时间" width="200">
|
||||
<template #default="{ row }">{{ formatTime(row.time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="download(row)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const tab = ref('usage')
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => ElMessage.success('已复制到剪贴板'),
|
||||
() => ElMessage.warning('复制失败,请手动复制'),
|
||||
)
|
||||
}
|
||||
|
||||
// 常见HTTP状态码说明
|
||||
const httpCodes = [
|
||||
{ code: '200', scene: '成功(业务错误通过 code 字段区分)' },
|
||||
{ code: '401', scene: '未登录 / Token过期 / 签名错误' },
|
||||
{ code: '403', scene: '无权限(角色权限不足)' },
|
||||
{ code: '404', scene: '资源不存在' },
|
||||
{ code: '429', scene: '触发限流(系统设置中的 API 请求频率限制)' },
|
||||
{ code: '503', scene: '站点维护中' },
|
||||
]
|
||||
|
||||
// 签名请求头说明
|
||||
const signHeaders = [
|
||||
{ header: 'X-Access-Key', desc: '创建 API Key 时返回的 access_key' },
|
||||
{ header: 'X-Timestamp', desc: '当前秒级 Unix 时间戳(±5分钟有效)' },
|
||||
{ header: 'X-Nonce', desc: '随机字符串(16位以上,防重放,同一 nonce 仅可用一次)' },
|
||||
{ header: 'X-Signature', desc: 'HMAC-SHA256 签名(算法见下)' },
|
||||
]
|
||||
|
||||
// 常用对接接口清单
|
||||
const apiTable = [
|
||||
{ method: 'POST', path: '/auth/login', desc: '登录,返回 JWT token 与用户信息', auth: '公开' },
|
||||
{ method: 'POST', path: '/files/upload', desc: '上传文件(multipart:file/project_id/path/visibility)', auth: 'JWT或签名' },
|
||||
{ method: 'GET', path: '/files', desc: '文件列表(支持 project_id/prefix/trash 过滤)', auth: 'JWT或签名' },
|
||||
{ method: 'GET', path: '/files/:id/download', desc: '下载文件(二进制流)', auth: 'JWT或签名' },
|
||||
{ method: 'POST', path: '/files/:id/temp-link', desc: '生成临时分享链接', auth: 'JWT或签名' },
|
||||
{ method: 'GET', path: '/files/temp/:token', desc: '临时链接公开访问(可带 password 参数)', auth: '公开' },
|
||||
{ method: 'GET', path: '/stats/user', desc: '用户存储/文件统计', auth: 'JWT或签名' },
|
||||
{ method: 'GET', path: '/stats/traffic', desc: '近N天上传统计(?days=30)', auth: 'JWT或签名' },
|
||||
{ method: 'POST', path: '/admin/backup', desc: '触发数据库备份', auth: '管理员 system:settings:edit' },
|
||||
{ method: 'GET', path: '/admin/backups', desc: '备份列表(name/size/time)', auth: '管理员 system:settings:view' },
|
||||
{ method: 'GET', path: '/admin/backups/download', desc: '下载备份文件(?name=备份文件名)', auth: '管理员 system:settings:view' },
|
||||
{ method: 'GET', path: '/status', desc: '站点状态(站点名/注册开关等)', auth: '公开' },
|
||||
]
|
||||
|
||||
const curlExample = `# 1. 登录获取JWT
|
||||
curl -X POST https://file.yunzer.cn/api/v1/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"account":"admin","password":"admin123"}'
|
||||
|
||||
# 2. 携带JWT调用接口
|
||||
curl https://file.yunzer.cn/api/v1/admin/backups \\
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 3. 下载备份文件
|
||||
curl -OJ "https://file.yunzer.cn/api/v1/admin/backups/download?name=backup-20260823-000240.db" \\
|
||||
-H "Authorization: Bearer <token>"`
|
||||
|
||||
const pythonExample = `import time, uuid, hmac, hashlib, requests
|
||||
|
||||
BASE = "https://file.yunzer.cn/api/v1" # 生产环境域名;本地开发调试可改为 http://localhost:8100/api/v1
|
||||
ACCESS_KEY = "你的access_key"
|
||||
SECRET_KEY = "你的secret_key" # 创建密钥时仅显示一次
|
||||
|
||||
def signed_get(path):
|
||||
ts = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex[:16]
|
||||
# 签名串: accessKey\\ntimestamp\\nnonce\\nMETHOD\\npath
|
||||
msg = "\\n".join([ACCESS_KEY, ts, nonce, "GET", path])
|
||||
sig = hmac.new(SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()
|
||||
headers = {
|
||||
"X-Access-Key": ACCESS_KEY,
|
||||
"X-Timestamp": ts,
|
||||
"X-Nonce": nonce,
|
||||
"X-Signature": sig,
|
||||
}
|
||||
return requests.get(BASE + path, headers=headers)
|
||||
|
||||
# 列出备份
|
||||
print(signed_get("/admin/backups").json())
|
||||
# 下载备份(二进制流)
|
||||
r = signed_get("/admin/backups/download?name=backup-xxx.db")
|
||||
open("backup.db", "wb").write(r.content)`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<el-tabs v-model="tab">
|
||||
<!-- ==================== 使用说明 ==================== -->
|
||||
<el-tab-pane label="使用说明" name="usage">
|
||||
<div class="doc-body">
|
||||
<section>
|
||||
<h2>1. 账号与角色</h2>
|
||||
<ul>
|
||||
<li>系统内置三种角色:<b>超级管理员(super_admin)</b>拥有全部权限;<b>管理员(admin)</b>可访问管理后台;<b>普通用户(user)</b>仅能使用个人功能。</li>
|
||||
<li>初始超级管理员账号见 <code>go/config.yaml</code> 的 <code>admin</code> 配置段,首次登录后请立即在「个人中心 → 修改密码」中修改。</li>
|
||||
<li>密码要求:至少 8 位,且包含大写字母、小写字母和数字。</li>
|
||||
<li>连续 5 次登录失败将锁定 15 分钟(防暴力破解)。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. 项目与文件</h2>
|
||||
<ul>
|
||||
<li>文件必须归属于某个项目,项目之间数据完全隔离。先在「项目管理」创建项目,再到「文件管理」上传。</li>
|
||||
<li>上传时可指定<b>虚拟目录</b>(如 <code>docs/2026</code>)与<b>可见性</b>;相同内容(MD5 一致)的文件自动去重,只占用一份物理存储。</li>
|
||||
<li><b>临时链接</b>:为文件生成对外分享链接,可设置提取密码、最大访问次数、有效期,无需登录即可访问。</li>
|
||||
<li><b>回收站</b>:普通删除进入回收站并释放配额,可随时恢复;「彻底删除」不可恢复,且仅在无其他文件引用时才清理物理文件。</li>
|
||||
<li>删除项目会级联删除项目下所有文件,请谨慎操作。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. 存储配额</h2>
|
||||
<ul>
|
||||
<li>用户与项目各有存储配额,超出配额将无法上传。默认值在「系统设置 → 存储与上传限制」中配置(可用页面顶部的字节计算器换算)。</li>
|
||||
<li>管理员可在「用户管理 / 项目管理」中为单个用户或项目单独调整配额。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. API Key 与 Webhook</h2>
|
||||
<ul>
|
||||
<li><b>API Key</b>:在「个人中心 → API Key」创建,用于程序化调用接口(HMAC 签名认证,替代 JWT)。<b>Secret Key 仅在创建时显示一次</b>,请立即保存。</li>
|
||||
<li><b>Webhook</b>:在「个人中心 → Webhook」订阅文件/项目事件,事件发生时系统会向回调地址发送 POST 通知。详见「开发说明」。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. 管理后台</h2>
|
||||
<ul>
|
||||
<li><b>用户管理</b>:创建/编辑/删除用户,启用或禁用账号(禁用后无法登录),重置密码,分配角色。</li>
|
||||
<li><b>角色权限</b>:自定义角色并按模块勾选权限;系统内置角色不可删除。</li>
|
||||
<li><b>系统设置</b>:站点信息、开放注册开关、站点维护模式(关闭后仅管理员可访问)、上传限制、限流等。</li>
|
||||
<li><b>日志管理</b>:操作日志记录用户行为,系统日志记录运行状态;支持按条件筛选与按天数清理。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. 数据备份</h2>
|
||||
<ul>
|
||||
<li>点击「立即备份」使用 SQLite <code>VACUUM INTO</code> 生成一致性快照,文件保存在服务器 <code>go/storage/backups/</code> 目录,命名格式 <code>backup-年月日-时分秒.db</code>。</li>
|
||||
<li>列表支持直接<b>下载</b>备份文件,建议下载后异地保存。</li>
|
||||
<li><b>恢复步骤</b>:① 停止服务 → ② 将备份文件覆盖 <code>go/storage/filestoragesystem.db</code>(重命名为该文件名)→ ③ 重新启动服务。</li>
|
||||
<li>建议:重要环境配置每日定时备份(可用 API 触发,见开发说明),并保留最近 7 份以上。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ==================== 开发说明 ==================== -->
|
||||
<el-tab-pane label="开发说明" name="dev">
|
||||
<div class="doc-body">
|
||||
<section>
|
||||
<h2>1. 基础约定</h2>
|
||||
<ul>
|
||||
<li>接口基地址:生产环境 <code>https://file.yunzer.cn/api/v1</code>;开发环境 <code>http://localhost:8100/api/v1</code></li>
|
||||
<li>统一响应结构:<code>{ "code": 0, "message": "ok", "data": ... }</code>,<code>code=0</code> 表示成功,非 0 为错误码。</li>
|
||||
<li>分页响应:<code>data = { "list": [], "total": 0, "page": 1, "page_size": 20 }</code></li>
|
||||
<li>请求与响应均为 UTF-8;JSON 接口请携带 <code>Content-Type: application/json</code>。</li>
|
||||
</ul>
|
||||
<el-table :data="httpCodes" size="small" style="max-width: 640px">
|
||||
<el-table-column prop="code" label="HTTP状态" width="110" />
|
||||
<el-table-column prop="scene" label="场景" />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. 认证方式</h2>
|
||||
<h3>方式一:JWT(适合网页/短时任务)</h3>
|
||||
<p>调用 <code>POST /auth/login</code> 获取 <code>token</code>,后续请求携带 <code>Authorization: Bearer <token></code>,默认 24 小时过期。</p>
|
||||
<h3>方式二:API Key 签名(推荐系统对接使用)</h3>
|
||||
<p>请求头携带 4 个字段:</p>
|
||||
<el-table :data="signHeaders" size="small" style="max-width: 760px">
|
||||
<el-table-column prop="header" label="请求头" width="150" />
|
||||
<el-table-column prop="desc" label="说明" />
|
||||
</el-table>
|
||||
<p>签名算法:</p>
|
||||
<pre class="code-block">签名串 = accessKey + "\n" + timestamp + "\n" + nonce + "\n" + HTTP方法(大写) + "\n" + 请求路径(不含域名和查询参数)
|
||||
X-Signature = HMAC-SHA256( secretKey, 签名串 ) 的十六进制小写字符串</pre>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin: 10px 0">
|
||||
注意事项:timestamp 为秒级时间戳,允许 ±5 分钟偏差;nonce 单次有效(防重放);路径示例:<code>/api/v1/files</code>(不含 <code>?参数</code>)。
|
||||
</el-alert>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. 对接示例</h2>
|
||||
<h3>curl(JWT 方式)</h3>
|
||||
<div class="code-wrap">
|
||||
<pre class="code-block">{{ curlExample }}</pre>
|
||||
<el-button size="small" class="copy-btn" @click="copyText(curlExample)">复制</el-button>
|
||||
</div>
|
||||
<h3>Python(API Key 签名方式)</h3>
|
||||
<div class="code-wrap">
|
||||
<pre class="code-block">{{ pythonExample }}</pre>
|
||||
<el-button size="small" class="copy-btn" @click="copyText(pythonExample)">复制</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. 常用对接接口</h2>
|
||||
<el-table :data="apiTable" size="small" border>
|
||||
<el-table-column prop="method" label="方法" width="80" />
|
||||
<el-table-column prop="path" label="路径" min-width="240">
|
||||
<template #default="{ row }"><span class="mono">{{ row.path }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="desc" label="说明" min-width="260" />
|
||||
<el-table-column prop="auth" label="认证/权限" width="200" />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Webhook 回调</h2>
|
||||
<p>订阅事件触发后,系统向回调地址发送 <code>POST</code> 请求(<code>Content-Type: application/json</code>,超时 10 秒):</p>
|
||||
<pre class="code-block">{
|
||||
"event": "file.upload",
|
||||
"timestamp": 1755900000,
|
||||
"data": { "file_id": 1, "filename": "a.png", "project_id": 1 }
|
||||
}</pre>
|
||||
<p>可订阅事件:<code>file.upload</code>、<code>file.delete</code>、<code>file.restore</code>、<code>project.create</code>、<code>project.delete</code>。回调处理应尽快返回 200,耗时逻辑请异步处理。</p>
|
||||
</section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-body {
|
||||
max-width: 960px;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
}
|
||||
.doc-body section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.doc-body h2 {
|
||||
font-size: 17px;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
.doc-body h3 {
|
||||
font-size: 14px;
|
||||
margin: 12px 0 6px;
|
||||
color: #303133;
|
||||
}
|
||||
.doc-body ul {
|
||||
padding-left: 22px;
|
||||
color: #606266;
|
||||
}
|
||||
.doc-body li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.doc-body code {
|
||||
background: #f4f4f5;
|
||||
color: #e6a23c;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.code-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.code-block {
|
||||
background: #1f2d3d;
|
||||
color: #dcdfe6;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi, type OperationLog, type SystemLog } from '../../api'
|
||||
import { formatTime } from '../../utils/format'
|
||||
|
||||
const tab = ref('operation')
|
||||
|
||||
// ---------- 操作日志 ----------
|
||||
const opLoading = ref(false)
|
||||
const opList = ref<OperationLog[]>([])
|
||||
const opTotal = ref(0)
|
||||
const opPage = ref(1)
|
||||
const opPageSize = ref(20)
|
||||
const opFilter = ref({ username: '', action: '', days: 0 })
|
||||
|
||||
async function loadOpLogs() {
|
||||
opLoading.value = true
|
||||
try {
|
||||
const r = await adminApi.listOperationLogs({
|
||||
page: opPage.value,
|
||||
page_size: opPageSize.value,
|
||||
username: opFilter.value.username || undefined,
|
||||
action: opFilter.value.action || undefined,
|
||||
days: opFilter.value.days || undefined,
|
||||
})
|
||||
opList.value = r.data!.list || []
|
||||
opTotal.value = r.data!.total
|
||||
} finally {
|
||||
opLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const actionOptions = ['login', 'register', 'create', 'update', 'delete', 'upload', 'download', 'restore']
|
||||
|
||||
// ---------- 系统日志 ----------
|
||||
const sysLoading = ref(false)
|
||||
const sysList = ref<SystemLog[]>([])
|
||||
const sysTotal = ref(0)
|
||||
const sysPage = ref(1)
|
||||
const sysPageSize = ref(20)
|
||||
const sysFilter = ref({ level: '', module: '', days: 0 })
|
||||
|
||||
async function loadSysLogs() {
|
||||
sysLoading.value = true
|
||||
try {
|
||||
const r = await adminApi.listSystemLogs({
|
||||
page: sysPage.value,
|
||||
page_size: sysPageSize.value,
|
||||
level: sysFilter.value.level || undefined,
|
||||
module: sysFilter.value.module || undefined,
|
||||
days: sysFilter.value.days || undefined,
|
||||
})
|
||||
sysList.value = r.data!.list || []
|
||||
sysTotal.value = r.data!.total
|
||||
} finally {
|
||||
sysLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const levelType = (level: string) =>
|
||||
level === 'error' ? 'danger' : level === 'warn' ? 'warning' : level === 'info' ? 'primary' : 'info'
|
||||
|
||||
// ---------- 清理 ----------
|
||||
const cleanDays = ref(30)
|
||||
|
||||
async function cleanup() {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定清理 ${cleanDays.value} 天前的全部日志?该操作不可恢复。`, '清理日志', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '清理',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await adminApi.cleanupLogs(cleanDays.value)
|
||||
ElMessage.success('日志已清理')
|
||||
loadOpLogs()
|
||||
loadSysLogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOpLogs()
|
||||
loadSysLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<el-tabs v-model="tab">
|
||||
<!-- 操作日志 -->
|
||||
<el-tab-pane label="操作日志" name="operation">
|
||||
<div class="page-toolbar">
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap">
|
||||
<el-input
|
||||
v-model="opFilter.username"
|
||||
placeholder="用户名"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="opPage = 1; loadOpLogs()"
|
||||
@clear="opPage = 1; loadOpLogs()"
|
||||
/>
|
||||
<el-select v-model="opFilter.action" placeholder="操作类型" clearable style="width: 140px" @change="opPage = 1; loadOpLogs()">
|
||||
<el-option v-for="a in actionOptions" :key="a" :label="a" :value="a" />
|
||||
</el-select>
|
||||
<el-select v-model="opFilter.days" placeholder="时间范围" clearable style="width: 140px" @change="opPage = 1; loadOpLogs()">
|
||||
<el-option label="最近1天" :value="1" />
|
||||
<el-option label="最近7天" :value="7" />
|
||||
<el-option label="最近30天" :value="30" />
|
||||
</el-select>
|
||||
<el-button @click="opPage = 1; loadOpLogs()">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="opList" v-loading="opLoading" empty-text="暂无日志">
|
||||
<el-table-column prop="username" label="用户" width="120" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.action === 'delete' ? 'danger' : row.action === 'create' ? 'success' : 'primary'">
|
||||
{{ row.action }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resource_type" label="资源类型" width="100" />
|
||||
<el-table-column prop="resource_name" label="资源名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="请求" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ row.request_method }} {{ row.request_path }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="opTotal > opPageSize"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
layout="total, prev, pager, next"
|
||||
:total="opTotal"
|
||||
:page-size="opPageSize"
|
||||
v-model:current-page="opPage"
|
||||
@current-change="loadOpLogs"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 系统日志 -->
|
||||
<el-tab-pane label="系统日志" name="system">
|
||||
<div class="page-toolbar">
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap">
|
||||
<el-select v-model="sysFilter.level" placeholder="级别" clearable style="width: 120px" @change="sysPage = 1; loadSysLogs()">
|
||||
<el-option label="info" value="info" />
|
||||
<el-option label="warn" value="warn" />
|
||||
<el-option label="error" value="error" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="sysFilter.module"
|
||||
placeholder="模块"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="sysPage = 1; loadSysLogs()"
|
||||
@clear="sysPage = 1; loadSysLogs()"
|
||||
/>
|
||||
<el-select v-model="sysFilter.days" placeholder="时间范围" clearable style="width: 140px" @change="sysPage = 1; loadSysLogs()">
|
||||
<el-option label="最近1天" :value="1" />
|
||||
<el-option label="最近7天" :value="7" />
|
||||
<el-option label="最近30天" :value="30" />
|
||||
</el-select>
|
||||
<el-button @click="sysPage = 1; loadSysLogs()">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
</el-button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<el-input-number v-model="cleanDays" :min="1" :max="365" style="width: 120px" />
|
||||
<span style="color: #909399; font-size: 13px">天前</span>
|
||||
<el-button type="danger" plain @click="cleanup">
|
||||
<el-icon><Delete /></el-icon>清理日志
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="sysList" v-loading="sysLoading" empty-text="暂无日志">
|
||||
<el-table-column label="级别" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="levelType(row.level)" size="small">{{ row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="module" label="模块" width="120" />
|
||||
<el-table-column prop="message" label="内容" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="sysTotal > sysPageSize"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
layout="total, prev, pager, next"
|
||||
:total="sysTotal"
|
||||
:page-size="sysPageSize"
|
||||
v-model:current-page="sysPage"
|
||||
@current-change="loadSysLogs"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi, type Role, type Permission } from '../../api'
|
||||
|
||||
const loading = ref(false)
|
||||
const roles = ref<Role[]>([])
|
||||
const groups = ref<{ module: string; permissions: Permission[] }[]>([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<Role | null>(null)
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ name: '', code: '', description: '' })
|
||||
const checked = ref<number[]>([])
|
||||
|
||||
const allPermIds = computed(() => groups.value.flatMap((g) => g.permissions.map((p) => p.id)))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await adminApi.listRoles()
|
||||
roles.value = r.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.name = ''
|
||||
form.code = ''
|
||||
form.description = ''
|
||||
checked.value = []
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(r: Role) {
|
||||
editing.value = r
|
||||
form.name = r.name
|
||||
form.code = r.code
|
||||
form.description = r.description
|
||||
checked.value = (r.permissions || []).map((p) => p.id)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim() || (!editing.value && !form.code.trim())) {
|
||||
ElMessage.warning('请填写角色名称和编码')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await adminApi.updateRole(editing.value.id, {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
permissions: checked.value,
|
||||
})
|
||||
ElMessage.success('角色已更新')
|
||||
} else {
|
||||
await adminApi.createRole({
|
||||
name: form.name,
|
||||
code: form.code.trim(),
|
||||
description: form.description,
|
||||
permissions: checked.value,
|
||||
})
|
||||
ElMessage.success('角色已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: Role) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除角色「${r.name}」?`, '删除角色', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await adminApi.deleteRole(r.id)
|
||||
ElMessage.success('角色已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
function toggleModule(g: { permissions: Permission[] }, on: boolean) {
|
||||
const ids = g.permissions.map((p) => p.id)
|
||||
checked.value = on
|
||||
? Array.from(new Set([...checked.value, ...ids]))
|
||||
: checked.value.filter((id) => !ids.includes(id))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
const r = await adminApi.listPermissions()
|
||||
groups.value = r.data || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="page-toolbar">
|
||||
<span class="page-tip">角色的权限决定用户可访问的后台功能,超级管理员拥有全部权限</span>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon>新建角色
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="roles" v-loading="loading" empty-text="暂无数据">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="角色名称" min-width="130" />
|
||||
<el-table-column label="角色编码" min-width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="权限数" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.code === 'super_admin' ? '全部' : (row.permissions?.length || 0) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_system ? 'warning' : 'success'" size="small">
|
||||
{{ row.is_system ? '系统' : '自定义' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">{{ row.is_system ? '配置权限' : '编辑' }}</el-button>
|
||||
<el-button v-if="!row.is_system" link type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新建/编辑角色 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? `编辑角色 - ${form.name}` : '新建角色'" width="640px" top="6vh">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="角色名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:运营人员" maxlength="50" :disabled="editing?.is_system" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editing" label="角色编码" required>
|
||||
<el-input v-model="form.code" placeholder="例如:operator(英文,唯一)" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限配置">
|
||||
<div class="perm-box">
|
||||
<el-alert
|
||||
v-if="editing?.code === 'super_admin'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="超级管理员自动拥有全部权限,无需配置"
|
||||
style="margin-bottom: 10px"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="perm-actions">
|
||||
<el-checkbox
|
||||
:model-value="checked.length === allPermIds.length && allPermIds.length > 0"
|
||||
:indeterminate="checked.length > 0 && checked.length < allPermIds.length"
|
||||
@change="(v: any) => (checked = v ? [...allPermIds] : [])"
|
||||
>
|
||||
全选
|
||||
</el-checkbox>
|
||||
<span class="perm-count">已选 {{ checked.length }} / {{ allPermIds.length }}</span>
|
||||
</div>
|
||||
<div v-for="g in groups" :key="g.module" class="perm-group">
|
||||
<div class="perm-group-header">
|
||||
<el-checkbox
|
||||
:model-value="g.permissions.every((p) => checked.includes(p.id))"
|
||||
:indeterminate="g.permissions.some((p) => checked.includes(p.id)) && !g.permissions.every((p) => checked.includes(p.id))"
|
||||
@change="(v: any) => toggleModule(g, !!v)"
|
||||
>
|
||||
<b>{{ g.module }}</b>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<el-checkbox-group v-model="checked" class="perm-items">
|
||||
<el-checkbox v-for="p in g.permissions" :key="p.id" :value="p.id" :title="p.description">
|
||||
{{ p.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.perm-box {
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
.perm-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #e4e7ed;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.perm-count {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.perm-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.perm-items {
|
||||
padding-left: 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminApi, type SystemSetting } from '../../api'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const groups = ref<{ name: string; settings: SystemSetting[] }[]>([])
|
||||
const activeGroups = ref<string[]>([])
|
||||
// key -> 编辑值
|
||||
const values = ref<Record<string, string>>({})
|
||||
|
||||
// 布尔开关类设置(值为 true/false 字符串)
|
||||
const boolKeys = ['site_enabled', 'register_enabled', 'webhook_enabled']
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
site: '站点信息',
|
||||
feature: '功能开关',
|
||||
storage: '存储与上传限制',
|
||||
api: 'API 与集成',
|
||||
}
|
||||
|
||||
// ---------- 字节计算器 ----------
|
||||
const calcAmount = ref(1)
|
||||
const calcUnit = ref('GB')
|
||||
const calcResult = ref<number | null>(null)
|
||||
const unitFactor: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
MB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
}
|
||||
|
||||
// 支持一键填入的字节类设置项
|
||||
const byteTargets = [
|
||||
{ key: 'default_storage_limit', label: '默认用户存储配额' },
|
||||
{ key: 'default_project_limit', label: '默认项目存储配额' },
|
||||
{ key: 'max_file_size', label: '单文件最大限制' },
|
||||
]
|
||||
const byteKeys = byteTargets.map((t) => t.key)
|
||||
|
||||
function calcBytes() {
|
||||
calcResult.value = Math.round((calcAmount.value || 0) * unitFactor[calcUnit.value])
|
||||
}
|
||||
|
||||
function applyTo(key: string) {
|
||||
if (calcResult.value === null) calcBytes()
|
||||
values.value[key] = String(calcResult.value)
|
||||
ElMessage.success('已填入,请点击“保存设置”生效')
|
||||
}
|
||||
|
||||
// 字节值转可读文本(输入框旁提示)
|
||||
function humanBytes(v?: string): string {
|
||||
const n = Number(v)
|
||||
if (!v || isNaN(n) || n <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
let x = n
|
||||
while (x >= 1024 && i < units.length - 1) {
|
||||
x /= 1024
|
||||
i++
|
||||
}
|
||||
const s = x >= 100 || i === 0 ? Math.round(x).toString() : x.toFixed(1)
|
||||
return `≈ ${s} ${units[i]}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await adminApi.getSettings()
|
||||
const data = r.data || {}
|
||||
groups.value = Object.entries(data).map(([name, settings]) => ({
|
||||
name,
|
||||
settings: settings as SystemSetting[],
|
||||
}))
|
||||
activeGroups.value = groups.value.map((g) => g.name)
|
||||
const v: Record<string, string> = {}
|
||||
for (const g of groups.value) {
|
||||
for (const s of g.settings) {
|
||||
v[s.key] = s.value
|
||||
}
|
||||
}
|
||||
values.value = v
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminApi.updateSettings({ ...values.value })
|
||||
ElMessage.success('设置已保存')
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card" v-loading="loading">
|
||||
<div class="page-toolbar">
|
||||
<span class="page-tip">修改系统全局配置,保存后立即生效</span>
|
||||
<el-button type="primary" :loading="saving" @click="save">
|
||||
<el-icon><Check /></el-icon>保存设置
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 字节计算器 -->
|
||||
<div class="calc-card">
|
||||
<div class="calc-title">
|
||||
<el-icon><Cpu /></el-icon>字节计算器
|
||||
<span class="calc-sub">填写容量大小,转换后可一键填入存储类设置项</span>
|
||||
</div>
|
||||
<div class="calc-row">
|
||||
<el-input-number v-model="calcAmount" :min="0" :step="1" style="width: 160px" />
|
||||
<el-select v-model="calcUnit" style="width: 90px" @change="calcResult = null">
|
||||
<el-option v-for="u in ['B', 'KB', 'MB', 'GB', 'TB']" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="calcBytes">
|
||||
<el-icon><Sort /></el-icon>转换为字节
|
||||
</el-button>
|
||||
<span v-if="calcResult !== null" class="calc-result mono">{{ calcResult }} 字节</span>
|
||||
</div>
|
||||
<div v-if="calcResult !== null" class="calc-apply">
|
||||
<span class="calc-apply-label">填入到:</span>
|
||||
<el-button v-for="t in byteTargets" :key="t.key" size="small" @click="applyTo(t.key)">
|
||||
{{ t.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="activeGroups" class="settings-collapse">
|
||||
<el-collapse-item
|
||||
v-for="g in groups"
|
||||
:key="g.name"
|
||||
:name="g.name"
|
||||
:title="groupLabels[g.name] || g.name"
|
||||
>
|
||||
<el-form label-width="200px" label-position="left" style="max-width: 760px">
|
||||
<el-form-item v-for="s in g.settings" :key="s.id" :label="s.description || s.key">
|
||||
<el-switch
|
||||
v-if="boolKeys.includes(s.key)"
|
||||
:model-value="values[s.key] === 'true'"
|
||||
@change="(v: any) => (values[s.key] = v ? 'true' : 'false')"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="values[s.key]"
|
||||
:placeholder="s.key"
|
||||
style="max-width: 420px"
|
||||
/>
|
||||
<span v-if="byteKeys.includes(s.key)" class="setting-hint">{{ humanBytes(values[s.key]) }}</span>
|
||||
<span class="setting-key mono">{{ s.key }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.setting-key {
|
||||
margin-left: 10px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.setting-hint {
|
||||
margin-left: 10px;
|
||||
color: #67c23a;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.calc-card {
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #d9ecff;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.calc-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.calc-sub {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.calc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.calc-result {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.calc-apply {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.calc-apply-label {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-collapse :deep(.el-collapse-item__header) {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi, type User, type Role } from '../../api'
|
||||
import { formatSize, formatTime } from '../../utils/format'
|
||||
import { session } from '../../store/user'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const keyword = ref('')
|
||||
const roleFilter = ref<number | undefined>()
|
||||
const statusFilter = ref<string>('')
|
||||
const roles = ref<Role[]>([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<User | null>(null)
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ username: '', email: '', password: '', role_id: 3, storage_limit: 0 })
|
||||
|
||||
const resetVisible = ref(false)
|
||||
const resetTarget = ref<User | null>(null)
|
||||
const resetPwd = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await adminApi.listUsers({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
keyword: keyword.value || undefined,
|
||||
role_id: roleFilter.value,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
list.value = r.data!.list || []
|
||||
total.value = r.data!.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.username = ''
|
||||
form.email = ''
|
||||
form.password = ''
|
||||
form.role_id = 3
|
||||
form.storage_limit = 0
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(u: User) {
|
||||
editing.value = u
|
||||
form.email = u.email
|
||||
form.role_id = u.role_id
|
||||
form.storage_limit = u.storage_limit ? u.storage_limit / (1024 * 1024) : 0
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!editing.value && (!form.username.trim() || !form.email.trim() || !form.password)) {
|
||||
ElMessage.warning('请完整填写用户名、邮箱和密码')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const limitBytes = form.storage_limit > 0 ? Math.round(form.storage_limit * 1024 * 1024) : 0
|
||||
try {
|
||||
if (editing.value) {
|
||||
await adminApi.updateUser(editing.value.id, {
|
||||
email: form.email,
|
||||
role_id: form.role_id,
|
||||
storage_limit: limitBytes,
|
||||
})
|
||||
ElMessage.success('用户已更新')
|
||||
} else {
|
||||
await adminApi.createUser({
|
||||
username: form.username.trim(),
|
||||
email: form.email.trim(),
|
||||
password: form.password,
|
||||
role_id: form.role_id,
|
||||
storage_limit: limitBytes || undefined,
|
||||
})
|
||||
ElMessage.success('用户已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(u: User) {
|
||||
const next = u.status === 1 ? 0 : 1
|
||||
await adminApi.setUserStatus(u.id, next)
|
||||
ElMessage.success(next === 1 ? '用户已启用' : '用户已禁用')
|
||||
load()
|
||||
}
|
||||
|
||||
function openReset(u: User) {
|
||||
resetTarget.value = u
|
||||
resetPwd.value = ''
|
||||
resetVisible.value = true
|
||||
}
|
||||
|
||||
async function doReset() {
|
||||
if (!resetPwd.value) {
|
||||
ElMessage.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
await adminApi.resetPassword(resetTarget.value!.id, resetPwd.value)
|
||||
ElMessage.success('密码已重置')
|
||||
resetVisible.value = false
|
||||
}
|
||||
|
||||
async function remove(u: User) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除用户「${u.username}」?其名下项目与文件将被一并删除。`, '删除用户', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await adminApi.deleteUser(u.id)
|
||||
ElMessage.success('用户已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
const r = await adminApi.listRoles()
|
||||
roles.value = r.data || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="page-toolbar">
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索用户名/邮箱"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="page = 1; load()"
|
||||
@clear="page = 1; load()"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="roleFilter" placeholder="角色" clearable style="width: 140px" @change="page = 1; load()">
|
||||
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
|
||||
</el-select>
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="page = 1; load()">
|
||||
<el-option label="正常" value="1" />
|
||||
<el-option label="禁用" value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon>新建用户
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" empty-text="暂无数据">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="角色" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role?.code === 'super_admin' ? 'danger' : row.role?.code === 'admin' ? 'warning' : 'info'" size="small">
|
||||
{{ row.role?.name || '-' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="存储用量" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ formatSize(row.storage_used) }} / {{ formatSize(row.storage_limit) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.last_login_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.id !== session.user?.id">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'warning' : 'success'" @click="toggleStatus(row)">
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="openReset(row)">重置密码</el-button>
|
||||
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
<span v-else style="color: #909399; font-size: 12px">当前账号</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
v-model:current-page="page"
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<!-- 新建/编辑用户 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新建用户'" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item v-if="!editing" label="用户名" required>
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="用户名">
|
||||
<el-input :model-value="editing.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" required>
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editing" label="密码" required>
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="8位以上,含大小写字母和数字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role_id" style="width: 100%">
|
||||
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="存储配额">
|
||||
<el-input-number v-model="form.storage_limit" :min="0" :step="512" style="width: 180px" />
|
||||
<span style="margin-left: 8px; color: #909399">MB(0 表示系统默认)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密码 -->
|
||||
<el-dialog v-model="resetVisible" title="重置密码" width="420px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="用户">
|
||||
<el-input :model-value="resetTarget?.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="resetPwd" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="resetVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="doReset">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
+18
-3
@@ -1,7 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 根据当前工作目录中的不同环境变量文件加载环境变量
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 开发环境代理到Go后端
|
||||
'/api': {
|
||||
target: env.VITE_API_BASE_URL || 'http://127.0.0.1:8100',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user