完成租户登录

This commit is contained in:
扫地僧 2025-10-29 23:07:53 +08:00
parent b9938b4c0d
commit ddf90424ba
43 changed files with 7761 additions and 474 deletions

539
pc/package-lock.json generated
View File

@ -9,11 +9,13 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@wangeditor/editor": "^5.1.23",
"axios": "^1.13.1",
"chart": "^0.1.2",
"chart.js": "^4.5.1",
"element-plus": "^2.11.5",
"less": "^4.4.2",
"marked": "^16.4.1",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
@ -59,6 +61,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz",
@ -938,6 +949,12 @@
"win32"
]
},
"node_modules/@transloadit/prettier-bytes": {
"version": "0.0.7",
"resolved": "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
"integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@ -945,6 +962,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.20.tgz",
@ -976,6 +999,61 @@
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
"license": "MIT"
},
"node_modules/@uppy/companion-client": {
"version": "2.2.2",
"resolved": "https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz",
"integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
"license": "MIT",
"dependencies": {
"@uppy/utils": "^4.1.2",
"namespace-emitter": "^2.0.1"
}
},
"node_modules/@uppy/core": {
"version": "2.3.4",
"resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz",
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
"license": "MIT",
"dependencies": {
"@transloadit/prettier-bytes": "0.0.7",
"@uppy/store-default": "^2.1.1",
"@uppy/utils": "^4.1.3",
"lodash.throttle": "^4.1.1",
"mime-match": "^1.0.2",
"namespace-emitter": "^2.0.1",
"nanoid": "^3.1.25",
"preact": "^10.5.13"
}
},
"node_modules/@uppy/store-default": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz",
"integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==",
"license": "MIT"
},
"node_modules/@uppy/utils": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz",
"integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
"license": "MIT",
"dependencies": {
"lodash.throttle": "^4.1.1"
}
},
"node_modules/@uppy/xhr-upload": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
"license": "MIT",
"dependencies": {
"@uppy/companion-client": "^2.2.2",
"@uppy/utils": "^4.1.2",
"nanoid": "^3.1.25"
},
"peerDependencies": {
"@uppy/core": "^2.3.3"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
@ -1223,6 +1301,155 @@
}
}
},
"node_modules/@wangeditor/basic-modules": {
"version": "1.1.7",
"resolved": "https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
"integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
"license": "MIT",
"dependencies": {
"is-url": "^1.2.4"
},
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/code-highlight": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
"integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
"license": "MIT",
"dependencies": {
"prismjs": "^1.23.0"
},
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/core": {
"version": "1.1.19",
"resolved": "https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz",
"integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
"license": "MIT",
"dependencies": {
"@types/event-emitter": "^0.3.3",
"event-emitter": "^0.3.5",
"html-void-elements": "^2.0.0",
"i18next": "^20.4.0",
"scroll-into-view-if-needed": "^2.2.28",
"slate-history": "^0.66.0"
},
"peerDependencies": {
"@uppy/core": "^2.1.1",
"@uppy/xhr-upload": "^2.0.3",
"dom7": "^3.0.0",
"is-hotkey": "^0.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.foreach": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lodash.toarray": "^4.4.0",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/editor": {
"version": "5.1.23",
"resolved": "https://registry.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz",
"integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
"license": "MIT",
"dependencies": {
"@uppy/core": "^2.1.1",
"@uppy/xhr-upload": "^2.0.3",
"@wangeditor/basic-modules": "^1.1.7",
"@wangeditor/code-highlight": "^1.0.3",
"@wangeditor/core": "^1.1.19",
"@wangeditor/list-module": "^1.0.5",
"@wangeditor/table-module": "^1.1.4",
"@wangeditor/upload-image-module": "^1.0.2",
"@wangeditor/video-module": "^1.1.4",
"dom7": "^3.0.0",
"is-hotkey": "^0.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.foreach": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lodash.toarray": "^4.4.0",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/list-module": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz",
"integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
"license": "MIT",
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/table-module": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz",
"integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
"license": "MIT",
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/upload-image-module": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
"integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
"license": "MIT",
"peerDependencies": {
"@uppy/core": "^2.0.3",
"@uppy/xhr-upload": "^2.0.3",
"@wangeditor/basic-modules": "1.x",
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"lodash.foreach": "^4.5.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/video-module": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz",
"integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
"license": "MIT",
"peerDependencies": {
"@uppy/core": "^2.1.4",
"@uppy/xhr-upload": "^2.0.7",
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
@ -1434,6 +1661,12 @@
"node": ">= 0.8"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
"license": "MIT"
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.2.tgz",
@ -1459,6 +1692,19 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -1577,6 +1823,15 @@
"node": ">=0.4.0"
}
},
"node_modules/dom7": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz",
"integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
"license": "MIT",
"dependencies": {
"ssr-window": "^3.0.0-alpha.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1771,6 +2026,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/esbuild": {
"version": "0.25.11",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.11.tgz",
@ -1826,6 +2121,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
@ -1836,6 +2146,16 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/exsolve": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz",
@ -1843,6 +2163,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"license": "ISC",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@ -2168,6 +2497,25 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/html-void-elements": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz",
"integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/i18next": {
"version": "20.6.1",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz",
"integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -2194,6 +2542,16 @@
"node": ">=0.10.0"
}
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz",
@ -2354,6 +2712,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
"license": "MIT"
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz",
@ -2394,6 +2758,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
@ -2487,6 +2860,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz",
@ -2616,6 +2995,49 @@
"lodash-es": "*"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.foreach": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"license": "MIT"
},
"node_modules/lodash.toarray": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@ -2639,6 +3061,18 @@
"node": ">=6"
}
},
"node_modules/marked": {
"version": "16.4.1",
"resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.1.tgz",
"integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -2676,6 +3110,15 @@
"node": ">= 0.6"
}
},
"node_modules/mime-match": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz",
"integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
"license": "ISC",
"dependencies": {
"wildcard": "^1.1.0"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
@ -2742,6 +3185,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/namespace-emitter": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@ -2777,6 +3226,12 @@
"node": ">= 4.4.x"
}
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
@ -2971,6 +3426,25 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.27.2",
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -3171,6 +3645,15 @@
"license": "ISC",
"optional": true
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"license": "MIT",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
@ -3306,6 +3789,38 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/slate": {
"version": "0.72.8",
"resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz",
"integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
"license": "MIT",
"dependencies": {
"immer": "^9.0.6",
"is-plain-object": "^5.0.0",
"tiny-warning": "^1.0.3"
}
},
"node_modules/slate-history": {
"version": "0.66.0",
"resolved": "https://registry.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz",
"integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
"license": "MIT",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"peerDependencies": {
"slate": ">=0.65.3"
}
},
"node_modules/snabbdom": {
"version": "3.6.3",
"resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.3.tgz",
"integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
"license": "MIT",
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
@ -3334,6 +3849,12 @@
"node": ">=0.10.0"
}
},
"node_modules/ssr-window": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz",
"integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@ -3455,6 +3976,12 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3495,6 +4022,12 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@ -3953,6 +4486,12 @@
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
"license": "MIT"
}
}
}

View File

@ -10,11 +10,13 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@wangeditor/editor": "^5.1.23",
"axios": "^1.13.1",
"chart": "^0.1.2",
"chart.js": "^4.5.1",
"element-plus": "^2.11.5",
"less": "^4.4.2",
"marked": "^16.4.1",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"

99
pc/src/api/file.js Normal file
View File

@ -0,0 +1,99 @@
import request from "@/utils/request";
/**
* 获取所有文件
* @returns {Promise}
*/
export function getAllFiles() {
return request({
url: "/api/files",
method: "get",
});
}
/**
* 获取我的文件
* @returns {Promise}
*/
export function getMyFiles() {
return request({
url: "/api/files/my",
method: "get",
});
}
/**
* 根据ID获取文件
* @param {number|string} id 文件ID
* @returns {Promise}
*/
export function getFileById(id) {
return request({
url: `/api/files/${id}`,
method: "get",
});
}
/**
* 上传文件
* @param {FormData} formData 文件数据
* @param {Object} options 额外选项
* @param {string} [options.category]
* @param {string} [options.tenantId]
* @returns {Promise}
*/
export function uploadFile(formData, options = {}) {
if (options.category) {
formData.append('category', options.category);
}
if (options.tenantId) {
formData.append('tenant_id', options.tenantId);
}
return request({
url: "/api/files",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data"
}
});
}
/**
* 更新文件信息
* @param {number|string} id 文件ID
* @param {Object} fileData 更新的数据
* @returns {Promise}
*/
export function updateFile(id, fileData) {
return request({
url: `/api/files/${id}`,
method: "put",
data: fileData,
});
}
/**
* 删除文件
* @param {number|string} id 文件ID
* @returns {Promise}
*/
export function deleteFile(id) {
return request({
url: `/api/files/${id}`,
method: "delete",
});
}
/**
* 搜索文件
* @param {string} keyword 关键字
* @returns {Promise}
*/
export function searchFiles(keyword) {
return request({
url: `/api/files/search`,
method: "get",
params: { keyword },
});
}

115
pc/src/api/knowledge.js Normal file
View File

@ -0,0 +1,115 @@
import request from "@/utils/request";
/**
* 获取知识列表
* @param {Object} params 查询参数
* @returns {Promise}
*/
export function getKnowledgeList(params) {
return request({
url: "/api/knowledge/list",
method: "get",
params,
});
}
/**
* 获取知识详情
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function getKnowledgeDetail(id) {
return request({
url: `/api/knowledge/detail`,
method: "get",
params: { id },
});
}
/**
* 创建知识
* @param {Object} data 知识数据
* @returns {Promise}
*/
export function createKnowledge(data) {
return request({
url: "/api/knowledge/create",
method: "post",
data,
});
}
/**
* 更新知识
* @param {number|string} id 知识ID
* @param {Object} data 更新的数据
* @returns {Promise}
*/
export function updateKnowledge(id, data) {
return request({
url: `/api/knowledge/update`,
method: "post",
data: { ...data, id },
});
}
/**
* 删除知识
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function deleteKnowledge(id) {
return request({
url: `/api/knowledge/delete`,
method: "post",
data: { id },
});
}
/**
* 获取分类列表
* @returns {Promise}
*/
export function getCategoryList() {
return request({
url: "/api/knowledge/categories",
method: "get",
});
}
/**
* 获取标签列表
* @returns {Promise}
*/
export function getTagList() {
return request({
url: "/api/knowledge/tags",
method: "get",
});
}
/**
* 添加分类
* @param {Object} data 分类数据
* @returns {Promise}
*/
export function addCategory(data) {
return request({
url: "/api/knowledge/category/add",
method: "post",
data,
});
}
/**
* 添加标签
* @param {Object} data 标签数据
* @returns {Promise}
*/
export function addTag(data) {
return request({
url: "/api/knowledge/tag/add",
method: "post",
data,
});
}

View File

@ -1,70 +1,113 @@
import request from "@/utils/request";
// 获取知识列表
/**
* 获取知识列表
* @param {Object} params 查询参数
* @returns {Promise}
*/
export function getKnowledgeList(params) {
return request({
url: `/api/knowledge/list`,
url: "/api/knowledge/list",
method: "get",
params: params,
params,
});
}
// 获取知识详情
/**
* 获取知识详情
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function getKnowledgeDetail(id) {
return request({
url: `/api/knowledge/detail/${id}`,
method: "get",
});
}
// 创建知识
/**
* 创建知识
* @param {Object} data 知识数据
* @returns {Promise}
*/
export function createKnowledge(data) {
return request({
url: `/api/knowledge/create`,
url: "/api/knowledge/create",
method: "post",
data: data,
data,
});
}
// 更新知识
/**
* 更新知识
* @param {number|string} id 知识ID
* @param {Object} data 更新的数据
* @returns {Promise}
*/
export function updateKnowledge(id, data) {
return request({
url: `/api/knowledge/update/${id}`,
method: "put",
data: data,
data,
});
}
// 删除知识
/**
* 删除知识
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function deleteKnowledge(id) {
return request({
url: `/api/knowledge/delete/${id}`,
method: "delete",
});
}
// 获取分类列表
/**
* 获取分类列表
* @returns {Promise}
*/
export function getCategoryList() {
return request({
url: `/api/knowledge/category/list`,
url: "/api/knowledge/category/list",
method: "get",
});
}
// 获取标签列表
/**
* 获取标签列表
* @returns {Promise}
*/
export function getTagList() {
return request({
url: `/api/knowledge/tag/list`,
url: "/api/knowledge/tag/list",
method: "get",
});
}
// 添加分类
/**
* 添加分类
* @param {Object} data 分类数据
* @returns {Promise}
*/
export function addCategory(data) {
return request({
url: `/api/knowledge/category/add`,
url: "/api/knowledge/category/add",
method: "post",
data: data,
data,
});
}
// 添加标签
/**
* 添加标签
* @param {Object} data 标签数据
* @returns {Promise}
*/
export function addTag(data) {
return request({
url: `/api/knowledge/tag/add`,
url: "/api/knowledge/tag/add",
method: "post",
data: data,
data,
});
}

View File

@ -1,13 +1,14 @@
import request from "@/utils/request";
// 登录
export function login(username, password) {
// 登录(使用租户名称)
export function login(username, password, tenantName) {
return request({
url: `/api/login`,
method: "post",
data: {
username: username,
password: password,
tenant_name: tenantName, // 后端期望的字段名是 tenant_name
},
});
}

View File

@ -8,3 +8,37 @@ export function getAllMenus() {
});
}
// 更新菜单状态
export function updateMenuStatus(menuId, status) {
return request({
url: `/api/menu/status/${menuId}`,
method: "patch",
data: { status },
});
}
// 创建菜单
export function createMenu(menuData) {
return request({
url: `/api/menu`,
method: "post",
data: menuData,
});
}
// 更新菜单
export function updateMenu(menuId, menuData) {
return request({
url: `/api/menu/${menuId}`,
method: "put",
data: menuData,
});
}
// 删除菜单
export function deleteMenu(menuId) {
return request({
url: `/api/menu/${menuId}`,
method: "delete",
});
}

55
pc/src/api/tenant.js Normal file
View File

@ -0,0 +1,55 @@
import request from "@/utils/request";
//获取所有租户
export function getAllTenants() {
return request({
url: "/api/tenant/list",
method: "get",
});
}
//新增租户
export function createTenant(tenantData) {
return request({
url: "/api/tenant",
method: "post",
data: tenantData,
});
}
// 编辑租户
export function updateTenant(tenantId, tenantData) {
return request({
url: `/api/tenant/${tenantId}`,
method: "put",
data: tenantData,
});
}
// 删除租户
export function deleteTenant(tenantId) {
return request({
url: `/api/tenant/${tenantId}`,
method: "delete",
});
}
// 审核租户
export function auditTenant(tenantId, auditData) {
return request({
url: `/api/tenant/${tenantId}/audit`,
method: "post",
data: auditData,
});
}
// 查看租户详情
export function getTenantDetail(tenantId) {
return request({
url: `/api/tenant/${tenantId}`,
method: "get",
});
}

View File

@ -54,6 +54,16 @@
--card-bg-color: #ffffff;
--card-border-color: #ebeef5;
// container-box 亮色主题
--container-box-bg: #fff;
--container-box-border: #ebeef5;
--container-box-shadow: 0 2px 8px 0 rgba(0,0,0,0.04);
// 滚动条颜色
--scrollbar-track-color: #f1f1f1;
--scrollbar-thumb-color: #c1c1c1;
--scrollbar-thumb-hover-color: #a8a8a8;
// Element Plus 组件主题适配
--el-bg-color: var(--bg-color);
--el-bg-color-page: var(--bg-color-page);
@ -125,6 +135,16 @@
// 卡片背景
--card-bg-color: #252526;
--card-border-color: #3c3c3d;
// container-box 暗色主题
--container-box-bg: #232325;
--container-box-border: #363637;
--container-box-shadow: 0 2px 8px 0 rgba(0,0,0,0.09);
// 滚动条颜色
--scrollbar-track-color: #2d2d2d;
--scrollbar-thumb-color: #555555;
--scrollbar-thumb-hover-color: #666666;
// Element Plus 组件主题适配
--el-bg-color: var(--bg-color);
@ -150,6 +170,12 @@ body {
transition: background-color 0.3s ease, color 0.3s ease;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
}
// 卡片样式
.el-card {
background-color: var(--card-bg-color);
@ -157,6 +183,19 @@ body {
color: var(--text-color-primary);
}
// container-box样式
.container-box {
background-color: var(--container-box-bg);
border: 1px solid var(--container-box-border);
box-shadow: var(--container-box-shadow);
border-radius: 8px;
padding: 24px;
transition:
background-color 0.3s,
border-color 0.3s,
box-shadow 0.3s;
}
// 输入框样式
.el-input__inner {
background-color: var(--fill-color-blank);
@ -168,24 +207,427 @@ body {
}
}
// 按钮样式过渡
// 按钮样式(全局主题适配)
.el-button {
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
// 默认按钮
&.el-button--default {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:hover:not(:disabled) {
background-color: var(--fill-color-light) !important;
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
&:disabled {
background-color: var(--fill-color) !important;
border-color: var(--border-color-lighter) !important;
color: var(--text-color-disabled) !important;
}
}
// 文本按钮
&.el-button--text {
color: var(--text-color-primary) !important;
&:hover:not(:disabled) {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
&:disabled {
color: var(--text-color-disabled) !important;
}
}
}
// 表格样式
// 表格样式(全局主题适配)
.el-table {
background-color: var(--bg-color);
color: var(--text-color-primary);
background-color: var(--bg-color) !important;
color: var(--text-color-primary) !important;
border-color: var(--border-color-lighter) !important;
th {
background-color: var(--fill-color-light);
color: var(--text-color-primary);
// 表头
th.el-table__cell {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
border-bottom-color: var(--border-color) !important;
}
td {
border-color: var(--border-color-lighter);
// 表格行
tr {
background-color: var(--bg-color) !important;
// 鼠标悬停
&:hover > td {
background-color: var(--fill-color-lighter) !important;
}
}
// 表格单元格
td.el-table__cell {
border-bottom-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
// 斑马纹表格
&.el-table--striped {
.el-table__body tr.el-table__row--striped td {
background-color: var(--fill-color-extra-light) !important;
}
.el-table__body tr.el-table__row--striped:hover td {
background-color: var(--fill-color-lighter) !important;
}
}
// 边框表格
&.el-table--border {
border-color: var(--border-color-lighter) !important;
&::after,
&::before {
background-color: var(--border-color-lighter) !important;
}
th.el-table__cell,
td.el-table__cell {
border-right-color: var(--border-color-lighter) !important;
}
}
// 空状态
.el-table__empty-block {
background-color: var(--bg-color) !important;
color: var(--text-color-secondary) !important;
}
// 空状态文字
.el-table__empty-text {
color: var(--text-color-secondary) !important;
}
}
// 标签样式(全局主题适配)
.el-tag {
background-color: var(--fill-color-light) !important;
border-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
// 对话框样式(全局主题适配)
.el-dialog {
background-color: var(--bg-color) !important;
border-color: var(--border-color) !important;
.el-dialog__header {
border-bottom-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
.el-dialog__title {
color: var(--text-color-primary) !important;
}
.el-dialog__body {
background-color: var(--bg-color) !important;
color: var(--text-color-primary) !important;
}
.el-dialog__footer {
border-top-color: var(--border-color-lighter) !important;
}
}
// MessageBox 确认框样式(全局主题适配)
.el-overlay.is-message-box {
background-color: rgba(0, 0, 0, 0.5) !important;
&[data-theme="dark"] {
background-color: rgba(0, 0, 0, 0.7) !important;
}
}
.el-message-box {
background-color: var(--bg-color) !important;
.el-message-box__header {
.el-message-box__title {
color: var(--text-color-primary) !important;
}
}
.el-message-box__content {
color: var(--text-color-primary) !important;
.el-message-box__message {
color: var(--text-color-primary) !important;
p {
color: var(--text-color-primary) !important;
}
}
}
}
// 描述列表样式(全局主题适配)
.el-descriptions {
.el-descriptions__header {
.el-descriptions__title {
color: var(--text-color-primary) !important;
}
}
.el-descriptions__table {
.el-descriptions__label {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
border-color: var(--border-color-lighter) !important;
}
.el-descriptions__content {
background-color: var(--bg-color) !important;
color: var(--text-color-primary) !important;
border-color: var(--border-color-lighter) !important;
}
td.el-descriptions__label,
td.el-descriptions__content {
border-color: var(--border-color-lighter) !important;
}
}
}
// 表单样式(全局主题适配)
.el-form {
.el-form-item__label {
color: var(--text-color-primary) !important;
}
.el-form-item__error {
color: var(--danger-color) !important;
}
}
// 选择器样式(全局主题适配)
.el-select {
// Element Plus 2.x 使用 el-select__wrapper
.el-select__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
border: 1px solid var(--border-color) !important;
box-shadow: none !important;
&:hover {
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
// 如果选择器内部使用了 el-input__wrapper兼容性处理
.el-input__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
border: 1px solid var(--border-color) !important;
box-shadow: none !important;
&:hover {
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
.el-input__inner {
background-color: transparent !important;
color: var(--text-color-primary) !important;
}
.el-input__suffix {
.el-input__suffix-inner {
.el-select__caret {
color: var(--text-color-secondary) !important;
}
}
}
}
// 选择器下拉菜单样式(全局主题适配)
.el-select-dropdown,
.el-select-dropdown__wrap {
background-color: var(--bg-color-overlay) !important;
border-color: var(--border-color) !important;
.el-select-dropdown__item {
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.selected {
color: var(--primary-color) !important;
font-weight: 700;
}
&.is-disabled {
color: var(--text-color-disabled) !important;
&:hover {
background-color: transparent !important;
}
}
}
.el-select-dropdown__empty {
color: var(--text-color-secondary) !important;
}
}
// Element Plus Popper 组件样式(全局主题适配,用于所有通过 Popper 渲染的下拉组件)
.el-popper,
.el-popper__arrow::before {
background-color: var(--bg-color-overlay) !important;
border-color: var(--border-color) !important;
}
.el-popper.is-dark {
background-color: var(--bg-color-overlay) !important;
color: var(--text-color-primary) !important;
}
// 输入框组件样式(全局主题适配,兼容 Element Plus 2.x
.el-input {
.el-input__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
border: 1px solid var(--border-color) !important;
box-shadow: none !important;
&:hover {
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
.el-input__inner {
background-color: transparent !important;
color: var(--text-color-primary) !important;
&::placeholder {
color: var(--text-color-placeholder) !important;
}
}
}
// 文本域样式(全局主题适配)
.el-textarea {
.el-textarea__inner {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&::placeholder {
color: var(--text-color-placeholder) !important;
}
&:focus {
border-color: var(--primary-color) !important;
}
}
}
// 单选按钮组样式(全局主题适配)
.el-radio-group {
.el-radio {
color: var(--text-color-primary) !important;
.el-radio__label {
color: var(--text-color-primary) !important;
}
&.is-checked {
.el-radio__label {
color: var(--primary-color) !important;
}
}
}
}
// 数字输入框样式(全局主题适配)
.el-input-number {
.el-input__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
.el-input__inner {
background-color: transparent !important;
color: var(--text-color-primary) !important;
}
}
.el-input-number__decrease,
.el-input-number__increase {
background-color: var(--fill-color-light) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:hover:not(.is-disabled) {
background-color: var(--fill-color) !important;
color: var(--primary-color) !important;
}
&.is-disabled {
background-color: var(--fill-color) !important;
color: var(--text-color-disabled) !important;
}
}
}
// 开关组件样式(全局主题适配)
.el-switch {
--el-switch-on-color: var(--primary-color);
--el-switch-off-color: var(--fill-color-dark);
.el-switch__core {
background-color: var(--fill-color-dark) !important;
border-color: var(--border-color) !important;
}
&.is-checked {
.el-switch__core {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
}
&.is-disabled {
.el-switch__core {
background-color: var(--fill-color) !important;
border-color: var(--border-color-lighter) !important;
opacity: 0.6;
}
}
}
// 警告提示样式(全局主题适配)
.el-alert {
background-color: var(--fill-color-light) !important;
border-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
// 菜单样式
@ -377,3 +819,295 @@ body {
}
}
// 滚动条样式(全局)
// WebKit 浏览器Chrome, Safari, Edge
* {
// Firefox 滚动条样式
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color);
// WebKit 滚动条样式
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--scrollbar-track-color);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-color);
border-radius: 4px;
transition: background-color 0.3s ease;
&:hover {
background: var(--scrollbar-thumb-hover-color);
}
}
&::-webkit-scrollbar-corner {
background: var(--scrollbar-track-color);
}
}
// 更细的滚动条样式(可选)
.thin-scrollbar {
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin: 14px 0 0 0;
}
/* 标准文章样式 支持黑白主题 */
.markdown-body {
color: var(--text-color, #222);
font-size: 16px;
line-height: 1.8;
word-break: break-word;
background: var(--content-bg);
transition: color 0.3s, background 0.3s;
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--title-color, var(--text-color, #222));
margin-top: 1.6em;
margin-bottom: 0.8em;
font-family: inherit;
font-weight: 700;
line-height: 1.3;
transition: color 0.3s;
}
h1 {
font-size: 2.2em;
border-bottom: 1px solid var(--border-color-light, #eaecef);
padding-bottom: 0.4em;
}
h2 {
font-size: 1.8em;
border-bottom: 1px solid var(--border-color-light, #eaecef);
padding-bottom: 0.3em;
}
h3 {
font-size: 1.4em;
}
h4 {
font-size: 1.2em;
}
h5 {
font-size: 1.06em;
}
h6 {
font-size: 1em;
color: var(--text-color-secondary, #969696);
}
p {
margin: 0.9em 0;
color: inherit;
}
ul,
ol {
padding-left: 2em;
margin: 0.9em 0;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 0.7em 0 0.7em 0.7em;
color: inherit;
}
a {
color: var(--primary-color, #2d8cf0);
text-decoration: underline;
transition: color 0.2s;
&:hover {
color: var(--primary-hover, #1a73e8);
}
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
border-radius: 4px;
background: var(--img-bg, #f7f7fa);
}
blockquote {
border-left: 4px solid var(--blockquote-border, #d0dde9);
background: var(--blockquote-bg, #f7f9fa);
padding: 0.6em 1.2em;
margin: 1.1em 0;
color: var(--blockquote-color, #6580a0);
font-style: italic;
transition: background 0.3s, border-color 0.3s, color 0.3s;
}
code {
font-family: var(
--code-font,
"Fira Mono",
"Menlo",
"Consolas",
"monospace"
);
background: var(--code-bg, #f4f4f4);
border-radius: 3px;
padding: 0.15em 0.4em;
color: var(--code-color, #c7254e);
font-size: 0.97em;
margin: 0 0.1em;
transition: background 0.3s, color 0.3s;
}
pre {
background: var(--pre-bg, #f6f8fa);
border-radius: 4px;
padding: 1em 1.2em;
font-family: var(
--code-font,
"Fira Mono",
"Menlo",
"Consolas",
"monospace"
);
font-size: 0.98em;
overflow-x: auto;
color: var(--pre-color, #212529);
margin: 1.2em 0;
transition: background 0.3s, color 0.3s;
code {
background: none;
color: inherit;
font-size: inherit;
padding: 0;
}
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.2em 0;
color: inherit;
font-size: 1em;
background: var(--table-bg, #fff);
transition: background 0.3s;
}
th,
td {
border: 1px solid var(--table-border, #dee2e6);
padding: 0.5em 1em;
color: inherit;
}
th {
background: var(--table-head-bg, #f4f8fb);
font-weight: 600;
}
tr:nth-child(even) {
background: var(--table-row-even-bg, #f9fbfc);
}
hr {
border: none;
border-top: 1px solid var(--border-color-light, #eaecef);
margin: 2em 0;
}
br {
display: block;
margin: 0.2em 0;
content: "";
}
// 兼容 Element 表单
.el-form-item__label,
.el-form-item__content,
.el-form-item__error,
.el-form-item__error-tip {
color: var(--text-color, #222);
}
}
/* 深色主题支持 */
.dark .markdown-body {
color: #fff;
background: #181b21;
h1,
h2,
h3,
h4,
h5,
h6 {
color: #fff;
}
h6 {
color: #ccc;
}
a {
color: #fff;
&:hover {
color: #e6e6e6;
}
}
img {
background: #23272b;
}
blockquote {
border-left-color: #5d7896;
background: #232a33;
color: #cce3fa;
}
code {
background: #31343b;
color: #fff;
}
pre {
background: #23272d;
color: #fff;
}
table {
background: #23272b;
}
th,
td {
border-color: #333b47;
color: #fff;
}
th {
background: #273041;
}
tr:nth-child(even) {
background: #20232a;
}
hr {
border-top-color: #333b47;
}
// Element兼容
.el-form-item__label,
.el-form-item__content,
.el-form-item__error,
.el-form-item__error-tip {
color: #fff;
}
}

View File

@ -91,6 +91,9 @@ const handleCommand = (command) => {
// user
localStorage.removeItem('user');
sessionStorage.removeItem('user');
//
localStorage.removeItem('tenant');
sessionStorage.removeItem('tenant');
router.push('/login');
}
};

View File

@ -8,9 +8,13 @@ import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createPinia } from 'pinia'
import { useAuthStore } from './stores/auth'
import { initTheme } from './utils/theme'
// 导入全局组件
import WangEditor from '@/views/components/WangEditor.vue';
const app = createApp(App)
const pinia = createPinia()
// 全局注册 WangEditor 组件
app.component('WangEditor', WangEditor);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
@ -29,7 +33,7 @@ authStore.checkAuth()
// 如果用户已登录,在应用启动时加载动态路由
if (authStore.isLoggedIn) {
loadAndAddDynamicRoutes().then(() => {
console.log('应用启动时已加载动态路由');
// console.log('应用启动时已加载动态路由');
}).catch(err => {
console.error('应用启动时加载动态路由失败:', err);
});

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from "vue-router";
import { convertMenusToRoutes } from "./dynamicRoutes";
// 静态路由登录页独立404 页面独立
// 仪表盘路由作为静态路由,永远放在第一个位置
const staticRoutes = [
{
path: "/login",
@ -13,8 +14,22 @@ const staticRoutes = [
path: "/",
name: "Main",
component: () => import("@/views/Main.vue"),
redirect: "/dashboard", // 默认重定向,动态路由加载后会更新
children: [], // 所有页面路由都会作为 children 添加到这里
redirect: "/dashboard", // 默认重定向到仪表盘
children: [
// 仪表盘路由:静态路由,永远放在第一个
{
path: "dashboard",
name: "Dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: {
requiresAuth: true,
title: "仪表盘",
icon: "fa-solid fa-gauge",
id: 1,
isStatic: true // 标记为静态路由
}
}
],
meta: { requiresAuth: true }
},
{
@ -76,15 +91,18 @@ function addDynamicRoutes(menus) {
return;
}
const dynamicRoutes = convertMenusToRoutes(menus);
// 过滤掉 ID 为 1 的仪表盘菜单,因为它是静态路由
const filteredMenus = menus.filter(menu => menu.id !== 1);
const dynamicRoutes = convertMenusToRoutes(filteredMenus);
// 打印路由树结构(只打印路径信息,不序列化组件)
console.log('生成的路由树:', dynamicRoutes.map(r => ({
path: r.path,
name: r.name,
hasComponent: !!r.component,
childrenCount: r.children?.length || 0
})));
// console.log('生成的路由树:', dynamicRoutes.map(r => ({
// path: r.path,
// name: r.name,
// hasComponent: !!r.component,
// childrenCount: r.children?.length || 0
// })));
// 获取主路由
const mainRoute = router.getRoutes().find(r => r.name === 'Main');
@ -93,45 +111,71 @@ function addDynamicRoutes(menus) {
return;
}
// 获取现有的静态路由(仪表盘路由)
const staticChildren = mainRoute.children || [];
const dashboardRoute = staticChildren.find(r => r.name === 'Dashboard');
// 移除旧的主路由
router.removeRoute('Main');
// 查找第一个有效路由作为默认重定向
const firstRoute = findFirstValidRoute(dynamicRoutes);
let redirectPath = "/dashboard"; // 默认值
if (firstRoute && firstRoute.meta?.menuPath) {
redirectPath = firstRoute.meta.menuPath;
}
// 重新添加主路由,包含所有动态子路由
// 重新添加主路由,仪表盘路由放在第一个,后面跟动态路由
router.addRoute({
...mainRoute,
redirect: redirectPath,
children: dynamicRoutes // 所有动态路由作为 Main 的 children
redirect: "/dashboard", // 始终重定向到仪表盘
children: [
// 仪表盘静态路由永远放在第一个
...(dashboardRoute ? [dashboardRoute] : []),
// 动态路由跟在后面
...dynamicRoutes
]
});
// 添加知识库的子路由(详情页和编辑页)
// 直接在 Main 路由下添加完整路径的子路由
router.addRoute('Main', {
path: 'apps/knowledge/detail/:id',
name: 'apps-knowledge-detail',
component: () => import('@/views/apps/knowledge/components/detail.vue'),
meta: {
requiresAuth: true,
title: '知识详情'
}
});
router.addRoute('Main', {
path: 'apps/knowledge/edit/:id',
name: 'apps-knowledge-edit',
component: () => import('@/views/apps/knowledge/components/edit.vue'),
meta: {
requiresAuth: true,
title: '编辑知识'
}
});
dynamicRoutesAdded = true;
// 打印路由信息用于调试
const finalMainRoute = router.getRoutes().find(r => r.name === 'Main');
console.log('动态路由已添加:', {
redirect: redirectPath,
childrenCount: dynamicRoutes.length,
childrenPaths: finalMainRoute?.children?.map(c => ({
path: c.path,
name: c.name,
metaPath: c.meta?.menuPath
}))
});
// console.log('动态路由已添加:', {
// redirect: "/dashboard",
// staticRoutesCount: dashboardRoute ? 1 : 0,
// dynamicRoutesCount: dynamicRoutes.length,
// totalChildrenCount: (dashboardRoute ? 1 : 0) + dynamicRoutes.length,
// childrenPaths: finalMainRoute?.children?.map(c => ({
// path: c.path,
// name: c.name,
// isStatic: c.meta?.isStatic || false,
// metaPath: c.meta?.menuPath
// }))
// });
// 测试路由解析(使用完整路径)
const testResolve = router.resolve('/dashboard');
console.log('测试路由解析 /dashboard:', {
matched: testResolve.matched.map(m => ({ name: m.name, path: m.path })),
fullPath: testResolve.fullPath,
name: testResolve.name,
hasMatched: testResolve.matched.length > 0
});
// console.log('测试路由解析 /dashboard:', {
// matched: testResolve.matched.map(m => ({ name: m.name, path: m.path })),
// fullPath: testResolve.fullPath,
// name: testResolve.name,
// hasMatched: testResolve.matched.length > 0
// });
}
// 查找第一个有效的路由(有组件的路由)

View File

@ -13,6 +13,12 @@ service.interceptors.request.use(
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
// 对于有 body 的请求POST、PUT、PATCH确保设置 Content-Type
if (config.data && ['post', 'put', 'patch'].includes(config.method?.toLowerCase())) {
if (!config.headers['Content-Type'] && !config.headers['content-type']) {
config.headers['Content-Type'] = 'application/json';
}
}
return config;
},
error => {

View File

@ -61,6 +61,7 @@
<div slot="header">
<span>正文内容</span>
</div>
<el-divider />
<div class="markdown-body" v-html="compiledMarkdown"></div>
</el-card>
</div>
@ -123,7 +124,8 @@ async function fetchDetail() {
try {
const idValue = id.value as string | number
const res = await getKnowledgeDetail(idValue)
const data = res.data
// API : { code: 0, data: {...}, message: "success" }
const data = (res.code === 0 && res.data) ? res.data : (res.data || res)
formData.value = {
title: data.title || '',
category: data.categoryName || '',
@ -133,6 +135,9 @@ async function fetchDetail() {
updateTime: data.updateTime || '',
content: data.content || '',
}
if (data.title) {
knowledgeTitle.value = data.title
}
} catch (e) {
ElMessage.error("获取详情失败")
}
@ -143,10 +148,7 @@ function goBack() {
router.push("/apps/knowledge")
}
function handleEdit() {
router.push({
name: "apps-knowledge-edit",
params: { id: id.value as string }
})
router.push(`/apps/knowledge/edit/${id.value}`)
}
function handleDelete() {
ElMessageBox.confirm(
@ -160,77 +162,196 @@ function handleDelete() {
).then(async () => {
try {
const idValue = id.value as string | number
await deleteKnowledge(idValue)
ElMessage.success("删除成功")
goBack()
} catch (e) {
ElMessage.error("删除失败")
const res = await deleteKnowledge(idValue)
// API : { code: 0, message: "", data: null }
if (res.code === 0) {
ElMessage.success("删除成功")
goBack()
} else {
ElMessage.error(res.message || "删除失败")
}
} catch (e: any) {
ElMessage.error(e.message || "删除失败")
}
})
}
</script>
<style scoped lang="scss">
/* 全屏显示,覆盖父容器 */
<style scoped lang="less">
/* 知识详情页面样式 - 使用主题变量 */
.knowledge-detail {
position: fixed;
top: 81px; /* 减去 header 高度 */
left: 160px; /* 侧边栏宽度,如果收起的活是 64px */
right: 0;
bottom: 81px; /* 减去 footer 高度 */
z-index: 1000;
overflow-y: auto;
background: var(--background-color);
background-color: var(--bg-color-page);
min-height: 100%;
padding: 24px;
transition: left var(--transition-base);
transition: background-color 0.3s ease;
}
.detail-header {
display: flex;
align-items: center;
margin-bottom: 20px;
background: var(--card-bg);
background-color: var(--card-bg-color);
padding: 16px 24px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--box-shadow);
border: 1px solid var(--border-color-lighter);
transition: all 0.3s ease;
}
.detail-header h2 {
margin: 0 0 0 12px;
font-size: 20px;
font-weight: 600;
color: var(--text-color);
color: var(--text-color-primary);
transition: color 0.3s ease;
}
.detail-body {
display: flex;
gap: 20px;
/* padding: 0 24px 0 24px; */
}
.info-panel {
width: 320px;
flex-shrink: 0;
.header{
// margin-bottom: 14px;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
.header {
color: var(--text-color-primary);
font-weight: 600;
transition: color 0.3s ease;
}
:deep(.el-form-item__label) {
color: var(--text-color-secondary);
transition: color 0.3s ease;
}
:deep(.el-form-item__content) {
color: var(--text-color-primary);
transition: color 0.3s ease;
}
:deep(.el-tag) {
background-color: var(--fill-color-light);
border-color: var(--border-color-lighter);
color: var(--text-color-primary);
transition: all 0.3s ease;
}
}
.content-panel {
flex: 1;
}
.actions {
margin-top: 16px;
text-align: center;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
}
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: var(--text-color);
color: var(--text-color-primary);
transition: color 0.3s ease;
// Markdown
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
color: var(--text-color-primary);
margin-top: 16px;
margin-bottom: 8px;
}
:deep(p), :deep(li), :deep(span), :deep(div) {
color: var(--text-color-primary);
margin: 8px 0;
}
:deep(a) {
color: var(--primary-color);
text-decoration: none;
&:hover {
opacity: 0.8;
}
}
:deep(code) {
background-color: var(--fill-color-light);
color: var(--text-color-primary);
border-color: var(--border-color-lighter);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
:deep(pre) {
background-color: var(--fill-color-light);
border-color: var(--border-color-lighter);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
code {
background-color: transparent;
border: none;
padding: 0;
}
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
}
:deep(ul), :deep(ol) {
color: var(--text-color-primary);
margin: 8px 0;
padding-left: 24px;
}
:deep(blockquote) {
border-left: 4px solid var(--primary-color);
padding-left: 16px;
margin: 16px 0;
color: var(--text-color-secondary);
background-color: var(--fill-color-extra-light);
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
th, td {
border: 1px solid var(--border-color-lighter);
padding: 8px 12px;
color: var(--text-color-primary);
}
th {
background-color: var(--fill-color-light);
font-weight: 600;
}
}
}
.actions {
margin-top: 16px;
text-align: center;
}
/* 响应式布局 */

View File

@ -82,15 +82,18 @@
<!-- 左侧编辑器 -->
<div class="editor-panel">
<el-card shadow="never">
<template #header>
<span>编辑正文</span>
</template>
<WangEditor v-model="formData.content" />
</el-card>
</div>
<!-- 右侧预览 -->
<div class="preview-panel">
<el-card shadow="never">
<div slot="header">
<template #header>
<span>预览效果</span>
</div>
</template>
<div class="markdown-body" v-html="compiledMarkdown"></div>
</el-card>
</div>
@ -193,7 +196,8 @@ const fetchDetail = async () => {
const currentId = id.value as string;
if (currentId && currentId !== "new") {
const res = await getKnowledgeDetail(currentId);
const data = res.data;
// API : { code: 0, data: {...}, message: "success" }
const data = (res.code === 0 && res.data) ? res.data : (res.data || res);
//
// categoryName
@ -216,7 +220,8 @@ const fetchDetail = async () => {
// Tags JSON
if (data.tags) {
try {
formData.tags = JSON.parse(data.tags);
const parsed = typeof data.tags === 'string' ? JSON.parse(data.tags) : data.tags;
formData.tags = Array.isArray(parsed) ? parsed : [];
} catch {
//
formData.tags = Array.isArray(data.tags) ? data.tags : [];
@ -233,15 +238,20 @@ const fetchDetail = async () => {
//
const loadCategoryAndTag = async () => {
try {
const [catRes, tagResRes] = await Promise.all([
getCategoryList
? getCategoryList()
: Promise.resolve({ data: [] }),
getTagList ? getTagList() : Promise.resolve({ data: [] }),
const [catRes, tagRes] = await Promise.all([
getCategoryList(),
getTagList(),
]);
categoryList.value = catRes.data || [];
tagList.value = tagResRes.data || [];
} catch (e) {
// API : { code: 0, data: [...], message: "success" }
const categories = (catRes.code === 0 && catRes.data) ? catRes.data : (catRes.data || []);
const tags = (tagRes.code === 0 && tagRes.data) ? tagRes.data : (tagRes.data || []);
categoryList.value = Array.isArray(categories) ? categories : [];
// { tagId, tagName } tagName
tagList.value = Array.isArray(tags) ? tags.map(tag => tag.tagName || tag) : [];
} catch (e: any) {
console.error("加载分类和标签失败:", e);
categoryList.value = [];
tagList.value = [];
}
@ -270,34 +280,65 @@ const handleSubmit = () => {
const currentId = id.value as string;
//
const submitData = {
id: isEdit.value ? parseInt(currentId as string) : 0,
title: formData.title,
categoryId: formData.categoryId,
author: formData.author,
content: formData.content,
tags: JSON.stringify(formData.tags), // JSON
// categoryId
if (!formData.categoryId) {
ElMessage.warning("请选择分类");
return;
}
// JSON tag
// categoryId
const submitData: any = {
title: formData.title || "",
categoryId: Number(formData.categoryId) || 0,
author: formData.author || "",
content: formData.content || "",
tags: Array.isArray(formData.tags) && formData.tags.length > 0
? JSON.stringify(formData.tags)
: "[]",
status: 1, //
};
//
console.log("提交的数据:", JSON.stringify(submitData, null, 2));
if (isEdit.value && currentId !== "new") {
//
// id JSON tag "id"
const knowledgeId = parseInt(currentId as string);
if (isNaN(knowledgeId) || knowledgeId <= 0) {
ElMessage.error("知识ID无效");
return;
}
// updateKnowledge id data
try {
await updateKnowledge(currentId, submitData);
ElMessage.success("保存成功");
goBack();
const res = await updateKnowledge(knowledgeId, submitData);
// API : { code: 0, message: "", data: null }
if (res.code === 0) {
ElMessage.success("保存成功");
goBack();
} else {
ElMessage.error(res.message || "保存失败");
}
} catch (e: any) {
ElMessage.error(e.message || "保存失败");
console.error("保存失败:", e);
const errorMessage = e.response?.data?.message || e.message || "保存失败";
ElMessage.error(errorMessage);
}
} else {
//
// id
try {
await createKnowledge(submitData);
ElMessage.success("创建成功");
goBack();
const res = await createKnowledge(submitData);
// API : { code: 0, message: "", data: { id: ... } }
if (res.code === 0) {
ElMessage.success("创建成功");
goBack();
} else {
ElMessage.error(res.message || "创建失败");
}
} catch (e: any) {
ElMessage.error(e.message || "创建失败");
console.error("创建失败:", e);
const errorMessage = e.response?.data?.message || e.message || "创建失败";
ElMessage.error(errorMessage);
}
}
});
@ -332,41 +373,54 @@ defineExpose({
});
</script>
<style scoped>
<style scoped lang="less">
.knowledge-edit {
position: fixed;
top: 81px;
left: 160px;
right: 0;
bottom: 81px;
z-index: 1000;
background: var(--background-color);
background-color: var(--bg-color-page);
min-height: 100%;
padding: 24px;
overflow-y: auto;
transition: left var(--transition-base);
transition: background-color 0.3s ease;
}
.edit-header {
display: flex;
align-items: center;
margin-bottom: 20px;
background: var(--card-bg);
background-color: var(--card-bg-color);
padding: 16px 24px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--box-shadow);
border: 1px solid var(--border-color-lighter);
transition: all 0.3s ease;
}
.edit-header h2 {
margin: 0 0 0 12px;
font-size: 20px;
font-weight: 600;
color: var(--text-color);
color: var(--text-color-primary);
transition: color 0.3s ease;
}
/* 基本信息区 */
.edit-meta {
margin-bottom: 20px;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
:deep(.el-divider) {
border-color: var(--border-color-lighter);
transition: border-color 0.3s ease;
}
}
.meta-actions {
@ -378,60 +432,106 @@ defineExpose({
.edit-body {
display: flex;
gap: 20px;
height: calc(100vh - 320px);
min-height: 600px;
}
.editor-panel {
flex: 1;
min-width: 0;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
height: 100%;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
}
.preview-panel {
flex: 1;
min-width: 0;
}
/* 编辑器容器样式 */
.editor-panel {
position: relative;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
height: 100%;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
}
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: var(--text-color);
color: var(--text-color-primary);
min-height: 400px;
padding: 14px 16px;
}
transition: color 0.3s ease;
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3),
.markdown-body :deep(h4),
.markdown-body :deep(h5),
.markdown-body :deep(h6) {
color: var(--text-color);
margin-top: 16px;
margin-bottom: 8px;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
color: var(--text-color-primary);
margin-top: 16px;
margin-bottom: 8px;
}
.markdown-body :deep(p) {
color: var(--text-secondary);
margin: 8px 0;
}
:deep(p), :deep(li), :deep(span), :deep(div) {
color: var(--text-color-primary);
margin: 8px 0;
}
.markdown-body :deep(code) {
background-color: var(--background-hover);
color: var(--text-color);
padding: 2px 6px;
border-radius: 3px;
}
:deep(a) {
color: var(--primary-color);
text-decoration: none;
&:hover {
opacity: 0.8;
}
}
.markdown-body :deep(pre) {
background-color: var(--background-hover);
padding: 12px;
border-radius: var(--border-radius);
overflow-x: auto;
:deep(code) {
background-color: var(--fill-color-light);
color: var(--text-color-primary);
border-color: var(--border-color-lighter);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
:deep(pre) {
background-color: var(--fill-color-light);
border-color: var(--border-color-lighter);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
code {
background-color: transparent;
border: none;
padding: 0;
}
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
}
}
/* 响应式布局 */
@ -454,9 +554,23 @@ defineExpose({
font-size: 16px;
}
}
:deep() {
.el-form-item--default {
margin-bottom: 0px;
:deep(.el-form-item--default) {
margin-bottom: 0px;
}
:deep(.el-input__wrapper), :deep(.el-textarea__inner) {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
}
:deep(.el-textarea__inner) {
&::placeholder {
color: var(--text-color-placeholder) !important;
}
&:focus {
border-color: var(--primary-color) !important;
}
}
</style>

View File

@ -8,15 +8,6 @@
<!-- 搜索区域 -->
<div class="search-container">
<el-select
v-model="searchType"
placeholder="知识库"
size="default"
class="search-select"
style="width: auto"
>
<el-option label="知识库" value="knowledge" />
</el-select>
<el-input
v-model="keyword"
placeholder="请输入关键字、产品编码进行查询"
@ -181,7 +172,7 @@
import { ref, reactive, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { getKnowledgeList, deleteKnowledge } from "@/api/knowlege";
import { getKnowledgeList, deleteKnowledge } from "@/api/knowledge";
//
interface Knowledge {
@ -286,8 +277,15 @@ async function fetchRepoList() {
keyword: keyword.value, //
});
repoList.value = result.list || [];
total.value = result.total || 0;
// API : { code: 0, data: { list: [...], total: 1 }, message: "success" }
if (result.code === 0 && result.data) {
repoList.value = result.data.list || [];
total.value = result.data.total || 0;
} else {
// list total
repoList.value = result.list || result.data?.list || [];
total.value = result.total || result.data?.total || 0;
}
// API
updateStats();
@ -302,27 +300,18 @@ async function fetchRepoList() {
}
function handleCreate() {
//
router.push({
name: "apps-knowledge-edit",
params: { id: "new" },
});
// 使
router.push(`/apps/knowledge/edit/new`);
}
function handleView(repo: Knowledge) {
// 使 params id
router.push({
name: "apps-knowledge-detail",
params: { id: repo.id.toString() },
});
// 使
router.push(`/apps/knowledge/detail/${repo.id}`);
}
function handleEdit(repo: Knowledge) {
// 使 params id
router.push({
name: "apps-knowledge-edit",
params: { id: repo.id.toString() },
});
// 使
router.push(`/apps/knowledge/edit/${repo.id}`);
}
function handleDelete(repo: Knowledge) {
@ -393,98 +382,74 @@ onMounted(() => {
</script>
<style lang="less" scoped>
//
// -
.hero.new-style {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 60px 20px;
margin-bottom: 30px;
position: relative;
overflow: hidden;
background: var(--card-bg-color);
padding: 48px 24px;
margin-bottom: 32px;
border-radius: 12px;
box-shadow: var(--box-shadow-dark);
transition: box-shadow 0.3s ease;
//
&::before {
content: "";
position: absolute;
top: -50%;
right: -10%;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
filter: blur(80px);
}
&::after {
content: "";
position: absolute;
bottom: -30%;
left: -10%;
width: 400px;
height: 400px;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
filter: blur(60px);
}
border: 1px solid var(--border-color-lighter);
box-shadow: var(--box-shadow);
transition: all 0.3s ease;
.hero-content {
max-width: 1200px;
margin: 0 auto;
position: relative;
z-index: 1;
text-align: center;
}
h1 {
font-size: 42px;
color: white;
margin: 0 0 12px;
font-weight: 700;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
letter-spacing: -0.5px;
font-size: 36px;
font-weight: 600;
color: var(--text-color-primary);
margin: 0 0 8px;
letter-spacing: -0.3px;
transition: color 0.3s ease;
}
p {
font-size: 18px;
color: rgba(255, 255, 255, 0.95);
margin: 0 0 30px;
font-weight: 300;
font-size: 15px;
color: var(--text-color-secondary);
margin: 0 0 32px;
font-weight: 400;
transition: color 0.3s ease;
}
.search-container {
display: flex;
align-items: stretch;
max-width: 900px;
margin: 0 auto 30px;
max-width: 800px;
margin: 0 auto 24px;
background: var(--fill-color-blank);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--box-shadow);
border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: none;
transition: all 0.3s ease;
border: 1px solid var(--border-color-lighter);
overflow: hidden;
&:hover {
box-shadow: var(--box-shadow-dark);
transform: translateY(-2px);
border-color: var(--primary-color);
}
&:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
}
.search-select {
border-right: 1px solid var(--border-color-lighter);
border-radius: 0;
transition: border-color 0.3s ease;
:deep(.el-input__wrapper) {
border: none;
box-shadow: none;
}
:deep(.el-input__inner) {
border: none;
border-radius: 0;
padding: 12px 20px;
font-weight: 500;
background-color: var(--fill-color-blank);
padding: 12px 16px;
font-weight: 400;
background-color: transparent;
color: var(--text-color-primary);
}
}
@ -492,30 +457,36 @@ onMounted(() => {
.search-input {
flex: 1;
:deep(.el-input__wrapper){
border: none !important;
}
:deep(.el-input__wrapper) {
border: none;
box-shadow: none;
}
:deep(.el-input__inner) {
border: none;
padding: 12px 20px;
font-size: 16px;
box-shadow: none;
&:focus {
box-shadow: none;
}
padding: 12px 16px;
font-size: 15px;
}
}
.search-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: var(--primary-color);
border: none;
padding: 0 32px;
font-size: 16px;
border-left: 1px solid;
padding: 0 28px;
font-size: 15px;
font-weight: 500;
color: white;
color: #fff;
transition: all 0.3s ease;
border-radius: 0;
&:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: scale(1.05);
background-color: var(--primary-color);
opacity: 0.9;
}
}
@ -524,29 +495,30 @@ onMounted(() => {
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
gap: 10px;
}
.hot-label {
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
font-weight: 500;
color: var(--text-color-secondary);
font-size: 13px;
font-weight: 400;
transition: color 0.3s ease;
}
.hot-tag {
background-color: rgba(255, 255, 255, 0.25);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
background-color: var(--fill-color-light);
color: var(--text-color-primary);
border: 1px solid var(--border-color-lighter);
cursor: pointer;
transition: all 0.3s ease;
padding: 6px 16px;
border-radius: 20px;
transition: all 0.2s ease;
padding: 12px;
border-radius: 4px;
font-size: 13px;
&:hover {
background-color: rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
background-color: var(--fill-color);
border-color: var(--primary-color);
color: var(--primary-color);
}
}
}
@ -933,43 +905,34 @@ onMounted(() => {
//
@media (max-width: 768px) {
.hero.new-style {
padding: 40px 15px;
border-radius: 0;
padding: 32px 16px;
border-radius: 8px;
h1 {
font-size: 32px;
font-size: 28px;
}
p {
font-size: 16px;
font-size: 14px;
}
.search-container {
flex-direction: column;
border-radius: 8px;
.search-select,
.search-input {
:deep(.el-input__inner) {
border-radius: 0;
}
}
.search-select {
:deep(.el-input__inner) {
border-radius: 8px 8px 0 0;
}
border-right: none;
border-bottom: 1px solid var(--border-color-lighter);
}
.search-button {
border-radius: 0 0 8px 8px;
width: 100%;
border-radius: 0 0 8px 8px;
}
}
.hot-search {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
}

View File

@ -0,0 +1,621 @@
<template>
<div class="wang-editor-wrapper" :class="{ focused: isFocused }">
<div ref="toolbarRef" class="toolbar-container"></div>
<div ref="editorRef" class="editor-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import '@wangeditor/editor/dist/css/style.css';
import { uploadFile } from '@/api/file';
interface Props {
modelValue: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const toolbarRef = ref<HTMLDivElement>();
const editorRef = ref<HTMLDivElement>();
const isFocused = ref(false);
let editorInstance: any = null;
let isDestroyed = false;
// URL
const getUploadUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL;
};
// Authorization Header
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
//
const handleUploadImage = async (file: File, insertFn: (url: string, alt?: string, href?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
// axios response.data使 response
if (response?.success) {
const fileUrl = response.data.file_url; // : /uploads/2024/01/15/xxx.jpg
const baseUrl = getUploadUrl() || window.location.origin;
// URLURL使baseUrl
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
// URL/baseUrl/
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
console.log('图片上传成功URL:', fullUrl);
insertFn(fullUrl, file.name, fullUrl);
ElMessage.success('图片上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
//
const handleUploadVideo = async (file: File, insertFn: (url: string, poster?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
if (response?.success) {
const fileUrl = response.data.file_url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
console.log('视频上传成功URL:', fullUrl);
insertFn(fullUrl, '');
ElMessage.success('视频上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
//
const handleUploadAttachment = async (file: File, insertFn: (url: string, text?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
if (response?.success) {
const fileUrl = response.data.file_url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
console.log('附件上传成功URL:', fullUrl);
insertFn(fullUrl, file.name);
ElMessage.success('附件上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
//
const initEditor = async () => {
if (!editorRef.value || !toolbarRef.value || isDestroyed) return;
try {
// wangEditor
const { createEditor, createToolbar } = await import('@wangeditor/editor');
const editorConfig = {
placeholder: '请输入内容...',
onChange: (editor: any) => {
if (!isDestroyed) {
const html = editor.getHtml();
emit('update:modelValue', html);
}
},
//
MENU_CONF: {
//
uploadImage: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, alt?: string, href?: string) => void) => {
await handleUploadImage(file, insertFn);
},
allowedFileTypes: ['image/*'],
maxFileSize: 5 * 1024 * 1024, // 5MB
},
//
uploadVideo: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, poster?: string) => void) => {
await handleUploadVideo(file, insertFn);
},
allowedFileTypes: ['video/*'],
maxFileSize: 100 * 1024 * 1024, // 100MB
},
//
uploadAttachment: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, text?: string) => void) => {
await handleUploadAttachment(file, insertFn);
},
allowedFileTypes: ['*'],
maxFileSize: 50 * 1024 * 1024, // 50MB
},
},
};
//
editorInstance = createEditor({
selector: editorRef.value,
html: props.modelValue || '',
config: editorConfig,
mode: 'default',
});
//
createToolbar({
editor: editorInstance,
selector: toolbarRef.value,
config: {},
});
// API
nextTick(() => {
if (editorInstance) {
// 1: 使 on
if (typeof editorInstance.on === 'function') {
editorInstance.on('focus', () => {
isFocused.value = true;
});
editorInstance.on('blur', () => {
isFocused.value = false;
});
}
// 2: DOM
const editorDom = editorRef.value;
if (editorDom) {
const textDom = editorDom.querySelector('.w-e-text');
if (textDom) {
textDom.addEventListener('focus', () => {
isFocused.value = true;
});
textDom.addEventListener('blur', () => {
isFocused.value = false;
});
}
}
}
});
} catch (error) {
console.error('Failed to initialize editor:', error);
}
};
//
watch(() => props.modelValue, (newVal) => {
if (editorInstance && newVal !== editorInstance.getHtml()) {
editorInstance.setHtml(newVal || '');
}
});
//
defineExpose({
clear: () => {
if (editorInstance) {
editorInstance.clear();
}
},
getContent: () => {
if (editorInstance) {
return editorInstance.getHtml();
}
return props.modelValue;
},
setContent: (content: string) => {
if (editorInstance) {
editorInstance.setHtml(content || '');
}
},
});
onMounted(() => {
nextTick(() => {
setTimeout(() => {
initEditor();
}, 100);
});
});
onBeforeUnmount(() => {
isDestroyed = true;
if (editorInstance) {
editorInstance.destroy();
editorInstance = null;
}
});
</script>
<style scoped lang="less">
.wang-editor-wrapper {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
background-color: var(--fill-color-blank);
transition: all 0.3s ease;
}
.toolbar-container {
border-bottom: 1px solid var(--border-color-lighter);
background-color: var(--fill-color-light);
transition: all 0.3s ease;
}
.editor-container {
min-height: 400px;
background-color: var(--fill-color-blank);
transition: background-color 0.3s ease;
}
</style>
<style lang="less">
// WangEditor
.wang-editor-wrapper {
//
:deep(.w-e-toolbar) {
background-color: var(--fill-color-light) !important;
border-bottom-color: var(--border-color-lighter) !important;
border-bottom: 1px solid var(--border-color-lighter) !important;
//
.w-e-bar-item {
button {
color: var(--text-color-primary) !important;
background-color: transparent !important;
border: none !important;
transition: all 0.2s ease !important;
&:hover:not(.disabled) {
background-color: var(--fill-color) !important;
color: var(--primary-color) !important;
}
&:active:not(.disabled) {
background-color: var(--fill-color-dark) !important;
}
&.active {
background-color: var(--fill-color-dark) !important;
color: var(--primary-color) !important;
}
&.disabled {
color: var(--text-color-disabled) !important;
cursor: not-allowed !important;
opacity: 0.5 !important;
}
}
// 线
&::after {
background-color: var(--border-color-lighter) !important;
}
}
// 线
.w-e-bar-divider {
background-color: var(--border-color-lighter) !important;
}
}
//
:deep(.w-e-text-container) {
background-color: var(--fill-color-blank) !important;
color: var(--text-color-primary) !important;
border: none !important;
//
.w-e-text {
color: var(--text-color-primary) !important;
background-color: transparent !important;
min-height: 400px !important;
//
&:focus {
outline: none !important;
}
//
p {
color: var(--text-color-primary) !important;
margin: 0.5em 0 !important;
}
//
h1, h2, h3, h4, h5, h6 {
color: var(--text-color-primary) !important;
font-weight: 600 !important;
}
//
a {
color: var(--primary-color) !important;
text-decoration: underline !important;
&:hover {
color: var(--primary-color) !important;
opacity: 0.8 !important;
}
}
//
code {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
border: 1px solid var(--border-color-lighter) !important;
padding: 2px 6px !important;
border-radius: 3px !important;
}
//
pre {
background-color: var(--fill-color-light) !important;
border: 1px solid var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
border-radius: 4px !important;
code {
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
}
//
blockquote {
border-left: 4px solid var(--border-color) !important;
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
padding: 0.6em 1.2em !important;
margin: 1em 0 !important;
}
//
table {
border-collapse: collapse !important;
border: 1px solid var(--border-color-lighter) !important;
th, td {
border: 1px solid var(--border-color-lighter) !important;
background-color: var(--fill-color-blank) !important;
color: var(--text-color-primary) !important;
}
th {
background-color: var(--fill-color-light) !important;
}
}
//
ul, ol {
color: var(--text-color-primary) !important;
}
li {
color: var(--text-color-primary) !important;
}
}
//
.placeholder {
color: var(--text-color-placeholder) !important;
}
}
//
:deep(.w-e-drop-panel) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
color: var(--text-color-primary) !important;
.w-e-list-item {
color: var(--text-color-primary) !important;
transition: all 0.2s ease !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.selected {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
&.disabled {
color: var(--text-color-disabled) !important;
cursor: not-allowed !important;
&:hover {
background-color: transparent !important;
}
}
}
// 线
.w-e-drop-panel-divider {
background-color: var(--border-color-lighter) !important;
}
}
//
:deep(.w-e-toolbar-menu) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
.w-e-menu-item {
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.active {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
}
}
//
:deep(.w-e-modal) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
.w-e-modal-header {
border-bottom: 1px solid var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
.w-e-modal-body {
background-color: var(--bg-color-overlay) !important;
color: var(--text-color-primary) !important;
input, textarea, select {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:focus {
border-color: var(--primary-color) !important;
}
}
}
.w-e-modal-footer {
border-top: 1px solid var(--border-color-lighter) !important;
button {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
}
}
}
//
:deep(.w-e-bar-item svg),
:deep(.w-e-bar-item .w-e-icon) {
fill: var(--text-color-primary) !important;
color: var(--text-color-primary) !important;
transition: fill 0.2s ease, color 0.2s ease !important;
}
:deep(.w-e-bar-item:hover:not(.disabled) svg),
:deep(.w-e-bar-item:hover:not(.disabled) .w-e-icon),
:deep(.w-e-bar-item.active svg),
:deep(.w-e-bar-item.active .w-e-icon) {
fill: var(--primary-color) !important;
color: var(--primary-color) !important;
}
//
:deep(.w-e-bar-divider) {
background-color: var(--border-color-lighter) !important;
}
//
&.focused {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
//
[data-theme="dark"] {
.wang-editor-wrapper {
:deep(.w-e-text-container .w-e-text) {
//
img {
border: 1px solid var(--border-color-lighter) !important;
border-radius: 4px !important;
}
// 线
hr {
border-top-color: var(--border-color-lighter) !important;
}
//
table tr:nth-child(even) {
background-color: var(--fill-color-extra-light) !important;
}
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { login } from "@/api/login";
@ -7,24 +7,64 @@ import { login } from "@/api/login";
const router = useRouter()
const authStore = useAuthStore()
const tenant = ref('')
const username = ref('')
const password = ref('')
const passwordVisible = ref(false)
const rememberMe = ref(false)
const loading = ref(false)
const errorMsg = ref('')
//
onMounted(() => {
const savedUser = localStorage.getItem('loginUsername')
const savedTenant = localStorage.getItem('tenant')
const savedRemember = localStorage.getItem('loginRememberMe')
if (savedRemember === 'true') {
tenant.value = savedTenant || ''
username.value = savedUser || ''
rememberMe.value = true
}
})
//
function cacheTenant(tenantValue) {
localStorage.setItem('tenant', tenantValue)
sessionStorage.setItem('tenant', tenantValue)
}
const handleLogin = async () => {
errorMsg.value = ''
if (!username.value || !password.value) {
errorMsg.value = '请输入用户名和密码'
if (!tenant.value || !username.value || !password.value) {
errorMsg.value = '请输入租户名称、用户名和密码'
return
}
//
const tenantName = tenant.value.trim()
if (!tenantName) {
errorMsg.value = '租户名称不能为空'
return
}
//
if (rememberMe.value) {
localStorage.setItem('loginUsername', username.value)
localStorage.setItem('loginRememberMe', 'true')
localStorage.setItem('tenant', tenantName)
} else {
localStorage.removeItem('loginUsername')
localStorage.setItem('loginRememberMe', 'false')
// tenantlocalStorage便
localStorage.setItem('tenant', tenantName)
}
// 便
cacheTenant(tenantName)
loading.value = true
try {
const res = await login(username.value, password.value)
// code === 0 data
const res = await login(username.value, password.value, tenantName)
if (res && res.code === 0 && res.data) {
// token
authStore.setLoginInfo(res.data)
// res.data.tenant使
cacheTenant(tenantName)
router.push({ path: '/dashboard' })
} else {
errorMsg.value = res.message || '登录失败'
@ -35,90 +75,417 @@ const handleLogin = async () => {
loading.value = false
}
}
//
const goRegister = () => {
router.push({ path: '/register' })
}
const goForget = () => {
router.push({ path: '/forget' })
}
</script>
<template>
<div class="login-container">
<div class="login-form">
<h2>登录</h2>
<div class="form-group">
<input
v-model="username"
type="text"
placeholder="用户名"
autocomplete="username"
/>
<div class="login-bg">
<div class="login-card">
<div class="login-side">
<div class="brand">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="18" fill="#eef8fc"/>
<circle cx="24" cy="24" r="14" fill="#52a8ff" opacity="0.15"/>
<circle cx="24" cy="24" r="9" fill="#3b7ddd" opacity="0.12"/>
<text x="24" y="30" text-anchor="middle" fill="#2d5fa7" font-size="16" font-family="Arial" font-weight="bold">Mete</text>
</svg>
<span class="brand-title">后台管理系统</span>
</div>
<div class="illus">
<svg viewBox="0 0 300 160" style="max-width:100%;" fill="none">
<ellipse cx="150" cy="140" rx="120" ry="16" fill="#edf4fd" />
<rect x="57" y="58" width="60" height="40" rx="12" fill="#64b6f7"/>
<rect x="125" y="46" width="110" height="64" rx="14" fill="#389bf7" opacity="0.11" />
<rect x="136" y="60" width="60" height="41" rx="10" fill="#b8e1ff"/>
</svg>
</div>
<!-- 版权信息 -->
<div class="copyright">
© 2024 Mete 管理系统
</div>
</div>
<div class="form-group">
<input
v-model="password"
type="password"
placeholder="密码"
autocomplete="current-password"
/>
<div class="login-panel">
<h2 class="login-title">欢迎登录</h2>
<div class="login-desc">请填写您的账号信息</div>
<div class="form-group icon-input-group">
<span class="input-icon">
<!-- 租户图标 -->
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
<rect x="2.2" y="4.2" width="15.6" height="11.6" rx="2" stroke="#4da1ff" stroke-width="1.4"/>
<rect x="6" y="8" width="8" height="4" rx="1" fill="#b9dbff"/>
<rect x="6.7" y="8.7" width="6.6" height="2.6" rx="0.7" fill="#fff"/>
</svg>
</span>
<input
v-model="tenant"
type="text"
placeholder="租户名称"
autocomplete="organization"
class="input input-with-icon"
/>
</div>
<div class="form-group icon-input-group">
<span class="input-icon">
<!-- 用户图标 -->
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="7" r="3.2" stroke="#4da1ff" stroke-width="1.4"/>
<ellipse cx="10" cy="14.1" rx="5.5" ry="3.3" stroke="#4da1ff" stroke-width="1.4"/>
</svg>
</span>
<input
v-model="username"
type="text"
placeholder="用户名"
autocomplete="username"
class="input input-with-icon"
/>
</div>
<div class="form-group icon-input-group">
<span class="input-icon">
<!-- 密码图标 -->
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
<rect x="3" y="8" width="14" height="7" rx="2" stroke="#4da1ff" stroke-width="1.4"/>
<circle cx="10" cy="11.5" r="1.5" stroke="#4da1ff" stroke-width="1.2"/>
<rect x="7" y="5" width="6" height="3" rx="1.5" stroke="#4da1ff" stroke-width="1"/>
</svg>
</span>
<input
v-model="password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="密码"
autocomplete="current-password"
class="input input-with-icon"
/>
<span class="visible-btn" @click="passwordVisible = !passwordVisible" :title="passwordVisible ? '隐藏密码' : '显示密码'">
<svg v-if="passwordVisible" width="20" height="20" fill="none" viewBox="0 0 20 20">
<!-- 可见(eye)图标 -->
<path d="M2 10c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z" stroke="#7bb7fa" stroke-width="1.4" fill="#eef5ff"/>
<circle cx="10" cy="10" r="2.5" stroke="#3794f7" stroke-width="1.4" fill="#fff"/>
</svg>
<svg v-else width="20" height="20" fill="none" viewBox="0 0 20 20">
<!-- 不可见(eye-off)图标 -->
<path d="M2 10c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z" stroke="#b7c7db" stroke-width="1.3" fill="#f2f6fd"/>
<path d="M5 15L15 5" stroke="#b7c7db" stroke-width="1.2"/>
</svg>
</span>
</div>
<div class="remember-me-row">
<label class="remember-me-label">
<input type="checkbox" v-model="rememberMe" class="remember-me-checkbox" />
<span>记住我</span>
</label>
<div class="action-links">
<a class="register-link" @click.prevent="goRegister">注册账号</a>
<span class="divider">|</span>
<a class="forget-link" @click.prevent="goForget">忘记密码?</a>
</div>
</div>
<transition name="fade">
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
</transition>
<button class="login-btn" @click="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
</div>
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
<button class="login-btn" @click="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
<!-- 背景光效 -->
<div class="login-light light1"></div>
<div class="login-light light2"></div>
</div>
</template>
<style scoped>
.login-container {
.login-bg {
min-height: 100vh;
width: 100vw;
background: linear-gradient(120deg, #e6f0ff 0%, #f5fcff 55%, #eaf6ff 100%);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f5f6fa;
justify-content: center;
position: relative;
overflow: hidden;
}
.login-form {
background: #fff;
padding: 40px 32px;
border-radius: 10px;
box-shadow: 0 2px 16px rgba(0,0,0,0.09);
min-width: 320px;
.login-card {
display: flex;
min-width: 770px;
background: rgba(255,255,255, 0.95);
border-radius: 22px;
box-shadow: 0 8px 36px 0 rgba(73,150,255,0.14), 0 1.5px 4px 0 rgba(30,42,79,0.05);
overflow: hidden;
z-index: 10;
}
.login-side {
width: 320px;
background: #52a8ff;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
padding: 44px 16px 32px 16px;
box-shadow: 4px 0 32px 0 rgba(189,231,255,0.13) inset;
position: relative;
}
.login-form h2 {
margin-bottom: 16px;
.brand {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 42px;
user-select: none;
}
.brand-title {
font-size: 25px;
letter-spacing: 2px;
font-weight: 700;
color: #fff;
/* text-shadow: 0 0 6px #d4ecfc; */
}
.illus {
margin-top: 30px;
user-select: none;
opacity: .95;
}
/* 版权信息样式 */
.copyright {
width: 100%;
text-align: center;
font-size: 13px;
color: #fff;
margin-top: auto;
margin-bottom: 2px;
letter-spacing: 0.2px;
padding-top: 25px;
user-select: none;
}
.login-panel {
flex: 1;
padding: 52px 54px 48px 54px;
display: flex;
flex-direction: column;
justify-content: center;
background: transparent;
min-width: 320px;
}
.login-title {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
color: #2560a9;
text-align: left;
letter-spacing: 1px;
}
.login-desc {
color: #7391c4;
font-size: 15px;
margin-bottom: 28px;
letter-spacing: 0.2px;
}
.form-group {
margin-bottom: 10px;
margin-bottom: 15px;
position: relative;
}
input[type="text"],
input[type="password"] {
/* 输入框前置图标样式 */
.icon-input-group {
display: flex;
align-items: center;
position: relative;
}
.input-with-icon {
padding-left: 36px !important;
}
.input-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
z-index: 2;
width: 20px;
height: 20px;
color: #4da1ff;
opacity: 0.95;
}
.visible-btn {
position: absolute;
right: 11px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
z-index: 2;
padding: 2px 2px;
display: flex;
align-items: center;
opacity: 0.82;
user-select: none;
}
.visible-btn:hover {
opacity: 1;
}
/* 防止密码输入框可见按钮和输入内容重叠 */
.icon-input-group .input-with-icon {
padding-right: 34px;
}
.input {
width: 100%;
padding: 10px 12px;
padding: 12px 14px;
font-size: 16px;
border: 1px solid #dcdfe6;
border-radius: 5px;
border: 1.3px solid #d6e6fa;
border-radius: 7px;
box-sizing: border-box;
margin-bottom: 4px;
transition: border 0.2s, box-shadow 0.2s;
background: #f7fbfe;
margin-bottom: 3px;
}
.input:focus {
border-color: #4da1ff;
outline: none;
box-shadow: 0 0 0 2px #e3f2ffb1;
}
/* 记住我单选框 */
.remember-me-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0;
margin-top: 3px;
min-height: 32px;
font-size: 15px;
color: #6d8eb8;
user-select: none;
}
.remember-me-label {
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
}
.remember-me-checkbox {
width: 16px;
height: 16px;
accent-color: #4da1ff;
margin-right: 2px;
}
.login-btn {
width: 100%;
padding: 10px;
font-size: 16px;
background: #005bea;
padding: 13px 0;
font-size: 17px;
background: linear-gradient(90deg, #3494e6 0%, #52a8ff 100%);
color: #fff;
border: none;
border-radius: 5px;
border-radius: 7px;
box-shadow: 0 2px 12px 0 rgba(81,173,255,0.13);
font-weight: 600;
letter-spacing: 1px;
margin-top: 15px;
cursor: pointer;
transition: background 0.2s;
transition: background 0.2s, transform 0.13s;
}
.login-btn:active {
transform: scale(0.98);
}
.login-btn:disabled {
background: #8bbcfa;
background: #b6dafc;
cursor: not-allowed;
}
.error-msg {
color: #e74c3c;
color: #e4574a;
background: #fdeceb;
text-align: center;
margin-bottom: 6px;
font-size: 14px;
border-radius: 4px;
padding: 7px 4px;
margin-bottom: 2px;
font-size: 14.5px;
letter-spacing: .3px;
animation: shake .28s;
}
@keyframes shake {
0% { transform: translateX(0);}
20% { transform: translateX(-6px);}
40% { transform: translateX(6px);}
60% { transform: translateX(-2px);}
80% { transform: translateX(2px);}
100% { transform: translateX(0);}
}
/* 注册、忘记密码链接 */
.action-links {
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 14.1px;
color: #6592c3;
margin-bottom: 0;
min-height: 22px;
}
.action-links a {
cursor: pointer;
color: #407ad6;
text-decoration: none;
transition: color 0.16s;
}
.action-links a:hover {
color: #165eec;
text-decoration: underline;
}
.action-links .divider {
color: #bbd3ee;
margin: 0 6px;
font-size: 13px;
}
.register-link {
margin-right: 0px;
}
.forget-link {
margin-left: 0px;
}
/* 渐隐提示 */
.fade-enter-active, .fade-leave-active {
transition: opacity .24s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* 炫彩光斑装饰 */
.login-light {
position: absolute;
border-radius: 50%;
pointer-events: none;
filter: blur(45px);
opacity: 0.4;
z-index: 1;
}
.light1 {
width: 340px;
height: 340px;
top: -90px;
left: -60px;
background: radial-gradient(circle at 60% 50%, #55b7f988 0%, #e1e8fa11 95%);
}
.light2 {
width: 260px;
height: 260px;
right: -60px;
bottom: -90px;
background: radial-gradient(circle at 55% 60%, #f3e7ff99 0%, #daf3ff10 100%);
}
@media (max-width: 940px) {
.login-card { min-width: 330px; flex-direction: column; }
.login-side { width: 100%; min-width: 0; border-radius: 0 0 18px 18px;}
.login-panel { padding: 30px 22px 34px 22px; min-width: 0; }
.copyright { padding-top: 13px; }
}
</style>

View File

@ -0,0 +1,472 @@
<template>
<div class="categories-container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">分类管理</h1>
<div class="action-bar">
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
<el-button @click="$router.push('/files')">
<el-icon><ArrowLeft /></el-icon>
返回文件列表
</el-button>
</div>
</div>
<!-- 分类列表 -->
<div class="categories-list">
<el-table
:data="categories"
v-loading="loading"
style="width: 100%"
:default-sort="{ prop: 'created_at', order: 'descending' }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="分类名称" min-width="150">
<template #default="{ row }">
<div class="category-name">
<el-tag :color="getCategoryColor(row.name)" effect="dark">
{{ row.name }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="file_count" label="文件数量" width="120" sortable>
<template #default="{ row }">
<el-badge :value="row.file_count || 0" :max="99" class="item">
<el-button size="small" @click="viewCategoryFiles(row)">查看文件</el-button>
</el-badge>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" sortable>
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" sortable>
<template #default="{ row }">
{{ formatDate(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="updateCategoryStatus(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editCategory(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 添加/编辑分类对话框 -->
<el-dialog
v-model="showAddDialog"
:title="editingCategory ? '编辑分类' : '添加分类'"
width="500px"
:before-close="handleDialogClose"
>
<el-form
ref="categoryFormRef"
:model="categoryForm"
:rules="categoryRules"
label-width="80px"
>
<el-form-item label="分类名称" prop="name">
<el-input v-model="categoryForm.name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="categoryForm.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
/>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="categoryForm.color" />
<span class="tip-text">选择分类显示颜色可选</span>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="categoryForm.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitCategory">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 分类文件列表对话框 -->
<el-dialog
v-model="showFilesDialog"
:title="currentCategory ? currentCategory.name + ' - 文件列表' : '文件列表'"
width="800px"
>
<div v-if="currentCategory" class="category-files">
<el-table :data="categoryFiles" style="width: 100%" v-loading="filesLoading">
<el-table-column prop="file_name" label="文件名" min-width="200" />
<el-table-column prop="original_name" label="原始文件名" min-width="200" />
<el-table-column prop="file_size" label="大小" width="100">
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</el-table-column>
<el-table-column prop="upload_by" label="上传人" width="120" />
<el-table-column prop="upload_time" label="上传时间" width="180">
<template #default="{ row }">
{{ formatDate(row.upload_time) }}
</template>
</el-table-column>
</el-table>
<div class="pagination" style="margin-top: 20px;">
<el-pagination
v-model:current-page="filesCurrentPage"
v-model:page-size="filesPageSize"
:total="totalFiles"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleFilesSizeChange"
@current-change="handleFilesCurrentChange"
/>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showFilesDialog = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Plus, ArrowLeft } from '@element-plus/icons-vue'
//
const loading = ref(false)
const categories = ref<any[]>([])
const showAddDialog = ref(false)
const editingCategory = ref<any>(null)
const showFilesDialog = ref(false)
const currentCategory = ref<any>(null)
const categoryFiles = ref<any[]>([])
const filesLoading = ref(false)
const filesCurrentPage = ref(1)
const filesPageSize = ref(10)
const totalFiles = ref(0)
//
const categoryFormRef = ref<FormInstance>()
//
const categoryForm = reactive({
name: '',
description: '',
color: '#409EFF',
status: 1
})
//
const categoryRules: FormRules = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 20, message: '分类名称长度在 2 到 20 个字符', trigger: 'blur' }
],
description: [
{ max: 200, message: '描述长度不能超过 200 个字符', trigger: 'blur' }
]
}
//
const categoryColors: Record<string, string> = {
'文档': '#67C23A',
'图片': '#E6A23C',
'视频': '#F56C6C',
'音频': '#909399',
'其他': '#409EFF'
}
//
onMounted(() => {
loadCategories()
})
//
const loadCategories = async () => {
try {
loading.value = true
//
categories.value = [
{
id: 1,
name: '文档',
description: '各种文档文件',
file_count: 15,
created_at: '2024-01-01 10:00:00',
updated_at: '2024-01-10 15:30:00',
status: 1
},
{
id: 2,
name: '图片',
description: '图片文件',
file_count: 8,
created_at: '2024-01-02 09:00:00',
updated_at: '2024-01-09 14:20:00',
status: 1
},
{
id: 3,
name: '视频',
description: '视频文件',
file_count: 3,
created_at: '2024-01-03 11:00:00',
updated_at: '2024-01-08 16:45:00',
status: 1
},
{
id: 4,
name: '音频',
description: '音频文件',
file_count: 5,
created_at: '2024-01-04 08:30:00',
updated_at: '2024-01-07 13:15:00',
status: 1
},
{
id: 5,
name: '其他',
description: '其他类型文件',
file_count: 12,
created_at: '2024-01-05 14:00:00',
updated_at: '2024-01-06 10:30:00',
status: 1
}
]
} catch (error) {
console.error('加载分类失败:', error)
ElMessage.error('加载分类失败')
} finally {
loading.value = false
}
}
const getCategoryColor = (categoryName: string) => {
return categoryColors[categoryName] || '#409EFF'
}
const viewCategoryFiles = async (category: any) => {
currentCategory.value = category
showFilesDialog.value = true
await loadCategoryFiles()
}
const loadCategoryFiles = async () => {
try {
filesLoading.value = true
//
categoryFiles.value = [
{
id: 1,
file_name: 'document.pdf',
original_name: '项目文档.pdf',
file_size: 2048576,
upload_by: '管理员',
upload_time: '2024-01-10 10:00:00'
},
{
id: 2,
file_name: 'image.jpg',
original_name: '产品图片.jpg',
file_size: 1024576,
upload_by: '用户A',
upload_time: '2024-01-09 15:30:00'
}
]
totalFiles.value = categoryFiles.value.length
} catch (error) {
console.error('加载分类文件失败:', error)
ElMessage.error('加载分类文件失败')
} finally {
filesLoading.value = false
}
}
const editCategory = (category: any) => {
editingCategory.value = category
Object.assign(categoryForm, category)
showAddDialog.value = true
}
const deleteCategory = async (category: any) => {
try {
await ElMessageBox.confirm(
`确定要删除分类 "${category.name}" 吗?此操作不可恢复。`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
//
categories.value = categories.value.filter(c => c.id !== category.id)
ElMessage.success('删除成功')
} catch (error) {
if (error !== 'cancel') {
console.error('删除分类失败:', error)
ElMessage.error('删除失败')
}
}
}
const updateCategoryStatus = async (category: any) => {
try {
//
ElMessage.success(`分类 "${category.name}" 状态已更新`)
} catch (error) {
console.error('更新状态失败:', error)
ElMessage.error('更新状态失败')
//
category.status = category.status === 1 ? 0 : 1
}
}
const submitCategory = async () => {
if (!categoryFormRef.value) return
try {
await categoryFormRef.value.validate()
if (editingCategory.value) {
//
const index = categories.value.findIndex(c => c.id === editingCategory.value.id)
if (index !== -1) {
Object.assign(categories.value[index], { ...categoryForm })
}
ElMessage.success('分类更新成功')
} else {
//
const newCategory = {
id: Date.now(),
...categoryForm,
file_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
categories.value.unshift(newCategory)
ElMessage.success('分类添加成功')
}
showAddDialog.value = false
resetForm()
} catch (error) {
console.error('提交分类失败:', error)
}
}
const handleDialogClose = () => {
showAddDialog.value = false
resetForm()
}
const resetForm = () => {
editingCategory.value = null
categoryForm.name = ''
categoryForm.description = ''
categoryForm.color = '#409EFF'
categoryForm.status = 1
categoryFormRef.value?.clearValidate()
}
const handleFilesSizeChange = (size: number) => {
filesPageSize.value = size
filesCurrentPage.value = 1
loadCategoryFiles()
}
const handleFilesCurrentChange = (page: number) => {
filesCurrentPage.value = page
loadCategoryFiles()
}
const formatDate = (dateString: string) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.categories-container {
padding: 20px;
background: #fff;
min-height: calc(100vh - 120px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.categories-list {
margin-bottom: 20px;
}
.category-name {
display: flex;
align-items: center;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #999;
}
.pagination {
display: flex;
justify-content: flex-end;
}
:deep(.el-badge__content) {
top: -8px;
right: -8px;
}
</style>

View File

@ -0,0 +1,673 @@
<template>
<el-card class="box-card">
<div class="header-bar">
<h2>文件管理</h2>
<!-- <el-button type="primary" @click="showProgramDialog = true">
<el-icon><Plus /></el-icon>
添加程序
</el-button> -->
</div>
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载程序数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchPrograms">重试</el-button>
</div>
<div v-else class="files-module fancy-bg">
<!-- 顶部功能卡片区域 -->
<div class="function-cards">
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/list')"
>
<div class="card-content">
<div class="card-icon">
<i class="fa-solid fa-folder-open"></i>
</div>
<div class="card-info">
<span class="card-title">文件列表</span>
<span class="card-desc">查看和管理所有文件</span>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/upload')"
>
<div class="card-content">
<div class="card-icon upload">
<i class="fa-solid fa-cloud-arrow-up"></i>
</div>
<div class="card-info">
<span class="card-title">批量上传</span>
<span class="card-desc">批量上传文件至系统</span>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/categories')"
>
<div class="card-content">
<div class="card-icon tags">
<i class="fa-solid fa-layer-group"></i>
</div>
<div class="card-info">
<span class="card-title">分类管理</span>
<span class="card-desc">管理分类/标签</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 搜索和筛选 -->
<div class="search-filter stylish-panel">
<el-input
v-model="searchKeyword"
placeholder="搜索文件名、原始文件名或分类"
clearable
@input="handleSearch"
size="large"
class="input-search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="filterCategory"
placeholder="全部分类"
clearable
@change="handleFilter"
size="large"
class="select-category"
>
<el-option
v-for="category in categories"
:key="category"
:label="category"
:value="category"
/>
</el-select>
<el-switch
v-model="showMyFiles"
class="switch-mine"
active-text="仅看我的文件"
inactive-text="全部文件"
@change="handleFilter"
/>
</div>
<!-- 文件数据列表 -->
<div class="file-list stylish-panel">
<el-table
:data="filteredFiles"
v-loading="loading"
highlight-current-row
border
:default-sort="{ prop: 'upload_time', order: 'descending' }"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="file_name" label="文件名" min-width="200">
<template #default="{ row }">
<div class="file-name">
<el-icon class="icon-doc"><Document /></el-icon>
<span>{{ row.file_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="original_name"
label="原始文件名"
min-width="180"
/>
<el-table-column prop="category" label="分类" width="100">
<template #default="{ row }">
<el-tag effect="dark" type="success">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="大小" width="100">
<template #default="{ row }">
<el-tag effect="plain">{{
formatFileSize(row.file_size)
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_type" label="类型" width="90" />
<el-table-column prop="upload_by" label="上传人" width="120" />
<el-table-column
prop="upload_time"
label="上传时间"
width="170"
sortable
>
<template #default="{ row }">
<span style="color: #8ea1d6">{{
formatDate(row.upload_time)
}}</span>
</template>
</el-table-column>
<el-table-column
label="操作"
width="170"
fixed="right"
align="center"
>
<template #default="{ row }">
<el-tooltip content="查看详情">
<el-button
type="primary"
circle
plain
size="small"
@click="viewFile(row)"
>
<el-icon><View /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除">
<el-button
circle
type="danger"
plain
size="small"
@click="deleteFile(row)"
>
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页区域 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalFiles"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 上传文件对话框 -->
<el-dialog
v-model="showUploadDialog"
title="上传文件"
width="500px"
:before-close="handleUploadClose"
class="upload-dialog"
>
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ category: uploadForm.category }"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
:show-file-list="false"
multiple
>
<el-icon
class="el-icon--upload"
style="font-size: 44px; color: #36b2fa"
><upload-filled
/></el-icon>
<div class="el-upload__text">
将文件拖到此处<em style="color: #409eff">点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持单个或批量上传单文件大小不超过
<span style="color: #f56c6c; font-weight: 600">10MB</span>
</div>
</template>
</el-upload>
<div class="upload-form upform-padding">
<el-form :model="uploadForm" label-width="80px">
<el-form-item label="分类">
<el-select
v-model="uploadForm.category"
placeholder="请选择分类"
style="width: 100%"
>
<el-option label="文档" value="文档" />
<el-option label="图片" value="图片" />
<el-option label="视频" value="视频" />
<el-option label="音频" value="音频" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="是否公开">
<el-switch
v-model="uploadForm.isPublic"
active-color="#409EFF"
inactive-color="#bfbfbf"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showUploadDialog = false"> </el-button>
<el-button type="primary" @click="submitUpload">上传</el-button>
</span>
</template>
</el-dialog>
<!-- 文件详情对话框 -->
<el-dialog
v-model="showFileDetail"
:title="currentFile ? currentFile.original_name : '文件详情'"
width="650px"
class="file-detail-dialog"
>
<div v-if="currentFile" class="file-detail">
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="文件ID">
<el-tag>{{ currentFile.id }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件名">
<span class="detail-primary">{{ currentFile.file_name }}</span>
</el-descriptions-item>
<el-descriptions-item label="原始文件名">{{
currentFile.original_name
}}</el-descriptions-item>
<el-descriptions-item label="文件大小">
<el-tag type="info">{{
formatFileSize(currentFile.file_size)
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件类型">{{
currentFile.file_type
}}</el-descriptions-item>
<el-descriptions-item label="文件扩展名">{{
currentFile.file_ext
}}</el-descriptions-item>
<el-descriptions-item label="分类">
<el-tag type="success" effect="plain">{{
currentFile.category
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上传人">
<el-tag>{{ currentFile.upload_by }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上传时间">{{
formatDate(currentFile.upload_time)
}}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag
:type="currentFile.status === 1 ? 'success' : 'danger'"
effect="dark"
>
{{ currentFile.status === 1 ? "正常" : "已删除" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否公开">
<el-tag
:type="currentFile.is_public === 1 ? 'success' : 'info'"
effect="plain"
>
{{ currentFile.is_public === 1 ? "公开" : "私有" }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="detail-btns">
<el-button type="primary" @click="downloadFile(currentFile)">
<el-icon><Download /></el-icon> 下载文件
</el-button>
<el-button @click="copyFileUrl(currentFile)">
<el-icon><Link /></el-icon> 复制链接
</el-button>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showFileDetail = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</el-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import {
Download,
Delete,
Search,
Document,
View,
Link,
UploadFilled,
} from "@element-plus/icons-vue";
const router = useRouter();
const recentFiles = ref([
{
id: 1,
name: "项目报告.pdf",
size: 2456789,
type: "PDF",
uploadTime: "2024-01-15 10:30:25",
},
{
id: 2,
name: "数据统计.xlsx",
size: 123456,
type: "Excel",
uploadTime: "2024-01-14 14:20:10",
},
{
id: 3,
name: "产品图片.jpg",
size: 345678,
type: "Image",
uploadTime: "2024-01-13 09:15:45",
},
]);
const getFileIcon = (type: string) => {
const iconMap: Record<string, string> = {
PDF: "fa-solid fa-file-pdf",
Excel: "fa-solid fa-file-excel",
Word: "fa-solid fa-file-word",
Image: "fa-solid fa-file-image",
Video: "fa-solid fa-file-video",
Audio: "fa-solid fa-file-audio",
Archive: "fa-solid fa-file-archive",
};
return iconMap[type] || "fa-solid fa-file";
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// mock
const categories = ref(["文档", "图片", "视频", "音频", "其他"]);
const filteredFiles = ref([]);
const loading = ref(false);
const searchKeyword = ref("");
const filterCategory = ref("");
const showMyFiles = ref(false);
const pageSize = ref(10);
const currentPage = ref(1);
const totalFiles = ref(0);
const showUploadDialog = ref(false);
//
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ;
return `${baseUrl}/api/files`;
});
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`
};
});
const uploadForm = ref({
category: "",
isPublic: false,
});
const showFileDetail = ref(false);
const currentFile = ref<any>(null);
const handleSearch = () => {};
const handleFilter = () => {};
const handleSizeChange = () => {};
const handleCurrentChange = () => {};
const handleUploadClose = () => {
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
};
const handleUploadSuccess = (response: any, file: any) => {
ElMessage.success('文件上传成功!');
//
showUploadDialog.value = false;
//
// loadFiles();
};
const handleUploadError = (error: Error, file: any) => {
ElMessage.error('文件上传失败:' + error.message);
};
const beforeUpload = (file: File) => {
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB');
return false;
}
return true;
};
const submitUpload = () => {
// Element Plus el-upload
ElMessage.info('开始上传文件...');
};
const viewFile = (row: any) => {};
const formatDate = (val: string | number) => val;
const copyFileUrl = (file: any) => {};
const downloadFile = (file: any) => {
//
console.log("下载文件:", file.name || file.file_name);
};
const deleteFile = (file: any) => {
//
console.log("删除文件:", file.name || file.file_name);
};
onMounted(() => {
console.log("文件管理模块已加载");
});
</script>
<style scoped>
.fancy-bg {
padding: 28px 14px 24px 14px;
}
.stylish-panel {
background: #fff;
border-radius: 14px;
box-shadow: 0 4px 14px 0 #e2eafe77;
margin-bottom: 28px;
padding: 28px 18px 24px 18px;
}
.function-cards {
margin-bottom: 32px;
}
.function-card {
border: none !important;
border-radius: 14px !important;
box-shadow: 0 8px 24px 0 #bedcff11 !important;
background: #fff;
cursor: pointer;
transition:
transform 0.15s cubic-bezier(0.17, 0.67, 0.83, 0.67),
box-shadow 0.18s;
}
.function-card:hover {
transform: translateY(-4px) scale(1.032);
box-shadow: 0 18px 48px 0 #6bbaf522 !important;
}
.card-gradient {
background: linear-gradient(120deg, #ecf4ff 94%, #eff8ff 100%) !important;
}
.card-content {
display: flex;
align-items: center;
padding: 26px 16px;
min-height: 95px;
}
.card-icon {
font-size: 34px;
width: 54px;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
color: #3178ea;
background: linear-gradient(133deg, #d9eaff 82%, #f2f6fd 100%);
border-radius: 12px;
margin-right: 18px;
transition: color 0.25s;
}
.card-icon.upload {
color: #13c2c2;
background: linear-gradient(145deg, #c5faea 58%, #d6f3ff 98%);
}
.card-icon.tags {
color: #a048ff;
background: linear-gradient(143deg, #e8dafe 88%, #f3e2fd 100%);
}
.card-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.card-title {
font-size: 19px;
font-weight: 700;
color: #2c3547;
letter-spacing: 1.5px;
margin-bottom: 3px;
}
.card-desc {
font-size: 13px;
color: #6c7ea5;
font-weight: 400;
}
.rc-title-bar {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.recent-icon {
font-size: 20px;
color: #409eff;
margin-right: 8px;
}
.recent-title {
font-size: 17px;
font-weight: 600;
color: #293a46;
}
.recent-table {
border-radius: 6px !important;
}
.input-search {
width: 300px !important;
margin-right: 18px !important;
box-shadow: 0 2px 6px 0 #dae7fd24;
}
.select-category {
width: 170px !important;
margin-right: 18px;
}
.switch-mine {
margin-left: 8px;
}
.file-list {
margin-bottom: 28px;
background: #fff;
}
.file-name {
display: flex;
align-items: center;
}
.icon-doc {
margin-right: 8px;
color: #2090ff;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
background: none;
}
.upload-dialog .el-dialog__body {
padding-top: 20px;
}
.upform-padding {
margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 20px;
}
.file-detail-dialog .el-dialog__body {
padding-top: 18px;
background: #fafcff;
}
.file-detail {
max-height: 380px;
overflow-y: auto;
}
.detail-primary {
color: #416cf7;
font-weight: 600;
}
.detail-btns {
margin-top: 28px;
display: flex;
justify-content: center;
gap: 22px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
@media (max-width: 900px) {
.stylish-panel,
.file-list,
.function-cards {
padding: 10px !important;
}
.fancy-bg {
padding: 10px !important;
}
}
</style>

View File

@ -0,0 +1,369 @@
<template>
<div class="upload-container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">批量上传</h1>
<div class="action-bar">
<el-button @click="$router.push('/files')">
<el-icon><ArrowLeft /></el-icon>
返回文件列表
</el-button>
</div>
</div>
<!-- 上传区域 -->
<div class="upload-area">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
multiple
:file-list="fileList"
:auto-upload="false"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持批量上传单个文件大小不超过 10MB支持拖拽排序
</div>
</template>
</el-upload>
</div>
<!-- 上传设置 -->
<div class="upload-settings">
<el-card header="上传设置">
<el-form :model="uploadForm" label-width="100px">
<el-form-item label="文件分类">
<el-select v-model="uploadForm.category" placeholder="请选择分类">
<el-option label="文档" value="文档" />
<el-option label="图片" value="图片" />
<el-option label="视频" value="视频" />
<el-option label="音频" value="音频" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="是否公开">
<el-switch v-model="uploadForm.isPublic" />
<span class="tip-text">公开文件所有用户可见私有文件仅自己可见</span>
</el-form-item>
<el-form-item label="覆盖同名">
<el-switch v-model="uploadForm.overwrite" />
<span class="tip-text">遇到同名文件时是否覆盖</span>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 文件列表预览 -->
<div class="file-preview" v-if="fileList.length > 0">
<el-card header="待上传文件列表">
<el-table :data="fileList" style="width: 100%">
<el-table-column prop="name" label="文件名" min-width="200" />
<el-table-column label="大小" width="120">
<template #default="{ row }">
{{ formatFileSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row, $index }">
<el-button
size="small"
type="danger"
@click="removeFile($index)"
:disabled="row.status === 'uploading'"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="upload-actions">
<el-button
type="primary"
@click="submitUpload"
:loading="uploading"
:disabled="fileList.length === 0"
>
{{ uploading ? '上传中...' : '开始上传' }}
</el-button>
<el-button @click="clearFiles" :disabled="fileList.length === 0">
清空列表
</el-button>
</div>
</el-card>
</div>
<!-- 上传进度 -->
<div class="upload-progress" v-if="uploading">
<el-card header="上传进度">
<el-progress
:percentage="uploadProgress"
:status="uploadStatus"
:stroke-width="8"
/>
<div class="progress-info">
<span>已上传: {{ uploadedCount }}/{{ fileList.length }}</span>
<span>成功率: {{ successRate }}%</span>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadInstance, type UploadFile } from 'element-plus'
import { ArrowLeft, UploadFilled } from '@element-plus/icons-vue'
import { fileAPI } from '@/services/api'
const router = useRouter()
const uploadRef = ref<UploadInstance>()
//
const fileList = ref<UploadFile[]>([])
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadedCount = ref(0)
const successCount = ref(0)
//
const uploadForm = ref({
category: '文档',
isPublic: false,
overwrite: false
})
//
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL
return `${baseUrl}/api/files`
})
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return {
Authorization: `Bearer ${token}`
}
})
const uploadStatus = computed(() => {
if (uploadProgress.value === 100) return 'success'
if (uploading.value) return undefined
return 'exception'
})
const successRate = computed(() => {
if (uploadedCount.value === 0) return 0
return Math.round((successCount.value / uploadedCount.value) * 100)
})
//
const beforeUpload = (file: File) => {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
ElMessage.error(`文件 "${file.name}" 大小不能超过 10MB!`)
return false
}
//
const exists = fileList.value.some(f => f.name === file.name)
if (exists && !uploadForm.value.overwrite) {
ElMessage.warning(`文件 "${file.name}" 已存在,请启用覆盖选项或重命名文件`)
return false
}
return true
}
const handleFileChange = (file: UploadFile, fileList: UploadFile[]) => {
fileList.value = fileList
}
const handleFileRemove = (file: UploadFile, fileList: UploadFile[]) => {
fileList.value = fileList
}
const removeFile = (index: number) => {
fileList.value.splice(index, 1)
}
const clearFiles = () => {
fileList.value = []
uploadProgress.value = 0
uploadedCount.value = 0
successCount.value = 0
}
const submitUpload = async () => {
if (fileList.value.length === 0) {
ElMessage.warning('请先选择要上传的文件')
return
}
try {
uploading.value = true
uploadProgress.value = 0
uploadedCount.value = 0
successCount.value = 0
//
const totalFiles = fileList.value.length
let currentProgress = 0
for (let i = 0; i < fileList.value.length; i++) {
const file = fileList.value[i]
try {
// API
// 使
await new Promise(resolve => setTimeout(resolve, 1000))
successCount.value++
ElMessage.success(`文件 "${file.name}" 上传成功`)
} catch (error) {
console.error(`文件 "${file.name}" 上传失败:`, error)
ElMessage.error(`文件 "${file.name}" 上传失败`)
}
uploadedCount.value++
currentProgress = Math.round((uploadedCount.value / totalFiles) * 100)
uploadProgress.value = currentProgress
}
if (successCount.value === totalFiles) {
ElMessage.success('所有文件上传完成')
} else {
ElMessage.warning(`上传完成,成功 ${successCount.value}/${totalFiles} 个文件`)
}
} catch (error) {
console.error('批量上传失败:', error)
ElMessage.error('批量上传失败')
} finally {
uploading.value = false
}
}
const handleUploadSuccess = (response: any, file: UploadFile) => {
if (response.success) {
file.status = 'success'
} else {
file.status = 'fail'
ElMessage.error(response.message || '上传失败')
}
}
const handleUploadError = (error: any, file: UploadFile) => {
console.error('上传失败:', error)
file.status = 'fail'
ElMessage.error('上传失败')
}
const getStatusType = (status: string) => {
switch (status) {
case 'success': return 'success'
case 'uploading': return 'warning'
case 'fail': return 'danger'
default: return 'info'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'success': return '成功'
case 'uploading': return '上传中'
case 'fail': return '失败'
default: return '等待上传'
}
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.upload-container {
padding: 20px;
background: #fff;
min-height: calc(100vh - 120px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.upload-area {
margin-bottom: 20px;
}
.upload-settings {
margin-bottom: 20px;
}
.file-preview {
margin-bottom: 20px;
}
.upload-actions {
margin-top: 20px;
text-align: center;
}
.upload-progress {
margin-bottom: 20px;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 14px;
color: #666;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #999;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 180px;
}
</style>

View File

@ -0,0 +1,548 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>菜单管理</h2>
<el-button type="primary" @click="handleAddMenu = true">
<el-icon><Plus /></el-icon>
添加菜单
</el-button>
</div>
<el-divider></el-divider>
<!-- 树形表格 -->
<el-table
:data="menuTree"
style="width: 100%"
row-key="Id"
border
:tree-props="{
children: 'children',
hasChildren: 'hasChildren',
}"
:expand-row-keys="defaultExpandedKeys"
>
<el-table-column prop="Name" label="菜单名称" width="200">
<template #default="scope">
<div class="menu-item">
<i
v-if="scope.row.Icon"
:class="scope.row.Icon"
class="menu-icon"
></i>
<span>{{ scope.row.Name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="Path" label="路由地址"></el-table-column>
<el-table-column
prop="MenuType"
label="菜单类型"
width="120"
align="center"
>
<template #default="scope">
<el-tag :type="getMenuTypeTagType(scope.row.MenuType)">
{{ getMenuTypeName(scope.row.MenuType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="Order"
label="排序"
width="80"
align="center"
></el-table-column>
<el-table-column prop="Status" label="状态" width="100" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.Status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row)"
:disabled="!scope.row.Id"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button
size="small"
text
@click="handleAddSubMenu(scope.row)"
:disabled="scope.row.MenuType === 3"
>
<el-icon><CirclePlus /></el-icon>
<span>子菜单</span>
</el-button>
<el-button size="small" text @click="handleEditMenu(scope.row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button
size="small"
text
type="danger"
@click="handleDeleteMenu(scope.row)"
>
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加/编辑菜单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form
:model="currentMenu"
label-width="100px"
:rules="formRules"
ref="menuFormRef"
>
<el-form-item label="父级菜单" prop="ParentId">
<el-cascader
v-model="currentMenu.ParentId"
:options="parentMenuOptions"
:props="cascaderProps"
placeholder="请选择父级菜单"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单名称" prop="Name">
<el-input v-model="currentMenu.Name" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="MenuType">
<el-radio-group v-model="currentMenu.MenuType" style="width: 100%">
<el-radio-button :label="1">普通菜单</el-radio-button>
<el-radio-button :label="2">分组菜单</el-radio-button>
<el-radio-button :label="3">按钮菜单</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
label="路由地址"
prop="Path"
v-if="currentMenu.MenuType !== 3"
>
<el-input v-model="currentMenu.Path" placeholder="例如:/system" />
</el-form-item>
<el-form-item
label="组件路径"
prop="ComponentPath"
v-if="currentMenu.MenuType === 1"
>
<el-input
v-model="currentMenu.ComponentPath"
placeholder="例如:@/views/settings/index.vue"
/>
</el-form-item>
<el-form-item label="图标" prop="Icon">
<el-input
v-model="currentMenu.Icon"
placeholder="例如fas fa-tachometer-alt"
/>
</el-form-item>
<el-form-item label="排序" prop="Order">
<el-input-number
v-model="currentMenu.Order"
:min="0"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="权限标识" prop="Permission">
<el-input
v-model="currentMenu.Permission"
placeholder="请输入权限标识"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveMenu">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
import { Plus, CirclePlus, Edit, Delete } from "@element-plus/icons-vue";
import { getAllMenus, updateMenuStatus, createMenu, updateMenu, deleteMenu } from "@/api/menu";
// Pascal
interface Menu {
Id: number;
Name: string;
Path: string;
ParentId: number;
Icon: string;
Order: number;
Status: 0 | 1;
ComponentPath: string;
IsExternal: 0 | 1;
ExternalUrl: string;
MenuType: 1 | 2 | 3; // 1: 2: 3:
Permission: string;
CreateTime: string;
UpdateTime: string;
children?: Menu[];
hasChildren?: boolean;
}
//
const menuTree = ref<Menu[]>([]);
// ID
const defaultExpandedKeys = ref<number[]>([]);
//
const dialogVisible = ref(false);
const dialogTitle = ref("");
const menuFormRef = ref<InstanceType<typeof ElForm>>();
//
const currentMenu = ref<Partial<Menu>>({
Id: 0,
Name: "",
Path: "",
ParentId: 0,
Icon: "",
Order: 0,
Status: 1,
ComponentPath: "",
IsExternal: 0,
ExternalUrl: "",
MenuType: 1,
Permission: "",
});
//
const formRules = ref({
Name: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
MenuType: [{ required: true, message: "请选择菜单类型", trigger: "change" }],
Order: [{ required: true, message: "请输入排序号", trigger: "blur" }],
});
// Pascal
const cascaderProps = ref({
value: "Id",
label: "Name",
children: "children",
checkStrictly: true,
emitPath: false,
});
//
const parentMenuOptions = ref<Menu[]>([]);
//
const fetchMenus = async () => {
try {
// 使 getAllMenus Pascal
const result = await getAllMenus();
if (result.success) {
// getAllMenusdatakey线Pascal
const data = result.data.map((item: any) => ({
Id: item.id,
Name: item.name,
Path: item.path,
ParentId: item.parentId,
Icon: item.icon,
Order: item.order,
Status: 1, // Status=1
ComponentPath: item.componentPath || "",
IsExternal: item.isExternal || 0,
ExternalUrl: item.externalUrl || "",
MenuType: 1, // MenuType1
Permission: "", //
CreateTime: "",
UpdateTime: "",
}));
const tree = buildMenuTree(data);
menuTree.value = tree;
// ParentId=0
defaultExpandedKeys.value = tree
.filter((menu) => menu.ParentId === 0)
.map((menu) => menu.Id);
//
parentMenuOptions.value = [
{
Id: 0,
Name: "顶级菜单",
MenuType: 1,
children: [],
} as Menu,
...tree,
];
} else {
ElMessage.error("获取菜单失败: " + result.message);
}
} catch (error) {
ElMessage.error("获取菜单数据失败: " + (error as Error).message);
}
};
//
const buildMenuTree = (menuList: Menu[]): Menu[] => {
const menuMap = new Map<number, Menu>();
// childrenhasChildren
menuList.forEach((menu) => {
menuMap.set(menu.Id, { ...menu, children: [], hasChildren: false });
});
//
const tree: Menu[] = [];
menuList.forEach((menu) => {
const current = menuMap.get(menu.Id)!;
if (menu.ParentId === 0) {
//
tree.push(current);
} else {
//
const parent = menuMap.get(menu.ParentId);
if (parent) {
parent.children?.push(current);
parent.hasChildren = true; //
}
}
});
return tree;
};
//
const getMenuTypeName = (type: number) => {
const typeMap = { 1: "普通菜单", 2: "分组菜单", 3: "按钮菜单" };
return typeMap[type as keyof typeof typeMap] || "未知类型";
};
//
const getMenuTypeTagType = (type: number) => {
const typeMap = { 1: "primary", 2: "success", 3: "info" };
return typeMap[type as keyof typeof typeMap] || "default";
};
//
const handleStatusChange = async (menu: Menu) => {
try {
const result = await updateMenuStatus(menu.Id, menu.Status);
if (!result.success) {
ElMessage.error(result.message);
//
menu.Status = menu.Status === 1 ? 0 : 1;
}
} catch (error) {
ElMessage.error("更新状态失败: " + (error as Error).message);
menu.Status = menu.Status === 1 ? 0 : 1;
}
};
//
const handleAddMenu = () => {
dialogTitle.value = "添加菜单";
currentMenu.value = {
Id: 0,
Name: "",
Path: "",
ParentId: 0,
Icon: "",
Order: 0,
Status: 1,
ComponentPath: "",
IsExternal: 0,
ExternalUrl: "",
MenuType: 1,
Permission: "",
};
dialogVisible.value = true;
};
//
const handleAddSubMenu = (parentMenu: Menu) => {
dialogTitle.value = `添加子菜单 (父菜单: ${parentMenu.Name})`;
currentMenu.value = {
Id: 0,
Name: "",
Path: "",
ParentId: parentMenu.Id,
Icon: "",
Order: 0,
Status: 1,
ComponentPath: "",
IsExternal: 0,
ExternalUrl: "",
MenuType: parentMenu.MenuType === 2 ? 1 : parentMenu.MenuType,
Permission: "",
};
dialogVisible.value = true;
};
//
const handleEditMenu = (menu: Menu) => {
dialogTitle.value = "编辑菜单";
currentMenu.value = { ...menu };
dialogVisible.value = true;
};
//
const handleDeleteMenu = (menu: Menu) => {
ElMessageBox.confirm(
`确定要删除菜单 "${menu.Name}" 吗?${menu.hasChildren ? "其下所有子菜单也将被删除。" : ""}`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
try {
const result = await deleteMenu(menu.Id);
if (result.success) {
ElMessage.success("删除成功");
fetchMenus();
} else {
ElMessage.error("删除失败: " + result.message);
}
} catch (error) {
ElMessage.error("删除失败: " + (error as Error).message);
}
});
};
//
const saveMenu = async () => {
//
if (!menuFormRef.value) return;
const valid = await menuFormRef.value.validate();
if (!valid) return;
try {
if (currentMenu.value.Id === 0) {
//
const result = await createMenu(currentMenu.value as Menu);
if (result.success) {
ElMessage.success("菜单添加成功");
dialogVisible.value = false;
fetchMenus();
} else {
ElMessage.error("添加失败: " + result.message);
}
} else {
//
const result = await updateMenu(
currentMenu.value.Id!,
currentMenu.value as Menu
);
if (result.success) {
ElMessage.success("更新成功");
dialogVisible.value = false;
fetchMenus();
} else {
ElMessage.error("更新失败: " + result.message);
}
}
} catch (error) {
ElMessage.error("操作失败: " + (error as Error).message);
}
};
//
onMounted(() => {
fetchMenus();
});
</script>
<style lang="less" scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f2f3f5;
}
.card-header span {
font-size: 18px;
font-weight: 500;
}
/* 表格核心样式 */
:deep(.el-table) {
border-radius: 0;
width: 100% !important;
}
:deep(.el-table__body td) {
padding: 12px 0;
vertical-align: middle;
}
/* 展开图标与菜单内容对齐 */
:deep(.el-table__expand-icon) {
margin: 0 !important;
vertical-align: middle;
}
:deep(.el-table__expand-icon-cell) {
padding: 0 8px !important;
}
/* 菜单项样式 */
.menu-item {
display: inline-flex;
align-items: center;
gap: 8px;
vertical-align: middle;
}
.menu-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* 隐藏无子女菜单的展开图标 */
:deep(.el-table__expand-icon--hidden) {
visibility: hidden;
width: 24px;
}
:deep(.el-table__expand-icon) {
margin-right: 8px !important;
vertical-align: middle;
}
/* 对话框样式精简 */
:deep(.el-dialog__body) {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
:deep(.el-form-item) {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>权限管理</h2>
<el-button type="primary" @click="handleAddPermission = true">
<el-icon><Plus /></el-icon>
添加权限
</el-button>
</div>
<el-divider></el-divider>
<el-table :data="permissions" style="width: 100%">
<el-table-column prop="name" label="权限名称" width="180" align="center" />
<el-table-column prop="code" label="权限代码" width="200" align="center" />
<el-table-column prop="description" label="权限描述" align="center" />
<el-table-column prop="type" label="权限类型" width="120" align="center">
<template #default="scope">
<el-tag :type="scope.row.type === 'menu' ? 'primary' : 'warning'">
{{ scope.row.type === "menu" ? "菜单权限" : "功能权限" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button size="small" type="danger" @click="handleDelete(scope.row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
// const loading = ref(false);
// const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
interface Permission {
id: number;
name: string;
code: string;
description: string;
type: string;
}
const permissions = ref<Permission[]>([
{
id: 1,
name: "用户管理",
code: "user:manage",
description: "用户的增删改查权限",
type: "menu",
},
{
id: 2,
name: "角色管理",
code: "role:manage",
description: "角色的增删改查权限",
type: "menu",
},
{
id: 3,
name: "数据导出",
code: "data:export",
description: "数据导出功能权限",
type: "function",
},
]);
const handleAddPermission = () => {
console.log("添加权限");
};
const handleEdit = (permission: Permission) => {
console.log("编辑权限:", permission);
};
const handleDelete = (permission: Permission) => {
console.log("删除权限:", permission);
};
</script>
<style scoped>
.permissions-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -0,0 +1,245 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>程序管理</h2>
<el-button type="primary" @click="showProgramDialog = true">
<el-icon><Plus /></el-icon>
添加程序
</el-button>
</div>
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载程序数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchPrograms">重试</el-button>
</div>
<!-- 程序列表 -->
<div v-else>
<el-table :data="programs" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="程序名称" min-width="160" align="center" />
<el-table-column prop="type" label="类型" width="100" align="center" />
<el-table-column prop="owner" label="负责人" width="120" align="center" />
<el-table-column prop="createdAt" label="创建时间" width="170" align="center" />
<el-table-column prop="remark" label="备注" min-width="140" align="center" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
<!-- 新增/编辑程序弹窗 -->
<el-dialog
:title="isEditing ? '编辑程序' : '添加程序'"
v-model="showProgramDialog"
width="420px"
:close-on-click-modal="false"
>
<el-form
:model="programForm"
:rules="formRules"
ref="programFormRef"
label-width="90px"
>
<el-form-item label="程序名称" prop="name">
<el-input v-model="programForm.name" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="programForm.type" placeholder="选择类型">
<el-option label="Web" value="Web" />
<el-option label="服务" value="Service" />
<el-option label="工具" value="Tool" />
<el-option label="其他" value="Other" />
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="owner">
<el-input v-model="programForm.owner" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="programForm.remark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showProgramDialog = false">取消</el-button>
<el-button type="primary" @click="submitProgramForm"> 保存 </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
} from "element-plus";
import { Plus } from "@element-plus/icons-vue";
//
const programs = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const showProgramDialog = ref(false);
const isEditing = ref(false);
const programForm = reactive({
id: null,
name: "",
type: "",
owner: "",
remark: "",
});
const programFormRef = ref<FormInstance>();
const formRules: FormRules = {
name: [
{ required: true, message: "请输入程序名称", trigger: "blur" },
{ min: 2, max: 32, message: "名称长度2-32字符", trigger: "blur" },
],
type: [{ required: true, message: "请选择类型", trigger: "change" }],
owner: [{ required: true, message: "请输入负责人", trigger: "blur" }],
};
async function fetchPrograms() {
loading.value = true;
error.value = "";
try {
// TODO: API
await new Promise((r) => setTimeout(r, 250));
//
const all = [
{
id: 1,
name: "门户网站",
type: "Web",
owner: "张三",
createdAt: "2023-11-08 09:22:53",
remark: "官网",
},
{
id: 2,
name: "自动备份服务",
type: "Service",
owner: "李四",
createdAt: "2024-01-16 15:40:01",
remark: "每日凌晨自动执行",
},
{
id: 3,
name: "运维工具",
type: "Tool",
owner: "王五",
createdAt: "2023-12-02 12:51:29",
remark: "",
},
// ...
];
total.value = all.length;
programs.value = all.slice(
(page.value - 1) * pageSize.value,
page.value * pageSize.value
);
} catch (err: any) {
error.value = err.message || "获取程序列表失败";
} finally {
loading.value = false;
}
}
const handlePageChange = (val: number) => {
page.value = val;
fetchPrograms();
};
function resetProgramForm() {
programForm.id = null;
programForm.name = "";
programForm.type = "";
programForm.owner = "";
programForm.remark = "";
}
function handleEdit(row: any) {
isEditing.value = true;
programForm.id = row.id;
programForm.name = row.name;
programForm.type = row.type;
programForm.owner = row.owner;
programForm.remark = row.remark;
showProgramDialog.value = true;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除程序「${row.name}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
);
// TODO: API
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("删除成功");
fetchPrograms();
} catch {
//
}
}
async function submitProgramForm() {
await programFormRef.value?.validate();
loading.value = true;
try {
if (isEditing.value) {
// TODO: API
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("程序更新成功");
} else {
// TODO: API
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("程序添加成功");
}
showProgramDialog.value = false;
fetchPrograms();
} catch (e: any) {
ElMessage.error(e.message || "操作失败,请重试");
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchPrograms();
});
</script>
<style scoped>
</style>

View File

@ -0,0 +1,290 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>角色管理</h2>
<div class="header-actions">
<el-button type="primary" @click="showRoleDialog = true">
<el-icon><Plus /></el-icon>
添加角色
</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载角色数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchRoles">重试</el-button>
</div>
<!-- 角色列表 -->
<div v-else>
<el-table :data="roles" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="label" label="角色名称" align="center" />
<el-table-column prop="value" label="角色标识" align="center" />
<el-table-column prop="remark" label="备注" align="center" />
<el-table-column label="操作" width="180" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
<!-- 新增/编辑角色弹窗 -->
<el-dialog
:title="isEditing ? '编辑角色' : '添加角色'"
v-model="showRoleDialog"
width="420px"
:close-on-click-modal="false"
>
<el-form
:model="roleForm"
:rules="formRules"
ref="roleFormRef"
label-width="90px"
>
<el-form-item label="角色名称" prop="label">
<el-input
v-model="roleForm.label"
:disabled="isEditing && roleForm.value === 'admin'"
/>
</el-form-item>
<el-form-item label="角色标识" prop="value">
<el-input
v-model="roleForm.value"
:disabled="isEditing && roleForm.value === 'admin'"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="roleForm.remark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showRoleDialog = false">取消</el-button>
<el-button type="primary" @click="submitRoleForm"> 保存 </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
} from "element-plus";
const roles = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const showRoleDialog = ref(false);
const isEditing = ref(false);
const roleForm = reactive({
id: null,
label: "",
value: "",
remark: "",
});
const roleFormRef = ref<FormInstance>();
const formRules: FormRules = {
label: [
{ required: true, message: "请输入角色名称", trigger: "blur" },
{ min: 2, max: 24, message: "名称长度2-24字符", trigger: "blur" },
],
value: [
{ required: true, message: "请输入角色标识", trigger: "blur" },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: "只能包含字母、数字或下划线",
trigger: "blur",
},
],
};
async function fetchRoles() {
loading.value = true;
error.value = "";
try {
// TODO: API
await new Promise((r) => setTimeout(r, 300));
//
const all = [
{ id: 1, label: "系统管理员", value: "admin", remark: "拥有全部权限" },
{ id: 2, label: "普通用户", value: "user", remark: "普通访问权限" },
{ id: 3, label: "运营", value: "ops", remark: "运营相关权限" },
// ...
];
total.value = all.length;
roles.value = all.slice(
(page.value - 1) * pageSize.value,
page.value * pageSize.value
);
} catch (err: any) {
error.value = err.message || "获取角色列表失败";
} finally {
loading.value = false;
}
}
const handlePageChange = (val: number) => {
page.value = val;
fetchRoles();
};
function resetRoleForm() {
roleForm.id = null;
roleForm.label = "";
roleForm.value = "";
roleForm.remark = "";
}
function handleEdit(row: any) {
isEditing.value = true;
roleForm.id = row.id;
roleForm.label = row.label;
roleForm.value = row.value;
roleForm.remark = row.remark;
showRoleDialog.value = true;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除角色「${row.label}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
);
// TODO: API
await new Promise((r) => setTimeout(r, 300));
ElMessage.success("删除成功");
fetchRoles();
} catch {
//
}
}
async function submitRoleForm() {
//
await roleFormRef.value?.validate();
loading.value = true;
try {
if (isEditing.value) {
// TODO: API
await new Promise((r) => setTimeout(r, 300));
ElMessage.success("角色更新成功");
} else {
// TODO: API
await new Promise((r) => setTimeout(r, 300));
ElMessage.success("角色添加成功");
}
showRoleDialog.value = false;
fetchRoles();
} catch (e: any) {
ElMessage.error(e.message || "操作失败,请重试");
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchRoles();
});
</script>
<style scoped>
.role-management-module {
box-sizing: border-box;
background: #fff;
padding: 28px 22px 14px 18px;
min-height: 500px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.03);
}
.header-bar h2 {
font-size: 1.18rem;
font-weight: 600;
margin: 0;
color: #24292f;
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px 0 16px 0;
background: #f5f7fa;
border-radius: 5px;
}
.loading-spinner {
width: 36px;
height: 36px;
border: 4px solid #e5e5e5;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
margin-bottom: 14px;
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
.el-dialog__body {
padding-top: 18px !important;
}
.el-form .el-form-item {
margin-bottom: 16px;
}
.el-form .el-input {
width: 100%;
}
@media (max-width: 600px) {
.role-management-module {
padding: 10px 4px;
min-height: 300px;
}
.header-bar {
flex-direction: column;
align-items: stretch;
row-gap: 8px;
padding-bottom: 2px;
}
}
</style>

View File

@ -0,0 +1,646 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>租户管理</h2>
<div class="header-actions">
<el-button @click="fetchTenants" title="刷新租户列表" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button type="primary" @click="showAddTenantDialog = true">
<el-icon><Plus /></el-icon>
添加租户
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载租户数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchTenants">重试</el-button>
</div>
<!-- 租户列表 -->
<div v-else>
<el-table
:data="tenants"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="租户名称" min-width="120" />
<el-table-column prop="code" label="租户编码" width="120" />
<el-table-column prop="owner" label="负责人" width="100" />
<el-table-column prop="created_at" label="创建时间" width="150" />
<el-table-column label="审核状态" width="100">
<template #default="{ row }">
<el-tag :type="getAuditStatusType(row.audit_status)">
{{ getAuditStatusText(row.audit_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'">
{{ row.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleView(row)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button
size="small"
type="success"
v-if="row.audit_status === 'pending'"
@click="handleAuditDialog(row)"
>
<el-icon><Check /></el-icon>
审核
</el-button>
<el-button
size="small"
@click="handleEdit(row)"
v-if="row.audit_status !== 'approved'"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(row)"
v-if="row.audit_status !== 'approved'"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
<!-- 租户详情查看弹窗 -->
<el-dialog
title="租户详情"
v-model="showViewDialog"
width="600px"
:close-on-click-modal="false"
>
<div class="tenant-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="租户ID">
{{ currentTenant.id }}
</el-descriptions-item>
<el-descriptions-item label="租户名称">
{{ currentTenant.name }}
</el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ currentTenant.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ currentTenant.owner }}
</el-descriptions-item>
<el-descriptions-item label="联系电话">
{{ currentTenant.phone || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ currentTenant.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="审核状态">
<el-tag :type="getAuditStatusType(currentTenant.audit_status)">
{{ getAuditStatusText(currentTenant.audit_status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentTenant.status === 'enabled' ? 'success' : 'info'">
{{ currentTenant.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ currentTenant.created_at }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ currentTenant.updated_at || '未更新' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ currentTenant.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="showViewDialog = false">关闭</el-button>
</template>
</el-dialog>
<!-- 审核弹窗 -->
<el-dialog
title="租户审核"
v-model="showAuditDialog"
width="700px"
:close-on-click-modal="false"
>
<div class="audit-content">
<!-- 租户信息展示 -->
<div class="tenant-info-section">
<h3>租户信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="租户ID">
{{ currentTenant.id }}
</el-descriptions-item>
<el-descriptions-item label="租户名称">
{{ currentTenant.name }}
</el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ currentTenant.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ currentTenant.owner }}
</el-descriptions-item>
<el-descriptions-item label="联系电话">
{{ currentTenant.phone || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ currentTenant.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentTenant.status === 'enabled' ? 'success' : 'info'">
{{ currentTenant.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ currentTenant.created_at }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ currentTenant.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 审核表单 -->
<div class="audit-form-section">
<h3>审核信息</h3>
<el-form :model="auditForm" :rules="auditRules" ref="auditFormRef" label-width="100px">
<el-form-item label="审核结果" prop="result">
<el-radio-group v-model="auditForm.result">
<el-radio value="approved">通过</el-radio>
<el-radio value="rejected">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见" prop="comment">
<el-input
v-model="auditForm.comment"
type="textarea"
:rows="4"
placeholder="请输入审核意见"
/>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<el-button @click="showAuditDialog = false">取消</el-button>
<el-button type="primary" @click="submitAudit">提交审核</el-button>
</template>
</el-dialog>
<!-- 新增/编辑租户弹窗 -->
<el-dialog
:title="isEditing ? '编辑租户' : '添加租户'"
v-model="showAddTenantDialog"
width="450px"
:close-on-click-modal="false"
>
<el-form :model="tenantForm" :rules="formRules" ref="tenantFormRef" label-width="90px">
<el-form-item label="租户名称" prop="name">
<el-input v-model="tenantForm.name" />
</el-form-item>
<el-form-item label="租户编码" prop="code">
<el-input v-model="tenantForm.code" />
</el-form-item>
<el-form-item label="负责人" prop="owner">
<el-input v-model="tenantForm.owner" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="tenantForm.phone" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="tenantForm.email" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="tenantForm.status">
<el-option label="启用" value="enabled" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="tenantForm.remark" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddTenantDialog = false">取消</el-button>
<el-button type="primary" @click="submitTenantForm">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus, Check, View, Edit, Delete } from "@element-plus/icons-vue";
import {
getAllTenants,
createTenant,
updateTenant,
deleteTenant,
auditTenant,
getTenantDetail,
} from "@/api/tenant";
const tenants = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
//
const showAddTenantDialog = ref(false);
const showViewDialog = ref(false);
const showAuditDialog = ref(false);
const isEditing = ref(false);
//
const currentTenant = ref<any>({});
//
const auditForm = reactive({
result: '',
comment: ''
});
const auditFormRef = ref<FormInstance>();
//
const tenantForm = reactive({
id: null,
name: "",
code: "",
owner: "",
phone: "",
email: "",
status: "enabled",
remark: ""
});
const tenantFormRef = ref<FormInstance>();
const formRules: FormRules = {
name: [{ required: true, message: "请输入租户名称", trigger: "blur" }],
code: [{ required: true, message: "请输入租户编码", trigger: "blur" }],
owner: [{ required: true, message: "请输入负责人", trigger: "blur" }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号", trigger: "blur" }],
email: [{ type: "email", message: "请输入正确的邮箱", trigger: "blur" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }]
};
const auditRules: FormRules = {
result: [{ required: true, message: "请选择审核结果", trigger: "change" }],
comment: [{ required: true, message: "请输入审核意见", trigger: "blur" }]
};
async function fetchTenants() {
loading.value = true;
error.value = "";
try {
const res = await getAllTenants();
// : { success: true, message: "...", data: [...] }
if (res.success && res.data) {
const allTenants = Array.isArray(res.data) ? res.data : [];
// create_time created_at
tenants.value = allTenants.map((tenant: any) => ({
...tenant,
created_at: tenant.create_time || tenant.created_at,
updated_at: tenant.update_time || tenant.updated_at,
}));
total.value = tenants.value.length;
} else {
error.value = res.message || "获取租户列表失败";
tenants.value = [];
total.value = 0;
}
} catch (err: any) {
error.value = err.message || "获取租户列表失败";
tenants.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
const handlePageChange = (val: number) => {
page.value = val;
fetchTenants();
};
//
function getAuditStatusType(status: string): 'warning' | 'success' | 'danger' | 'info' {
const statusMap: Record<string, 'warning' | 'success' | 'danger' | 'info'> = {
pending: 'warning',
approved: 'success',
rejected: 'danger'
};
return statusMap[status] || 'info';
}
function getAuditStatusText(status: string) {
const statusMap: Record<string, string> = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝'
};
return statusMap[status] || '未知';
}
//
async function handleView(row: any) {
try {
loading.value = true;
const res = await getTenantDetail(row.id);
if (res.success && res.data) {
const tenant = res.data;
currentTenant.value = {
...tenant,
created_at: tenant.create_time || tenant.created_at,
updated_at: tenant.update_time || tenant.updated_at,
};
showViewDialog.value = true;
} else {
ElMessage.error(res.message || "获取租户详情失败");
}
} catch (err: any) {
ElMessage.error(err.message || "获取租户详情失败");
} finally {
loading.value = false;
}
}
//
function handleAuditDialog(row: any) {
currentTenant.value = { ...row };
//
auditForm.result = '';
auditForm.comment = '';
showAuditDialog.value = true;
}
//
async function submitAudit() {
await auditFormRef.value?.validate();
loading.value = true;
try {
const auditData = {
audit_status: auditForm.result,
audit_comment: auditForm.comment,
audit_by: getCurrentUser(),
};
const res = await auditTenant(currentTenant.value.id, auditData);
if (res.success) {
const action = auditForm.result === 'approved' ? '通过' : '拒绝';
ElMessage.success(`审核${action}成功`);
showAuditDialog.value = false;
//
await fetchTenants();
} else {
ElMessage.error(res.message || "审核失败,请重试");
}
} catch (e: any) {
ElMessage.error(e.message || "审核失败,请重试");
} finally {
loading.value = false;
}
}
//
function getCurrentUser() {
const userStr = localStorage.getItem("user");
if (userStr) {
try {
const user = JSON.parse(userStr);
return user.username || user.name || user.userName || "system";
} catch {
return "system";
}
}
return "system";
}
function resetTenantForm() {
tenantForm.id = null;
tenantForm.name = "";
tenantForm.code = "";
tenantForm.owner = "";
tenantForm.phone = "";
tenantForm.email = "";
tenantForm.status = "enabled";
tenantForm.remark = "";
}
function handleEdit(row: any) {
isEditing.value = true;
tenantForm.id = row.id;
tenantForm.name = row.name;
tenantForm.code = row.code;
tenantForm.owner = row.owner;
tenantForm.phone = row.phone || "";
tenantForm.email = row.email || "";
tenantForm.status = row.status;
tenantForm.remark = row.remark || "";
showAddTenantDialog.value = true;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除租户「${row.name}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
);
loading.value = true;
try {
const res = await deleteTenant(row.id);
if (res.success) {
ElMessage.success("删除成功");
await fetchTenants();
} else {
ElMessage.error(res.message || "删除失败");
}
} catch (err: any) {
ElMessage.error(err.message || "删除失败");
} finally {
loading.value = false;
}
} catch {
//
}
}
function submitTenantForm() {
(tenantFormRef.value as FormInstance).validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
const tenantData = {
name: tenantForm.name,
code: tenantForm.code,
owner: tenantForm.owner,
phone: tenantForm.phone || "",
email: tenantForm.email || "",
status: tenantForm.status,
remark: tenantForm.remark || "",
};
let res;
if (isEditing.value && tenantForm.id) {
//
res = await updateTenant(tenantForm.id, tenantData);
} else {
//
res = await createTenant(tenantData);
}
if (res.success) {
if (isEditing.value) {
ElMessage.success("租户信息已更新");
} else {
ElMessage.success("租户添加成功");
}
showAddTenantDialog.value = false;
resetTenantForm();
isEditing.value = false;
await fetchTenants();
} else {
ElMessage.error(res.message || "操作失败,请重试");
}
} catch (err: any) {
ElMessage.error(err.message || "操作失败,请重试");
} finally {
loading.value = false;
}
});
}
onMounted(() => {
fetchTenants();
});
</script>
<style lang="less" scoped>
.tenant-management-module {
padding: 20px;
min-height: 600px;
background: var(--card-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--card-shadow);
transition: var(--transition-base);
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.loading-state, .error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
}
.loading-spinner {
width: 36px;
height: 36px;
border: 4px solid var(--border-color);
border-left-color: var(--primary-color);
border-radius: var(--border-radius-full);
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.tenant-detail {
padding: 16px 0;
}
.tenant-detail .el-descriptions {
margin-top: 0;
}
.tenant-detail .el-descriptions-item__label {
font-weight: 600;
color: var(--text-secondary);
}
.tenant-detail .el-descriptions-item__content {
color: var(--text-color);
}
.audit-content {
padding: 16px 0;
}
.tenant-info-section,
.audit-form-section {
margin-bottom: 24px;
}
.tenant-info-section h3,
.audit-form-section h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
border-bottom: 2px solid var(--primary-color);
padding-bottom: 8px;
}
.audit-form-section .el-form {
margin-top: 16px;
}
.audit-form-section .el-radio-group {
display: flex;
gap: 24px;
}
.audit-form-section .el-radio {
margin-right: 0;
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>用户管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAddUser = true">
<el-icon><Plus /></el-icon>
添加用户
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-table :data="users" style="width: 100%">
<el-table-column
prop="username"
label="用户名"
width="150"
align="center"
/>
<el-table-column
prop="email"
label="邮箱"
align="center"
min-width="200"
/>
<el-table-column prop="role" label="角色" width="120" align="center">
<template #default="scope">
<el-tag :type="scope.row.role === 'admin' ? 'danger' : 'primary'">
{{ scope.row.role === "admin" ? "管理员" : "普通用户" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag
:type="scope.row.status === 'active' ? 'success' : 'danger'"
>
{{ scope.row.status === "active" ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="lastLogin"
label="最后登录"
width="180"
align="center"
/>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface User {
id: number;
username: string;
email: string;
role: string;
status: string;
lastLogin: string;
}
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const users = ref<User[]>([
{
id: 1,
username: "admin",
email: "admin@example.com",
role: "admin",
status: "active",
lastLogin: "2025-01-20 10:30:00",
},
{
id: 2,
username: "user1",
email: "user1@example.com",
role: "user",
status: "active",
lastLogin: "2025-01-19 15:45:00",
},
{
id: 3,
username: "user2",
email: "user2@example.com",
role: "user",
status: "inactive",
lastLogin: "2025-01-18 09:20:00",
},
]);
const handleAddUser = () => {
console.log("添加用户");
};
const handleEdit = (user: User) => {
console.log("编辑用户:", user);
};
const handleDelete = (user: User) => {
console.log("删除用户:", user);
};
</script>
<style lang="less" scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 1.4rem;
font-weight: 700;
}
}
</style>

View File

@ -16,19 +16,21 @@ type AuthController struct {
beego.Controller
}
// Login 处理登录请求
// Login 处理登录请求(支持租户模式,使用租户名称)
func (c *AuthController) Login() {
var username, password string
var username, password, tenantName string
// 优先尝试从URL参数获取Apifox测试方式
username = c.GetString("username")
password = c.GetString("password")
tenantName = c.GetString("tenant_name")
// 如果URL参数为空尝试从JSON请求体获取前端方式
if username == "" || password == "" {
if username == "" || password == "" || tenantName == "" {
var loginData struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
TenantName string `json:"tenant_name"`
}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &loginData)
@ -44,31 +46,43 @@ func (c *AuthController) Login() {
username = loginData.Username
password = loginData.Password
tenantName = loginData.TenantName
}
// 验证参数
if tenantName == "" {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "租户名称不能为空",
"data": nil,
}
c.ServeJSON()
return
}
// 添加日志调试
fmt.Println("接收到的登录请求:")
fmt.Println("用户名:", username)
fmt.Println("密码:", password)
fmt.Println("租户名称:", tenantName)
// 验证用户
fmt.Println("开始验证用户:", username)
user, err := models.ValidateUser(username, password)
// 验证用户(先验证租户,再验证租户下的用户)
fmt.Println("开始验证用户:", username, "租户:", tenantName)
user, err := models.ValidateUser(username, password, tenantName)
fmt.Println("验证结果:", err)
if user != nil {
fmt.Println("用户信息ID=", user.Id, "Username=", user.Username, "Salt=", user.Salt)
fmt.Println("用户信息ID=", user.Id, "Username=", user.Username, "TenantId=", user.TenantId)
}
if err != nil {
// 登录失败
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "用户名或密码错误",
"message": err.Error(),
"data": nil,
}
} else {
// 使用models包中的GenerateToken函数生成token
tokenString, err := models.GenerateToken(user.Id, user.Username)
// 使用models包中的GenerateToken函数生成token包含租户ID
tokenString, err := models.GenerateToken(user.Id, user.Username, user.TenantId)
if err != nil {
c.Data["json"] = map[string]interface{}{
@ -85,11 +99,12 @@ func (c *AuthController) Login() {
"accessToken": tokenString,
"token": tokenString, // 兼容性
"user": map[string]interface{}{
"id": user.Id,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"nickname": user.Nickname,
"id": user.Id,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"nickname": user.Nickname,
"tenant_id": user.TenantId,
},
},
}
@ -99,14 +114,35 @@ func (c *AuthController) Login() {
c.ServeJSON()
}
// ResetPassword 重置用户密码
// ResetPassword 重置用户密码(支持租户模式)
func (c *AuthController) ResetPassword() {
// 获取请求参数
username := c.GetString("username")
superPassword := c.GetString("superPassword")
tenantId, _ := c.GetInt("tenant_id", 0)
// 如果URL参数中没有租户ID尝试从JSON请求体获取
if tenantId == 0 {
var resetData struct {
Username string `json:"username"`
SuperPassword string `json:"superPassword"`
TenantId int `json:"tenant_id"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &resetData); err == nil {
username = resetData.Username
superPassword = resetData.SuperPassword
tenantId = resetData.TenantId
}
}
if tenantId <= 0 {
c.Data["json"] = map[string]interface{}{"success": false, "message": "租户ID不能为空"}
c.ServeJSON()
return
}
// 调用模型方法
err := models.ResetPassword(username, superPassword)
err := models.ResetPassword(username, superPassword, tenantId)
if err != nil {
c.Data["json"] = map[string]interface{}{"success": false, "message": err.Error()}
@ -117,15 +153,38 @@ func (c *AuthController) ResetPassword() {
c.ServeJSON()
}
// ChangePassword 修改用户密码
// ChangePassword 修改用户密码(支持租户模式)
func (c *AuthController) ChangePassword() {
// 获取请求参数
username := c.GetString("username")
oldPassword := c.GetString("oldPassword")
newPassword := c.GetString("newPassword")
tenantId, _ := c.GetInt("tenant_id", 0)
// 如果URL参数中没有租户ID尝试从JSON请求体获取
if tenantId == 0 {
var changeData struct {
Username string `json:"username"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
TenantId int `json:"tenant_id"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &changeData); err == nil {
username = changeData.Username
oldPassword = changeData.OldPassword
newPassword = changeData.NewPassword
tenantId = changeData.TenantId
}
}
if tenantId <= 0 {
c.Data["json"] = map[string]interface{}{"success": false, "message": "租户ID不能为空"}
c.ServeJSON()
return
}
// 调用模型方法
err := models.ChangePassword(username, oldPassword, newPassword)
err := models.ChangePassword(username, oldPassword, newPassword, tenantId)
if err != nil {
c.Data["json"] = map[string]interface{}{"success": false, "message": err.Error()}
} else {
@ -144,9 +203,11 @@ func (c *AuthController) Logout() {
c.ServeJSON()
}
// FindAllUsers 获取所有用户
// FindAllUsers 获取所有用户(支持按租户过滤)
func (c *AuthController) FindAllUsers() {
users := models.FindAllUsers()
// 从查询参数获取租户ID可选
tenantId, _ := c.GetInt("tenant_id", 0)
users := models.FindAllUsers(tenantId)
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "获取用户列表成功",
@ -155,10 +216,12 @@ func (c *AuthController) FindAllUsers() {
c.ServeJSON()
}
// GetUserByUsername 通过用户名查询用户信息
// GetUserByUsername 通过用户名查询用户信息(支持租户模式)
func (c *AuthController) GetUserByUsername() {
// 获取请求参数中的用户名
// 获取请求参数中的用户名和租户ID
username := c.GetString("username")
tenantId, _ := c.GetInt("tenant_id", 0)
if username == "" {
c.Data["json"] = map[string]interface{}{
"code": 1,
@ -169,8 +232,18 @@ func (c *AuthController) GetUserByUsername() {
return
}
if tenantId <= 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "租户ID不能为空",
"data": nil,
}
c.ServeJSON()
return
}
// 调用模型层方法查询用户
user, err := models.GetUserByUsername(username) // 假设models层有这个方法
user, err := models.GetUserByUsername(username, tenantId)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
@ -188,19 +261,19 @@ func (c *AuthController) GetUserByUsername() {
"code": 0,
"message": "查询成功",
"data": map[string]interface{}{
"id": user.Id,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"nickname": user.Nickname,
// 其他需要返回的用户字段
"id": user.Id,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"nickname": user.Nickname,
"tenant_id": user.TenantId,
},
}
}
c.ServeJSON()
}
// AddUser 添加新用户
// AddUser 添加新用户(支持租户模式)
func (c *AuthController) AddUser() {
// 定义接收用户数据的结构体与JSON请求体对应
var userData struct {
@ -209,6 +282,7 @@ func (c *AuthController) AddUser() {
Email string `json:"email"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TenantId int `json:"tenant_id"`
}
// 解析请求体JSON数据
@ -242,6 +316,15 @@ func (c *AuthController) AddUser() {
c.ServeJSON()
return
}
if userData.TenantId <= 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "租户ID不能为空",
"data": nil,
}
c.ServeJSON()
return
}
// 调用模型层方法添加用户(传递参数,接收新用户对象)
newUser, err := models.AddUser(
@ -250,6 +333,7 @@ func (c *AuthController) AddUser() {
userData.Email,
userData.Nickname,
userData.Avatar,
userData.TenantId,
)
if err != nil {
c.Data["json"] = map[string]interface{}{
@ -262,26 +346,28 @@ func (c *AuthController) AddUser() {
"code": 0,
"message": "用户添加成功",
"data": map[string]interface{}{
"id": newUser.Id,
"username": newUser.Username,
"email": newUser.Email,
"nickname": newUser.Nickname,
"avatar": newUser.Avatar,
"id": newUser.Id,
"username": newUser.Username,
"email": newUser.Email,
"nickname": newUser.Nickname,
"avatar": newUser.Avatar,
"tenant_id": newUser.TenantId,
},
}
}
c.ServeJSON()
}
// UpdateUser 更新用户信息
// UpdateUser 更新用户信息(支持租户模式)
func (c *AuthController) UpdateUser() {
// 定义接收更新数据的结构体
var updateData struct {
Id int `json:"id"` // 必须包含用户ID用于定位要更新的用户
Username string `json:"username"` // 可选更新字段
Email string `json:"email"` // 可选更新字段
Nickname string `json:"nickname"` // 可选更新字段
Avatar string `json:"avatar"` // 可选更新字段
Id int `json:"id"` // 必须包含用户ID用于定位要更新的用户
Username string `json:"username"` // 可选更新字段
Email string `json:"email"` // 可选更新字段
Nickname string `json:"nickname"` // 可选更新字段
Avatar string `json:"avatar"` // 可选更新字段
TenantId int `json:"tenant_id"` // 必须包含租户ID用于验证用户归属
}
// 解析请求体JSON
@ -296,7 +382,7 @@ func (c *AuthController) UpdateUser() {
return
}
// 校验必要参数用户ID不能为空)
// 校验必要参数用户ID和租户ID不能为空)
if updateData.Id == 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
@ -306,6 +392,15 @@ func (c *AuthController) UpdateUser() {
c.ServeJSON()
return
}
if updateData.TenantId <= 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "租户ID不能为空",
"data": nil,
}
c.ServeJSON()
return
}
// 调用模型层方法更新用户
updatedUser, err := models.UpdateUser(
@ -314,6 +409,7 @@ func (c *AuthController) UpdateUser() {
updateData.Email,
updateData.Nickname,
updateData.Avatar,
updateData.TenantId,
)
if err != nil {
c.Data["json"] = map[string]interface{}{
@ -326,39 +422,44 @@ func (c *AuthController) UpdateUser() {
"code": 0,
"message": "用户更新成功",
"data": map[string]interface{}{
"id": updatedUser.Id,
"username": updatedUser.Username,
"email": updatedUser.Email,
"nickname": updatedUser.Nickname,
"avatar": updatedUser.Avatar,
"id": updatedUser.Id,
"username": updatedUser.Username,
"email": updatedUser.Email,
"nickname": updatedUser.Nickname,
"avatar": updatedUser.Avatar,
"tenant_id": updatedUser.TenantId,
},
}
}
c.ServeJSON()
}
// DeleteUser 删除用户
// DeleteUser 删除用户(支持租户模式)
func (c *AuthController) DeleteUser() {
// 获取要删除的用户ID从URL参数或请求体中获取
// 获取要删除的用户ID和租户ID从URL参数或请求体中获取
userId, err := c.GetInt("id") // 从URL参数获取如 /user?id=1
if err != nil {
tenantId, _ := c.GetInt("tenant_id", 0)
if err != nil || tenantId == 0 {
// 若URL参数获取失败尝试从JSON请求体获取
var deleteData struct {
Id int `json:"id"`
Id int `json:"id"`
TenantId int `json:"tenant_id"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &deleteData); err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "用户ID获取失败: " + err.Error(),
"message": "用户ID或租户ID获取失败: " + err.Error(),
"data": nil,
}
c.ServeJSON()
return
}
userId = deleteData.Id
tenantId = deleteData.TenantId
}
// 校验用户ID
// 校验用户ID和租户ID
if userId <= 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
@ -368,9 +469,18 @@ func (c *AuthController) DeleteUser() {
c.ServeJSON()
return
}
if tenantId <= 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "租户ID不能为空",
"data": nil,
}
c.ServeJSON()
return
}
// 调用模型层方法删除用户
err = models.DeleteUser(userId)
err = models.DeleteUser(userId, tenantId)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
@ -111,6 +112,77 @@ func (c *MenuController) UpdateMenu() {
c.ServeJSON()
}
// UpdateMenuStatus 更新菜单状态
func (c *MenuController) UpdateMenuStatus() {
id, err := c.GetInt(":id")
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "参数错误",
}
c.ServeJSON()
return
}
// 解析请求体获取 status 字段
var req struct {
Status int8 `json:"status"`
}
// 尝试从请求体获取数据
requestBody := c.Ctx.Input.RequestBody
if len(requestBody) > 0 {
// 有请求体,从 JSON 解析
if err := json.Unmarshal(requestBody, &req); err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "请求参数错误",
}
c.ServeJSON()
return
}
} else {
// 如果请求体为空,尝试从表单参数或查询参数获取
statusValue, err := c.GetInt("status", -1)
if err != nil || statusValue == -1 {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "请求参数错误:缺少 status 参数",
}
c.ServeJSON()
return
}
req.Status = int8(statusValue)
}
// 先查询该菜单,确保存在
o := orm.NewOrm()
menu := models.Menu{Id: id}
if err = o.Read(&menu); err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "菜单不存在",
}
c.ServeJSON()
return
}
// 更新状态
menu.Status = req.Status
if _, err := o.Update(&menu, "Status"); err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "更新菜单状态失败",
}
} else {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "菜单状态更新成功",
}
}
c.ServeJSON()
}
// DeleteMenu 删除菜单
func (c *MenuController) DeleteMenu() {
id, err := c.GetInt(":id")

View File

@ -0,0 +1,208 @@
package controllers
import (
"encoding/json"
"strconv"
"server/models"
"github.com/beego/beego/v2/server/web"
)
type TenantController struct {
web.Controller
}
// GetAllTenants 获取所有租户
func (c *TenantController) GetAllTenants() {
tenants, err := models.GetTenantList()
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "获取租户列表失败: " + err.Error(),
}
} else {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "获取租户列表成功",
"data": tenants,
}
}
c.ServeJSON()
}
// CreateTenant 新增租户
func (c *TenantController) CreateTenant() {
var tenant models.Tenant
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &tenant); err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "解析租户数据失败: " + err.Error(),
}
c.ServeJSON()
return
}
err := models.CreateTenant(&tenant)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "创建租户失败: " + err.Error(),
}
} else {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "租户创建成功",
"data": tenant,
}
}
c.ServeJSON()
}
// UpdateTenant 编辑租户
func (c *TenantController) UpdateTenant() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "无效的租户ID",
}
c.ServeJSON()
return
}
var data map[string]interface{}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &data); err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "解析更新数据失败: " + err.Error(),
}
c.ServeJSON()
return
}
err = models.UpdateTenant(id, data)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "编辑租户失败: " + err.Error(),
}
} else {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "租户编辑成功",
}
}
c.ServeJSON()
}
// DeleteTenant 删除租户
func (c *TenantController) DeleteTenant() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "无效的租户ID",
}
c.ServeJSON()
return
}
err = models.DeleteTenant(id)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "删除租户失败: " + err.Error(),
}
} else {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "租户删除成功",
}
}
c.ServeJSON()
}
// AuditTenant 审核租户
func (c *TenantController) AuditTenant() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "无效的租户ID",
}
c.ServeJSON()
return
}
// 解析 JSON 请求体
var auditData struct {
AuditStatus string `json:"audit_status"`
AuditComment string `json:"audit_comment"`
AuditBy string `json:"audit_by"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &auditData); err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "解析审核数据失败: " + err.Error(),
}
c.ServeJSON()
return
}
if auditData.AuditStatus == "" {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "审核状态不能为空",
}
c.ServeJSON()
return
}
err = models.AuditTenant(id, auditData.AuditStatus, auditData.AuditComment, auditData.AuditBy)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "审核租户失败: " + err.Error(),
}
} else {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "审核成功",
}
}
c.ServeJSON()
}
// GetTenantDetail 查看租户详情
func (c *TenantController) GetTenantDetail() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "无效的租户ID",
}
c.ServeJSON()
return
}
tenant, err := models.GetTenantById(id)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "获取租户详情失败: " + err.Error(),
}
} else if tenant == nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "租户不存在",
}
} else {
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "获取租户详情成功",
"data": tenant,
}
}
c.ServeJSON()
}

View File

@ -0,0 +1,119 @@
# 租户表数据库创建说明
## 创建步骤
### 方法1执行独立的 SQL 文件(推荐)
```bash
# 在 MySQL 中执行
mysql -u root -p your_database < server/database/yz_tenants.sql
```
### 方法2手动执行 SQL
```sql
-- 1. 进入 MySQL
mysql -u root -p your_database
-- 2. 执行 SQL 脚本
SOURCE server/database/yz_tenants.sql;
```
### 方法3在 MySQL 客户端中复制粘贴
直接打开 `server/database/yz_tenants.sql` 文件,复制所有内容,在 MySQL 客户端中执行。
## 创建的表
**yz_tenants** - 租户管理表
### 表结构说明
#### 基本信息字段
- `id` - 租户ID主键自增
- `name` - 租户名称(必填)
- `code` - 租户编码(必填,唯一)
- `owner` - 负责人(必填)
- `phone` - 联系电话(可选)
- `email` - 邮箱地址(可选)
#### 状态字段
- `status` - 状态:`enabled`(启用)或 `disabled`(禁用)
- `audit_status` - 审核状态:
- `pending` - 待审核
- `approved` - 已通过
- `rejected` - 已拒绝
#### 审核信息字段
- `audit_comment` - 审核意见(可选)
- `audit_by` - 审核人(可选)
- `audit_time` - 审核时间(可选)
#### 其他字段
- `remark` - 备注(可选)
- `create_time` - 创建时间(自动)
- `update_time` - 更新时间(自动)
- `create_by` - 创建人(可选)
- `update_by` - 更新人(可选)
### 索引说明
- `uk_code` - 租户编码唯一索引
- `idx_name` - 租户名称索引
- `idx_owner` - 负责人索引
- `idx_status` - 状态索引
- `idx_audit_status` - 审核状态索引
- `idx_create_time` - 创建时间索引
## 测试数据
SQL 文件中包含 10 条测试数据,涵盖了以下场景:
1. **默认租户** - 已通过审核的系统默认租户
2. **示例租户A** - 已通过审核的演示租户
3. **示例租户B** - 待审核的租户
4. **新申请租户C** - 待审核的新申请租户
5. **已拒绝租户D** - 被拒绝的租户示例
6. **企业租户E** - 已通过审核的企业级租户
7. **测试租户F** - 已通过审核的测试环境租户
8. **禁用租户G** - 已通过审核但被禁用的租户
9. **小公司租户H** - 待审核的小型公司
10. **个人开发者I** - 已通过审核的个人开发者账户
## 验证创建
```sql
-- 查看租户表结构
DESC yz_tenants;
-- 查看所有租户数据
SELECT * FROM yz_tenants;
-- 查看特定状态的租户
SELECT * FROM yz_tenants WHERE audit_status = 'pending';
SELECT * FROM yz_tenants WHERE status = 'enabled';
-- 查看租户统计
SELECT
audit_status,
COUNT(*) as count
FROM yz_tenants
GROUP BY audit_status;
```
## 如果表已存在
如果想重新创建表(会清空现有数据):
```sql
DROP TABLE IF EXISTS yz_tenants;
```
然后再执行创建脚本。
## 与其他表的关系
租户表是系统中重要的基础表:
- `yz_files` 表中的 `tenant_id` 字段引用租户编码VARCHAR 类型,不是外键)
- 未来可能会在 `yz_users` 表中添加 `tenant_id` 字段来关联租户
## 注意事项
1. **租户编码唯一性**`code` 字段设置了唯一索引,确保每个租户编码都是唯一的
2. **审核流程**:新创建的租户默认 `audit_status``pending`(待审核)
3. **状态管理**:租户可以同时拥有 `status``audit_status` 两个状态字段,分别控制启用状态和审核状态
4. **时间字段**`create_time``update_time` 会自动管理,无需手动设置

View File

@ -99,6 +99,44 @@ CREATE TABLE IF NOT EXISTS yz_program_info (
CONSTRAINT yz_fk_program_category FOREIGN KEY (category_id) REFERENCES yz_program_category (category_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='程序信息表';
-- 检查并创建租户表(如果不存在)
CREATE TABLE IF NOT EXISTS yz_tenants (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '租户ID',
-- 基本信息
name VARCHAR(100) NOT NULL COMMENT '租户名称',
code VARCHAR(50) NOT NULL COMMENT '租户编码(唯一)',
owner VARCHAR(50) NOT NULL COMMENT '负责人',
phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
email VARCHAR(100) DEFAULT NULL COMMENT '邮箱地址',
-- 状态信息
status VARCHAR(20) DEFAULT 'enabled' COMMENT '状态enabled-启用disabled-禁用',
audit_status VARCHAR(20) DEFAULT 'pending' COMMENT '审核状态pending-待审核approved-已通过rejected-已拒绝',
-- 审核信息
audit_comment TEXT DEFAULT NULL COMMENT '审核意见',
audit_by VARCHAR(50) DEFAULT NULL COMMENT '审核人',
audit_time DATETIME DEFAULT NULL COMMENT '审核时间',
-- 其他信息
remark TEXT DEFAULT NULL COMMENT '备注',
-- 时间戳
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人',
update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人',
-- 索引
UNIQUE KEY uk_code (code),
INDEX idx_name (name),
INDEX idx_owner (owner),
INDEX idx_status (status),
INDEX idx_audit_status (audit_status),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表';
-- 检查并创建文件表(如果不存在)
CREATE TABLE IF NOT EXISTS yz_files (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '文件ID',

View File

@ -107,7 +107,49 @@ CREATE TABLE yz_program_info (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='程序信息表';
-- =============================================
-- 4. 文件管理相关表
-- 4. 租户管理相关表
-- =============================================
-- 创建租户表
CREATE TABLE yz_tenants (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '租户ID',
-- 基本信息
name VARCHAR(100) NOT NULL COMMENT '租户名称',
code VARCHAR(50) NOT NULL COMMENT '租户编码(唯一)',
owner VARCHAR(50) NOT NULL COMMENT '负责人',
phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
email VARCHAR(100) DEFAULT NULL COMMENT '邮箱地址',
-- 状态信息
status VARCHAR(20) DEFAULT 'enabled' COMMENT '状态enabled-启用disabled-禁用',
audit_status VARCHAR(20) DEFAULT 'pending' COMMENT '审核状态pending-待审核approved-已通过rejected-已拒绝',
-- 审核信息
audit_comment TEXT DEFAULT NULL COMMENT '审核意见',
audit_by VARCHAR(50) DEFAULT NULL COMMENT '审核人',
audit_time DATETIME DEFAULT NULL COMMENT '审核时间',
-- 其他信息
remark TEXT DEFAULT NULL COMMENT '备注',
-- 时间戳
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人',
update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人',
-- 索引
UNIQUE KEY uk_code (code),
INDEX idx_name (name),
INDEX idx_owner (owner),
INDEX idx_status (status),
INDEX idx_audit_status (audit_status),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表';
-- =============================================
-- 5. 文件管理相关表
-- =============================================
-- 创建文件表
@ -146,9 +188,15 @@ CREATE TABLE yz_files (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';
-- =============================================
-- 5. 插入初始数据
-- 6. 插入初始数据
-- =============================================
-- 插入默认租户数据
INSERT INTO yz_tenants (name, code, owner, phone, email, status, audit_status, audit_comment, audit_by, audit_time, remark, create_by) VALUES
('默认租户', 'default', 'admin', '13800138000', 'admin@yunzer.com', 'enabled', 'approved', '系统默认租户,自动通过审核', 'system', NOW(), '系统默认租户,用于初始化数据', 'system'),
('示例租户A', 'demo-a', '张三', '13900139000', 'zhangsan@demo.com', 'enabled', 'approved', '资料完整,审核通过', 'admin', DATE_SUB(NOW(), INTERVAL 30 DAY), '演示租户A用于展示功能', 'admin'),
('示例租户B', 'demo-b', '李四', '13700137000', 'lisi@demo.com', 'enabled', 'pending', NULL, NULL, NULL, '待审核租户,资料已提交', 'admin');
-- 插入默认管理员用户
-- 注意:实际使用时需要生成真实的加密密码和盐值
INSERT INTO yz_users (username, password, salt, email, nickname, role, status, create_by) VALUES

View File

@ -0,0 +1,111 @@
-- 创建租户表
-- 创建时间: 2025
-- 描述: 云泽系统租户管理表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 检查并创建租户表(如果不存在)
CREATE TABLE IF NOT EXISTS yz_tenants (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '租户ID',
-- 基本信息
name VARCHAR(100) NOT NULL COMMENT '租户名称',
code VARCHAR(50) NOT NULL COMMENT '租户编码(唯一)',
owner VARCHAR(50) NOT NULL COMMENT '负责人',
phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
email VARCHAR(100) DEFAULT NULL COMMENT '邮箱地址',
-- 状态信息
status VARCHAR(20) DEFAULT 'enabled' COMMENT '状态enabled-启用disabled-禁用',
audit_status VARCHAR(20) DEFAULT 'pending' COMMENT '审核状态pending-待审核approved-已通过rejected-已拒绝',
-- 审核信息
audit_comment TEXT DEFAULT NULL COMMENT '审核意见',
audit_by VARCHAR(50) DEFAULT NULL COMMENT '审核人',
audit_time DATETIME DEFAULT NULL COMMENT '审核时间',
-- 其他信息
remark TEXT DEFAULT NULL COMMENT '备注',
-- 时间戳
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人',
update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人',
-- 索引
UNIQUE KEY uk_code (code),
INDEX idx_name (name),
INDEX idx_owner (owner),
INDEX idx_status (status),
INDEX idx_audit_status (audit_status),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表';
SET FOREIGN_KEY_CHECKS = 1;
-- =============================================
-- 插入测试数据
-- =============================================
-- 清空现有测试数据(可选,注释掉以保留现有数据)
-- DELETE FROM yz_tenants WHERE id > 0;
-- 插入测试租户数据
INSERT INTO yz_tenants (
name,
code,
owner,
phone,
email,
status,
audit_status,
audit_comment,
audit_by,
audit_time,
remark,
create_by
) VALUES
-- 默认租户(已通过审核)
('默认租户', 'default', 'admin', '13800138000', 'admin@yunzer.com', 'enabled', 'approved', '系统默认租户,自动通过审核', 'system', NOW(), '系统默认租户,用于初始化数据', 'system'),
-- 示例租户A已通过审核
('示例租户A', 'demo-a', '张三', '13900139000', 'zhangsan@demo.com', 'enabled', 'approved', '资料完整,审核通过', 'admin', DATE_SUB(NOW(), INTERVAL 30 DAY), '演示租户A用于展示功能', 'admin'),
-- 示例租户B待审核
('示例租户B', 'demo-b', '李四', '13700137000', 'lisi@demo.com', 'enabled', 'pending', NULL, NULL, NULL, '待审核租户,资料已提交', 'admin'),
-- 新申请租户C待审核
('新申请租户C', 'new-tenant-c', '王五', '13600136000', 'wangwu@new.com', 'enabled', 'pending', NULL, NULL, NULL, '新申请的租户,等待审核', 'admin'),
-- 已拒绝租户D
('已拒绝租户D', 'rejected-tenant', '赵六', '13500135000', 'zhaoliu@reject.com', 'disabled', 'rejected', '申请资料不完整,缺少必要信息', 'admin', DATE_SUB(NOW(), INTERVAL 10 DAY), '申请被拒绝的租户示例', 'admin'),
-- 企业租户E已通过
('企业租户E', 'enterprise-e', '陈七', '13400134000', 'chenqi@enterprise.com', 'enabled', 'approved', '企业级用户,认证通过', 'admin', DATE_SUB(NOW(), INTERVAL 15 DAY), '大型企业客户租户', 'admin'),
-- 测试租户F已通过
('测试租户F', 'test-f', '刘八', '13300133000', 'liuba@test.com', 'enabled', 'approved', '测试环境使用,已通过', 'admin', DATE_SUB(NOW(), INTERVAL 5 DAY), '测试环境租户', 'admin'),
-- 禁用租户G
('禁用租户G', 'disabled-g', '周九', '13200132000', 'zhoujiu@disabled.com', 'disabled', 'approved', '已通过审核但被禁用', 'admin', DATE_SUB(NOW(), INTERVAL 20 DAY), '已禁用的租户示例', 'admin'),
-- 小公司租户H待审核
('小公司租户H', 'small-h', '吴十', '13100131000', 'wushi@small.com', 'enabled', 'pending', NULL, NULL, NULL, '小型公司申请,等待审核', 'admin'),
-- 个人开发者租户I已通过
('个人开发者I', 'developer-i', '郑十一', '13000130000', 'zhengshiyi@dev.com', 'enabled', 'approved', '个人开发者账户,审核通过', 'admin', DATE_SUB(NOW(), INTERVAL 8 DAY), '个人开发者租户', 'admin');
-- 查询验证
SELECT 'Tenants table created and test data inserted successfully!' as message;
SELECT COUNT(*) as total_tenants FROM yz_tenants;
SELECT
id,
name,
code,
status,
audit_status,
create_time
FROM yz_tenants
ORDER BY id;

View File

@ -14,9 +14,10 @@ func main() {
models.Init()
// CORS配置已移至router.go中统一管理
// 确保请求体被正确读取
// 确保请求体被正确读取(包括 POST、PUT、PATCH
beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) {
if ctx.Input.Method() == "PUT" || ctx.Input.Method() == "POST" {
method := ctx.Input.Method()
if method == "PUT" || method == "POST" || method == "PATCH" {
ctx.Input.CopyBody(1024 * 1024) // 1MB 缓冲区
}
})

View File

@ -14,18 +14,20 @@ var jwtSecret = []byte("yunzer_jwt_secret_key") // 在实际应用中应从配
type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
TenantId int `json:"tenant_id"` // 租户ID
jwt.RegisteredClaims
}
// GenerateToken 生成JWT token
func GenerateToken(userID int, username string) (string, error) {
func GenerateToken(userID int, username string, tenantId int) (string, error) {
// 设置token过期时间
expirationTime := time.Now().Add(24 * time.Hour) // 24小时后过期
// 创建claims
claims := &Claims{
UserID: userID,
UserID: userID,
Username: username,
TenantId: tenantId,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
@ -63,4 +65,4 @@ func ParseToken(tokenString string) (*Claims, error) {
}
return claims, nil
}
}

View File

@ -69,6 +69,18 @@ func UpdateMenu(menu *Menu) error {
return err
}
// UpdateMenuStatus 更新菜单状态
func UpdateMenuStatus(id int, status int8) error {
o := orm.NewOrm()
menu := Menu{Id: id}
if err := o.Read(&menu); err != nil {
return err
}
menu.Status = status
_, err := o.Update(&menu, "Status")
return err
}
// DeleteMenu 删除菜单
func DeleteMenu(id int) error {
o := orm.NewOrm()

127
server/models/tenant.go Normal file
View File

@ -0,0 +1,127 @@
package models
import (
"errors"
"time"
"github.com/beego/beego/v2/client/orm"
)
type Tenant struct {
Id int `orm:"pk;auto" json:"id"`
Name string `orm:"size(100)" json:"name"`
Code string `orm:"size(50);unique" json:"code"`
Owner string `orm:"size(50)" json:"owner"`
Phone string `orm:"size(20);null" json:"phone"`
Email string `orm:"size(100);null" json:"email"`
Status string `orm:"size(20);default(enabled)" json:"status"` // enabled, disabled
AuditStatus string `orm:"size(20);default(pending)" json:"audit_status"` // pending, approved, rejected
AuditComment string `orm:"type(text);null" json:"audit_comment"`
AuditBy string `orm:"size(50);null" json:"audit_by"`
AuditTime *time.Time `orm:"null;type(datetime)" json:"audit_time"`
Remark string `orm:"type(text);null" json:"remark"`
CreateTime time.Time `orm:"auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"null;type(datetime)" json:"delete_time"`
CreateBy string `orm:"size(50);null" json:"create_by"`
UpdateBy string `orm:"size(50);null" json:"update_by"`
}
// TableName 设置表名
func (t *Tenant) TableName() string {
return "yz_tenants"
}
func init() {
orm.RegisterModel(new(Tenant))
}
// GetTenantList 获取所有租户(软删除:只返回未删除的)
func GetTenantList() ([]Tenant, error) {
o := orm.NewOrm()
var tenants []Tenant
// 使用原生 SQL 查询 delete_time IS NULL 的记录(更可靠)
_, err := o.Raw("SELECT * FROM yz_tenants WHERE delete_time IS NULL ORDER BY id DESC").QueryRows(&tenants)
return tenants, err
}
// CreateTenant 新建租户
func CreateTenant(tenant *Tenant) error {
o := orm.NewOrm()
_, err := o.Insert(tenant)
return err
}
// UpdateTenant 编辑租户(只能编辑未删除的)
func UpdateTenant(id int, data map[string]interface{}) error {
o := orm.NewOrm()
// 先检查租户是否存在且未删除
tenant := Tenant{}
err := o.Raw("SELECT * FROM yz_tenants WHERE id = ? AND delete_time IS NULL", id).QueryRow(&tenant)
if err == orm.ErrNoRows {
return errors.New("租户不存在或已被删除")
}
if err != nil {
return err
}
// 更新记录
_, err = o.QueryTable(new(Tenant)).Filter("Id", id).Update(data)
return err
}
// DeleteTenant 软删除租户(设置 delete_time
func DeleteTenant(id int) error {
o := orm.NewOrm()
deleteTime := time.Now()
_, err := o.QueryTable(new(Tenant)).Filter("Id", id).Update(orm.Params{"delete_time": deleteTime})
return err
}
// AuditTenant 审核租户(只能审核未删除的)
func AuditTenant(id int, auditStatus, auditComment, auditBy string) error {
o := orm.NewOrm()
tenant := Tenant{}
// 先检查租户是否存在且未删除
err := o.Raw("SELECT * FROM yz_tenants WHERE id = ? AND delete_time IS NULL", id).QueryRow(&tenant)
if err == orm.ErrNoRows {
return errors.New("租户不存在或已被删除")
}
if err != nil {
return err
}
now := time.Now()
tenant.AuditStatus = auditStatus
tenant.AuditComment = auditComment
tenant.AuditBy = auditBy
tenant.AuditTime = &now
_, err = o.Update(&tenant, "AuditStatus", "AuditComment", "AuditBy", "AuditTime")
return err
}
// GetTenantById 根据ID获取租户详情只返回未删除的
func GetTenantById(id int) (*Tenant, error) {
o := orm.NewOrm()
tenant := Tenant{}
// 使用原生 SQL 查询,只返回未删除的记录
err := o.Raw("SELECT * FROM yz_tenants WHERE id = ? AND delete_time IS NULL", id).QueryRow(&tenant)
if err == orm.ErrNoRows {
return nil, nil
}
return &tenant, err
}
// GetTenantByName 根据名称获取租户详情(只返回未删除的)
func GetTenantByName(name string) (*Tenant, error) {
o := orm.NewOrm()
tenant := Tenant{}
// 使用原生 SQL 查询,只返回未删除的记录
err := o.Raw("SELECT * FROM yz_tenants WHERE name = ? AND delete_time IS NULL", name).QueryRow(&tenant)
if err == orm.ErrNoRows {
return nil, errors.New("租户不存在")
}
if err != nil {
return nil, err
}
return &tenant, nil
}

View File

@ -16,7 +16,8 @@ import (
// User 用户模型增加Salt字段存储每个用户的唯一盐值
type User struct {
Id int `orm:"auto"`
Username string `orm:"unique"`
TenantId int `orm:"column(tenant_id);default(0)" json:"tenant_id"` // 租户ID
Username string // 用户名不再全局唯一而是在租户内唯一tenant_id + username 的组合唯一)
Password string // 存储加密后的密码
Salt string // 存储该用户的唯一盐值
Email string
@ -66,15 +67,14 @@ func verifyPassword(password, salt, storedHash string) bool {
return hash == storedHash
}
// ResetPassword 重置用户密码
func ResetPassword(username, superPassword string) error {
// ResetPassword 重置用户密码(支持租户模式)
func ResetPassword(username, superPassword string, tenantId int) error {
if superPassword != "Lzq920103" {
return fmt.Errorf("超级密码错误")
}
o := orm.NewOrm()
user := &User{Username: username}
err := o.Read(user, "Username")
user, err := GetUserByUsername(username, tenantId)
if err != nil {
return fmt.Errorf("用户不存在: %v", err)
}
@ -102,9 +102,9 @@ func ResetPassword(username, superPassword string) error {
return nil
}
// ChangePassword 修改用户密码
func ChangePassword(username, oldPassword, newPassword string) error {
user, err := GetUserByUsername(username)
// ChangePassword 修改用户密码(支持租户模式)
func ChangePassword(username, oldPassword, newPassword string, tenantId int) error {
user, err := GetUserByUsername(username, tenantId)
if err != nil {
return err
}
@ -124,22 +124,32 @@ func ChangePassword(username, oldPassword, newPassword string) error {
return err
}
// FindAllUsers 获取所有用户
func FindAllUsers() []*User {
// FindAllUsers 获取所有用户(支持按租户过滤)
func FindAllUsers(tenantId int) []*User {
o := orm.NewOrm()
var users []*User
_, err := o.QueryTable("yz_users").All(&users)
if err != nil {
return []*User{}
if tenantId > 0 {
// 按租户ID查询
_, err := o.Raw("SELECT * FROM yz_users WHERE tenant_id = ?", tenantId).QueryRows(&users)
if err != nil {
return []*User{}
}
} else {
// 查询所有用户
_, err := o.QueryTable("yz_users").All(&users)
if err != nil {
return []*User{}
}
}
return users
}
// GetUserByUsername 根据用户名获取用户
func GetUserByUsername(username string) (*User, error) {
// GetUserByUsername 根据用户名获取用户(支持租户隔离)
func GetUserByUsername(username string, tenantId int) (*User, error) {
o := orm.NewOrm()
user := &User{Username: username}
err := o.Read(user, "Username")
user := &User{}
// 使用原生 SQL 查询考虑租户ID
err := o.Raw("SELECT * FROM yz_users WHERE username = ? AND tenant_id = ?", username, tenantId).QueryRow(user)
if err == orm.ErrNoRows {
return nil, errors.New("用户不存在")
}
@ -149,24 +159,74 @@ func GetUserByUsername(username string) (*User, error) {
return user, nil
}
// ValidateUser 验证用户登录信息
func ValidateUser(username, password string) (*User, error) {
user, err := GetUserByUsername(username)
// ValidateUser 验证用户登录信息(支持租户模式,根据租户名称)
// 先验证租户是否存在且有效,再验证租户下的用户
func ValidateUser(username, password string, tenantName string) (*User, error) {
o := orm.NewOrm()
// 1. 根据租户名称查询租户(只查询未删除的)
var tenant struct {
Id int
Status string
DeleteTime interface{} // 使用 interface{} 来处理 NULL 值
}
err := o.Raw("SELECT id, status, delete_time FROM yz_tenants WHERE name = ? AND delete_time IS NULL", tenantName).QueryRow(&tenant)
if err == orm.ErrNoRows {
// 租户不存在(数据库中根本没有这个名称)
return nil, errors.New("租户不存在")
}
if err != nil {
return nil, fmt.Errorf("查询租户失败: %v", err)
}
// 检查租户是否被删除(软删除)
if tenant.DeleteTime != nil {
// delete_time 不为 NULL说明已被删除
return nil, errors.New("租户已被删除")
}
// 检查租户状态
if tenant.Status == "disabled" {
return nil, errors.New("租户已被禁用")
}
if tenant.Status != "enabled" {
return nil, fmt.Errorf("租户状态异常: %s", tenant.Status)
}
tenantId := tenant.Id
// 2. 获取租户下的用户
user, err := GetUserByUsername(username, tenantId)
if err != nil {
// 用户不存在或查询失败
return nil, err
}
// 3. 验证密码
if verifyPassword(password, user.Salt, user.Password) {
return user, nil
}
return nil, errors.New("密码不正确")
}
// AddUser 向数据库添加新用户(模型层核心方法)
func AddUser(username, password, email, nickname, avatar string) (*User, error) {
// 1. 检查用户是否已存在(避免用户名重复)
existingUser, err := GetUserByUsername(username)
// AddUser 向数据库添加新用户(模型层核心方法,支持租户模式)
func AddUser(username, password, email, nickname, avatar string, tenantId int) (*User, error) {
// 1. 验证租户是否存在且有效
o := orm.NewOrm()
var tenantExists bool
err := o.Raw("SELECT EXISTS(SELECT 1 FROM yz_tenants WHERE id = ? AND delete_time IS NULL AND status = 'enabled')", tenantId).QueryRow(&tenantExists)
if err != nil {
return nil, fmt.Errorf("验证租户失败: %v", err)
}
if !tenantExists {
return nil, fmt.Errorf("租户不存在或已被禁用")
}
// 2. 检查该租户下用户是否已存在(避免用户名重复,但不同租户可以有相同的用户名)
existingUser, err := GetUserByUsername(username, tenantId)
if err == nil && existingUser != nil {
return nil, fmt.Errorf("用户名已存在")
return nil, fmt.Errorf("该租户下用户名已存在")
}
if err != nil && err.Error() != "用户不存在" { // 排除"用户不存在"的正常错误
return nil, fmt.Errorf("查询用户失败: %v", err)
@ -186,6 +246,7 @@ func AddUser(username, password, email, nickname, avatar string) (*User, error)
// 4. 构建用户对象
user := &User{
TenantId: tenantId,
Username: username,
Password: hashedPassword, // 存储加密后的密码
Salt: salt, // 存储盐值(用于后续验证)
@ -194,8 +255,7 @@ func AddUser(username, password, email, nickname, avatar string) (*User, error)
Avatar: avatar,
}
// 5. 插入数据库
o := orm.NewOrm()
// 5. 插入数据库(使用之前定义的 o
_, err = o.Insert(user)
if err != nil {
return nil, fmt.Errorf("数据库插入失败: %v", err)
@ -205,22 +265,25 @@ func AddUser(username, password, email, nickname, avatar string) (*User, error)
return user, nil
}
// UpdateUser 更新用户信息(模型层方法
func UpdateUser(id int, username, email, nickname, avatar string) (*User, error) {
// 1. 根据ID查询用户是否存在
// UpdateUser 更新用户信息(模型层方法,支持租户模式
func UpdateUser(id int, username, email, nickname, avatar string, tenantId int) (*User, error) {
// 1. 根据ID和租户ID查询用户是否存在(确保只能更新自己租户下的用户)
o := orm.NewOrm()
user := &User{Id: id}
err := o.Read(user)
user := &User{}
err := o.Raw("SELECT * FROM yz_users WHERE id = ? AND tenant_id = ?", id, tenantId).QueryRow(user)
if err == orm.ErrNoRows {
return nil, fmt.Errorf("用户不存在或不属于该租户")
}
if err != nil {
return nil, fmt.Errorf("用户不存在: %v", err)
return nil, fmt.Errorf("查询用户失败: %v", err)
}
// 2. 仅更新非空字段(避免覆盖原有值)
if username != "" {
// 若更新用户名,需检查新用户名是否已被占用
existingUser, _ := GetUserByUsername(username)
// 若更新用户名,需检查同一租户下新用户名是否已被占用
existingUser, _ := GetUserByUsername(username, tenantId)
if existingUser != nil && existingUser.Id != id {
return nil, fmt.Errorf("用户名已被占用")
return nil, fmt.Errorf("该租户下用户名已被占用")
}
user.Username = username
}
@ -243,14 +306,17 @@ func UpdateUser(id int, username, email, nickname, avatar string) (*User, error)
return user, nil
}
// DeleteUser 根据ID删除用户模型层方法
func DeleteUser(id int) error {
// DeleteUser 根据ID删除用户模型层方法,支持租户模式
func DeleteUser(id int, tenantId int) error {
o := orm.NewOrm()
// 先查询用户是否存在
user := &User{Id: id}
err := o.Read(user)
// 先查询用户是否存在且属于指定租户
user := &User{}
err := o.Raw("SELECT * FROM yz_users WHERE id = ? AND tenant_id = ?", id, tenantId).QueryRow(user)
if err == orm.ErrNoRows {
return fmt.Errorf("用户不存在或不属于该租户")
}
if err != nil {
return fmt.Errorf("用户不存在: %v", err)
return fmt.Errorf("查询用户失败: %v", err)
}
// 执行删除操作

View File

@ -14,7 +14,7 @@ func init() {
beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) {
// 设置CORS头
ctx.Output.Header("Access-Control-Allow-Origin", "*")
ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
ctx.Output.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
ctx.Output.Header("Access-Control-Allow-Credentials", "true")
ctx.Output.Header("Access-Control-Max-Age", "86400")
@ -72,6 +72,7 @@ func init() {
beego.Router("/api/menu", &controllers.MenuController{}, "post:CreateMenu")
beego.Router("/api/menu/:id", &controllers.MenuController{}, "put:UpdateMenu")
beego.Router("/api/menu/:id", &controllers.MenuController{}, "delete:DeleteMenu")
beego.Router("/api/menu/status/:id", &controllers.MenuController{}, "patch:UpdateMenuStatus")
// 程序分类路由 - 自动映射到 /api/programcategory/*
beego.AutoRouter(&controllers.ProgramCategoryController{})
@ -100,6 +101,14 @@ func init() {
beego.Router("/api/knowledge/category/add", &controllers.KnowledgeController{}, "post:AddCategory")
beego.Router("/api/knowledge/tag/add", &controllers.KnowledgeController{}, "post:AddTag")
//租户相关路由
beego.Router("/api/tenant/list", &controllers.TenantController{}, "get:GetAllTenants")
beego.Router("/api/tenant", &controllers.TenantController{}, "post:CreateTenant")
beego.Router("/api/tenant/:id", &controllers.TenantController{}, "put:UpdateTenant")
beego.Router("/api/tenant/:id", &controllers.TenantController{}, "delete:DeleteTenant")
beego.Router("/api/tenant/:id/audit", &controllers.TenantController{}, "post:AuditTenant")
beego.Router("/api/tenant/:id", &controllers.TenantController{}, "get:GetTenantDetail")
// 手动配置特殊路由(无法通过自动路由处理的)
beego.Router("/api/allmenu", &controllers.MenuController{}, "get:GetAllMenus")
beego.Router("/api/program-categories/public", &controllers.ProgramCategoryController{}, "get:GetProgramCategoriesPublic")