修复用户和文件管理,增加文件预览

This commit is contained in:
李志强 2025-11-03 16:18:30 +08:00
parent 2023fe4b97
commit 88d2f527f4
22 changed files with 4821 additions and 1095 deletions

420
package-lock.json generated Normal file
View File

@ -0,0 +1,420 @@
{
"name": "yunzer_go",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"docx-preview": "^0.3.7",
"vue3-pdf-app": "^1.0.3"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT",
"peer": true
},
"node_modules/@vue/compiler-core": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/shared": "3.5.22",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.22",
"@vue/shared": "3.5.22"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/compiler-core": "3.5.22",
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.19",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/shared": "3.5.22"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.22"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
"@vue/shared": "3.5.22",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
},
"peerDependencies": {
"vue": "3.5.22"
}
},
"node_modules/@vue/shared": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.22.tgz",
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
"license": "MIT",
"peer": true
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
},
"node_modules/docx-preview": {
"version": "0.3.7",
"resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
"integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
"license": "Apache-2.0",
"dependencies": {
"jszip": ">=3.0.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC",
"peer": true
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",
"@vue/runtime-dom": "3.5.22",
"@vue/server-renderer": "3.5.22",
"@vue/shared": "3.5.22"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue3-pdf-app": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/vue3-pdf-app/-/vue3-pdf-app-1.0.3.tgz",
"integrity": "sha512-qegWTIF4wYKiocZ3KreB70wRXhqSdXWbdERDyyKzT7d5PbjKbS9tD6vaKkCqh3PzTM84NyKPYrQ3iuwJb60YPQ==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
}
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"docx-preview": "^0.3.7",
"vue3-pdf-app": "^1.0.3"
}
}

View File

@ -98,3 +98,42 @@ export function searchFiles(keyword) {
params: { keyword }, params: { keyword },
}); });
} }
/**
* 下载文件
* @param {number|string} id 文件ID
* @returns {string} 下载URL
*/
export function downloadFile(id) {
return `/api/files/download/${id}`;
}
/**
* 预览文件
* @param {number|string} id 文件ID
* @returns {string} 预览URL
*/
export function previewFile(id) {
return `/api/files/preview/${id}`;
}
/**
* 检查文件是否可预览
* @param {string} fileExt 文件扩展名
* @returns {boolean}
*/
export function canPreview(fileExt) {
const previewableExts = [
// 图片格式
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg',
// 文档格式
'.pdf', '.txt',
// Office文档格式仅支持 .docx不支持旧的 .doc、Excel、PPT
'.docx',
// 视频格式
'.mp4', '.webm',
// 音频格式
'.mp3', '.wav'
];
return previewableExts.includes(fileExt.toLowerCase());
}

View File

@ -16,6 +16,14 @@ export function getUserInfo(userId) {
}); });
} }
//获取租户用户信息
export function getTenantUsers(tenantId) {
return request({
url: `/api/tenantUsers/${tenantId}`,
method: 'get',
});
}
// 添加用户 // 添加用户
export function addUser(data) { export function addUser(data) {
return request({ return request({

View File

@ -4,7 +4,7 @@ import CommonHeader from '@/components/CommonHeader.vue';
import { useTabsStore } from '@/stores'; import { useTabsStore } from '@/stores';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted, computed } from 'vue'; import { ref, watch, reactive, nextTick, onMounted, computed } from 'vue';
import { More, Close, CircleClose } from '@element-plus/icons-vue' import { More, Close, CircleClose, ArrowUp } from '@element-plus/icons-vue'
const tabsStore = useTabsStore(); const tabsStore = useTabsStore();
const router = useRouter(); const router = useRouter();
@ -451,6 +451,15 @@ const canCloseRight = computed(() => {
</keep-alive> </keep-alive>
</router-view> </router-view>
</div> </div>
<!-- 回到顶部按钮 -->
<el-backtop :target="'.right-main'" :visibility-height="300" :right="30" :bottom="50">
<div class="backtop-button">
<el-icon :size="20">
<ArrowUp />
</el-icon>
</div>
</el-backtop>
</el-main> </el-main>
</el-container> </el-container>
</el-container> </el-container>
@ -606,6 +615,31 @@ const canCloseRight = computed(() => {
:deep(.el-menu){ :deep(.el-menu){
border-right: none !important; border-right: none !important;
} }
//
.backtop-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-color-primary);
color: #fff;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(64, 129, 255, 0.4);
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: var(--el-color-primary-light-3);
box-shadow: 0 6px 16px rgba(64, 129, 255, 0.5);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
</style> </style>
<style lang="less"> <style lang="less">

View File

@ -0,0 +1,431 @@
<template>
<div class="category-container">
<!-- 顶部操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
<el-button @click="refreshCategories">
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</div>
<!-- 分类列表 -->
<div class="category-list">
<el-table :data="categories" v-loading="loading" style="width: 100%" stripe>
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="name" label="分类名称" min-width="150">
<template #default="{ row }">
<div class="category-name">
<el-tag :color="row.color" effect="dark" size="large">
{{ row.name }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
<el-table-column prop="file_count" label="文件数量" width="120" sortable>
<template #default="{ row }">
<el-badge :value="row.file_count || 0" :max="999" class="item">
<el-button size="small" text type="primary">
{{ row.file_count || 0 }} 个文件
</el-button>
</el-badge>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="100" sortable>
<template #default="{ row }">
<el-tag type="info">{{ row.sort_order || 0 }}</el-tag>
</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)"
:disabled="loading"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @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" show-alpha />
<span class="tip-text">选择分类显示颜色</span>
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number
v-model="categoryForm.sort_order"
:min="0"
:max="999"
placeholder="排序值,越小越靠前"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="categoryForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleDialogClose"> </el-button>
<el-button type="primary" @click="saveCategory" :loading="saving">
{{ saving ? '保存中...' : '确 定' }}
</el-button>
</div>
</template>
</el-dialog>
<!-- 统计信息卡片 -->
<div class="stats-cards">
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #e6f7ff; color: #1890ff">
<el-icon><Grid /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">总分类数</div>
<div class="stat-value">{{ totalCategories }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #f0f9ff; color: #52c41a">
<el-icon><SuccessFilled /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">启用分类</div>
<div class="stat-value">{{ enabledCategories }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #fff7e6; color: #faad14">
<el-icon><Document /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">关联文件</div>
<div class="stat-value">{{ totalFiles }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #fff1f0; color: #f5222d">
<el-icon><CircleClose /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">禁用分类</div>
<div class="stat-value">{{ disabledCategories }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
Plus,
RefreshRight,
Grid,
Document,
SuccessFilled,
CircleClose,
} from '@element-plus/icons-vue'
// Props & Emits
const emit = defineEmits(['category-change'])
//
const loading = ref(false)
const saving = ref(false)
const showAddDialog = ref(false)
const editingCategory = ref<any>(null)
const categoryFormRef = ref<FormInstance>()
const categories = ref([
{ id: 1, name: '文档', description: '各类文档文件', color: '#409eff', file_count: 15, sort_order: 1, status: 1 },
{ id: 2, name: '图片', description: '图片和照片', color: '#67c23a', file_count: 32, sort_order: 2, status: 1 },
{ id: 3, name: '视频', description: '视频文件', color: '#e6a23c', file_count: 8, sort_order: 3, status: 1 },
{ id: 4, name: '音频', description: '音频文件', color: '#f56c6c', file_count: 5, sort_order: 4, status: 1 },
{ id: 5, name: '编辑器', description: '编辑器上传的文件', color: '#909399', file_count: 28, sort_order: 5, status: 1 },
{ id: 6, name: '其他', description: '其他类型文件', color: '#606266', file_count: 12, sort_order: 6, status: 1 },
])
const categoryForm = reactive({
name: '',
description: '',
color: '#409eff',
sort_order: 0,
status: 1,
})
const categoryRules = reactive<FormRules>({
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' },
],
description: [
{ max: 200, message: '描述不能超过 200 个字符', trigger: 'blur' },
],
})
//
const totalCategories = computed(() => categories.value.length)
const enabledCategories = computed(() => categories.value.filter((c) => c.status === 1).length)
const disabledCategories = computed(() => categories.value.filter((c) => c.status === 0).length)
const totalFiles = computed(() => categories.value.reduce((sum, c) => sum + (c.file_count || 0), 0))
//
const refreshCategories = () => {
loading.value = true
// API
setTimeout(() => {
loading.value = false
ElMessage.success('刷新成功')
}, 500)
}
const editCategory = (row: any) => {
editingCategory.value = row
categoryForm.name = row.name
categoryForm.description = row.description || ''
categoryForm.color = row.color || '#409eff'
categoryForm.sort_order = row.sort_order || 0
categoryForm.status = row.status
showAddDialog.value = true
}
const deleteCategory = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要删除分类「${row.name}」吗?该分类下有 ${row.file_count || 0} 个文件,删除后文件将归入"未分类"。`,
'警告',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
}
)
loading.value = true
// API
setTimeout(() => {
const index = categories.value.findIndex((c) => c.id === row.id)
if (index > -1) {
categories.value.splice(index, 1)
}
loading.value = false
ElMessage.success('删除成功')
emit('category-change')
}, 500)
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败')
}
}
}
const updateCategoryStatus = (row: any) => {
loading.value = true
// API
setTimeout(() => {
loading.value = false
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
emit('category-change')
}, 300)
}
const saveCategory = async () => {
if (!categoryFormRef.value) return
await categoryFormRef.value.validate(async (valid) => {
if (!valid) return
saving.value = true
try {
// API
await new Promise((resolve) => setTimeout(resolve, 500))
if (editingCategory.value) {
//
Object.assign(editingCategory.value, categoryForm)
ElMessage.success('更新成功')
} else {
//
const newCategory = {
id: categories.value.length + 1,
...categoryForm,
file_count: 0,
}
categories.value.push(newCategory)
ElMessage.success('添加成功')
}
showAddDialog.value = false
resetForm()
emit('category-change')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
saving.value = false
}
})
}
const handleDialogClose = () => {
showAddDialog.value = false
resetForm()
}
const resetForm = () => {
editingCategory.value = null
categoryForm.name = ''
categoryForm.description = ''
categoryForm.color = '#409eff'
categoryForm.sort_order = 0
categoryForm.status = 1
categoryFormRef.value?.resetFields()
}
//
onMounted(() => {
refreshCategories()
})
</script>
<style scoped>
.category-container {
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
min-height: 500px;
}
.action-bar {
margin-bottom: 20px;
display: flex;
gap: 12px;
}
.category-list {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.category-name {
display: flex;
align-items: center;
}
.stats-cards {
margin-top: 20px;
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,909 @@
<template>
<div class="file-list-container">
<!-- 搜索和筛选栏 -->
<div class="search-filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索原始文件名"
clearable
@input="handleSearch"
size="large"
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="filterCategory"
placeholder="全部分类"
clearable
@change="applyFilters"
size="large"
class="category-select"
>
<el-option
v-for="category in categories"
:key="category"
:label="category"
:value="category"
/>
</el-select>
<el-button
type="primary"
size="large"
@click="showUploadDialog = true"
>
<el-icon><UploadFilled /></el-icon>
快速上传
</el-button>
<el-button
size="large"
@click="fetchFiles"
>
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</div>
<!-- 文件表格 -->
<div class="file-table">
<el-table
:data="paginatedFiles"
v-loading="loading"
style="width: 100%"
stripe
:default-sort="{ prop: 'upload_time', order: 'descending' }"
>
<el-table-column prop="id" label="ID" width="80" sortable />
<el-table-column label="文件名" min-width="250">
<template #default="{ row }">
<div class="file-name">
<el-icon class="icon-doc"><Document /></el-icon>
<span>{{ row.original_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="file_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.file_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="大小" width="120" sortable>
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="120">
<template #default="{ row }">
<el-tag type="success" effect="plain">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="upload_by" label="上传人" width="120" />
<el-table-column prop="upload_time" label="上传时间" width="180" sortable>
<template #default="{ row }">
{{ formatDate(row.upload_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-tooltip :content="canPreviewFile(row.file_ext) ? '预览' : '下载'">
<el-button
:type="canPreviewFile(row.file_ext) ? 'success' : 'warning'"
circle
plain
size="small"
@click="handleFileAction(row)"
>
<el-icon>
<View v-if="canPreviewFile(row.file_ext)" />
<Download v-else />
</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="showPreviewDialog"
:width="isFullscreen ? '100%' : '80%'"
:fullscreen="isFullscreen"
:before-close="handlePreviewClose"
class="preview-dialog"
>
<template #header>
<div class="preview-dialog-header">
<span class="preview-dialog-title">{{ previewFile?.original_name || '文件预览' }}</span>
<el-button
:icon="isFullscreen ? 'FullScreen' : 'FullScreen'"
circle
size="small"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'"
>
<el-icon v-if="isFullscreen"><DCaret /></el-icon>
<el-icon v-else><FullScreen /></el-icon>
</el-button>
</div>
</template>
<div v-if="previewFile" class="preview-content">
<!-- 图片预览 -->
<div v-if="isImageFile(previewFile.file_ext)" class="preview-image-wrapper">
<el-image
:src="previewImageUrl"
fit="contain"
:preview-src-list="[previewImageUrl]"
:hide-on-click-modal="true"
:style="{ width: '100%', maxHeight: isFullscreen ? 'calc(100vh - 140px)' : '70vh' }"
loading="lazy"
/>
</div>
<!-- PDF预览 -->
<div v-else-if="isPdfFile(previewFile.file_ext)" :class="['preview-pdf-wrapper', { 'is-fullscreen': isFullscreen }]">
<VuePdfApp v-if="previewImageUrl" :pdf="previewImageUrl" />
</div>
<!-- Word文档预览 -->
<div v-else-if="isWordFile(previewFile.file_ext)" :class="['preview-word-wrapper', { 'is-fullscreen': isFullscreen }]">
<div ref="wordPreviewContainer" class="word-preview-container"></div>
</div>
<!-- 其他Office文档Excel, PPT暂不支持 -->
<div v-else-if="isOfficeFile(previewFile.file_ext)" class="preview-other">
<el-icon style="font-size: 64px; color: #909399"><Document /></el-icon>
<p>{{ getOfficeFileTypeName(previewFile.file_ext) }} 文件暂不支持在线预览</p>
<el-button type="primary" @click="downloadFile(previewFile)">
<el-icon><Download /></el-icon>
下载文件
</el-button>
</div>
<!-- 其他文件显示提示 -->
<div v-else class="preview-other">
<el-icon style="font-size: 64px; color: #909399"><Document /></el-icon>
<p>该文件类型不支持在线预览</p>
<el-button type="primary" @click="downloadFile(previewFile)">
<el-icon><Download /></el-icon>
下载文件
</el-button>
</div>
</div>
</el-dialog>
<!-- 快速上传对话框 -->
<el-dialog
v-model="showUploadDialog"
title="快速上传"
width="500px"
:before-close="handleUploadClose"
>
<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"
multiple
>
<el-icon class="el-icon--upload" style="font-size: 44px; color: #36b2fa">
<UploadFilled />
</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">
<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>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
UploadFilled,
RefreshRight,
Document,
View,
Download,
Delete,
WarningFilled,
FullScreen,
DCaret,
} from '@element-plus/icons-vue'
import {
getFilesByTenant,
deleteFile as deleteFileApi,
canPreview as canPreviewApi,
previewFile as previewFileApi,
downloadFile as downloadFileApi,
} from '@/api/file'
import { useAuthStore } from '@/stores/auth'
import { renderAsync } from 'docx-preview'
import VuePdfApp from 'vue3-pdf-app'
import 'vue3-pdf-app/dist/icons/main.css'
// Props & Emits
const emit = defineEmits(['refresh'])
// Store
const authStore = useAuthStore()
//
const allFiles = ref<any[]>([])
const categories = ref<string[]>([])
const filteredFiles = ref<any[]>([])
const loading = ref(false)
const searchKeyword = ref('')
const filterCategory = ref('')
const pageSize = ref(10)
const currentPage = ref(1)
const totalFiles = ref(0)
const showUploadDialog = ref(false)
const showPreviewDialog = ref(false)
const previewFile = ref<any>(null)
const previewImageUrl = ref('')
const wordPreviewContainer = ref<HTMLElement | null>(null)
const isFullscreen = ref(false)
const uploadForm = ref({
category: '文档',
})
//
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 paginatedFiles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredFiles.value.slice(start, end)
})
// ID
const getCurrentTenantId = () => {
if (authStore.user && authStore.user.tenant_id) {
return authStore.user.tenant_id
}
const userInfo = localStorage.getItem('userInfo')
if (userInfo) {
try {
const user = JSON.parse(userInfo)
return user.tenant_id || user.tenantId || 1
} catch (e) {
console.error('Failed to parse user info:', e)
}
}
return 1
}
//
const fetchFiles = async () => {
loading.value = true
try {
const tenantId = getCurrentTenantId()
const res = await getFilesByTenant(tenantId)
if (res.success && res.data) {
const files = Array.isArray(res.data) ? res.data : []
allFiles.value = files.map((file: any) => ({
...file,
upload_time: file.upload_time || file.create_time,
}))
//
const uniqueCategories = new Set<string>()
files.forEach((file: any) => {
if (file.category) {
uniqueCategories.add(file.category)
}
})
categories.value = Array.from(uniqueCategories).sort()
applyFilters()
totalFiles.value = filteredFiles.value.length
emit('refresh')
} else {
allFiles.value = []
totalFiles.value = 0
}
} catch (err: any) {
ElMessage.error(err.message || '获取文件列表失败')
allFiles.value = []
totalFiles.value = 0
} finally {
loading.value = false
}
}
//
const applyFilters = () => {
let result = [...allFiles.value]
if (filterCategory.value) {
result = result.filter((file) => file.category === filterCategory.value)
}
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(
(file) =>
file.original_name && file.original_name.toLowerCase().includes(keyword)
)
}
filteredFiles.value = result
totalFiles.value = result.length
if (currentPage.value > 1 && result.length === 0) {
currentPage.value = 1
}
}
//
const handleSearch = () => {
applyFilters()
}
//
const handleSizeChange = (val: number) => {
pageSize.value = val
currentPage.value = 1
}
const handleCurrentChange = (val: number) => {
currentPage.value = val
}
//
const handleUploadClose = () => {
showUploadDialog.value = false
uploadForm.value.category = '文档'
}
const beforeUpload = (file: File) => {
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
return false
}
return true
}
const handleUploadSuccess = (response: any, file: any) => {
if (response.success) {
ElMessage.success('文件上传成功!')
showUploadDialog.value = false
uploadForm.value.category = '文档'
fetchFiles()
} else {
ElMessage.error(response.message || '文件上传失败!')
}
}
const handleUploadError = (error: Error, file: any) => {
ElMessage.error('文件上传失败:' + (error.message || error))
}
//
const canPreviewFile = (fileExt: string) => {
return canPreviewApi(fileExt)
}
const handleFileAction = (file: any) => {
if (canPreviewFile(file.file_ext)) {
previewFileAction(file)
} else {
downloadFile(file)
}
}
//
const isImageFile = (fileExt: string) => {
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
return imageExts.includes(fileExt.toLowerCase())
}
// PDF
const isPdfFile = (fileExt: string) => {
return fileExt.toLowerCase() === '.pdf'
}
// OfficeWord, Excel, PPT
const isOfficeFile = (fileExt: string) => {
const officeExts = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
return officeExts.includes(fileExt.toLowerCase())
}
// Word
const isWordFile = (fileExt: string) => {
const wordExts = ['.doc', '.docx']
return wordExts.includes(fileExt.toLowerCase())
}
// Excel
const isExcelFile = (fileExt: string) => {
const excelExts = ['.xls', '.xlsx']
return excelExts.includes(fileExt.toLowerCase())
}
// PPT
const isPptFile = (fileExt: string) => {
const pptExts = ['.ppt', '.pptx']
return pptExts.includes(fileExt.toLowerCase())
}
// Office
const getOfficeFileTypeName = (fileExt: string) => {
if (isExcelFile(fileExt)) return 'Excel'
if (isPptFile(fileExt)) return 'PowerPoint'
return 'Office'
}
const previewFileAction = async (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
const previewUrl = `${baseUrl}${previewFileApi(file.id)}`
const token = localStorage.getItem('token')
if (!token) {
ElMessage.error('请先登录')
return
}
try {
loading.value = true
//
const response = await fetch(previewUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
//
let errorMessage = '预览失败'
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// JSON使
}
}
ElMessage.error(errorMessage)
loading.value = false
return
}
const blob = await response.blob()
// Word
if (isWordFile(file.file_ext)) {
previewFile.value = file
showPreviewDialog.value = true
// DOM
await nextTick()
if (wordPreviewContainer.value) {
//
wordPreviewContainer.value.innerHTML = ''
// 使 docx-preview
try {
await renderAsync(blob, wordPreviewContainer.value, undefined, {
className: 'docx-wrapper',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
})
} catch (error: any) {
console.error('Word 预览失败:', error)
ElMessage.error('Word 预览失败: ' + (error.message || error))
showPreviewDialog.value = false
previewFile.value = null
}
}
}
// PDF
else if (isPdfFile(file.file_ext)) {
const blobUrl = window.URL.createObjectURL(blob)
previewFile.value = file
previewImageUrl.value = blobUrl
showPreviewDialog.value = true
}
//
else if (isImageFile(file.file_ext)) {
const blobUrl = window.URL.createObjectURL(blob)
previewFile.value = file
previewImageUrl.value = blobUrl
showPreviewDialog.value = true
}
// OfficeExcel, PPT- 线
else if (isOfficeFile(file.file_ext)) {
ElMessageBox.confirm(
`${getOfficeFileTypeName(file.file_ext)} 文件暂不支持在线预览,是否下载文件?`,
'提示',
{
confirmButtonText: '下载',
cancelButtonText: '取消',
type: 'info',
}
).then(() => {
downloadFile(file)
}).catch(() => {})
}
//
else {
const blobUrl = window.URL.createObjectURL(blob)
const previewWindow = window.open(blobUrl, '_blank')
if (!previewWindow) {
ElMessage.warning('无法打开预览窗口,请检查浏览器弹窗设置')
window.URL.revokeObjectURL(blobUrl)
return
}
// blob URL
const checkWindow = setInterval(() => {
if (previewWindow.closed) {
window.URL.revokeObjectURL(blobUrl)
clearInterval(checkWindow)
}
}, 1000)
}
} catch (error: any) {
console.error('预览失败:', error)
ElMessage.error('预览失败: ' + (error.message || error))
} finally {
loading.value = false
}
}
const handlePreviewClose = () => {
// blob URL
if (previewImageUrl.value && previewImageUrl.value.startsWith('blob:')) {
window.URL.revokeObjectURL(previewImageUrl.value)
}
// Word
if (wordPreviewContainer.value) {
wordPreviewContainer.value.innerHTML = ''
}
showPreviewDialog.value = false
previewFile.value = null
previewImageUrl.value = ''
isFullscreen.value = false
}
//
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
}
const downloadFile = (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
const downloadUrl = `${baseUrl}${downloadFileApi(file.id)}`
const token = localStorage.getItem('token')
const link = document.createElement('a')
link.href = downloadUrl
if (token) {
fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.blob())
.then((blob) => {
const url = window.URL.createObjectURL(blob)
link.href = url
link.download = file.original_name || file.file_name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('文件下载开始')
})
.catch((err) => {
ElMessage.error('文件下载失败:' + err.message)
})
} else {
link.download = file.original_name || file.file_name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
const deleteFile = async (file: any) => {
try {
await ElMessageBox.confirm(
`确定要删除文件「${file.original_name || file.file_name}」吗?删除后不可恢复。`,
'警告',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
}
)
loading.value = true
const res = await deleteFileApi(file.id)
if (res.success) {
ElMessage.success('删除成功')
await fetchFiles()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败')
}
} finally {
loading.value = false
}
}
//
const formatFileSize = (bytes: number | string | undefined | null): string => {
if (!bytes && bytes !== 0) return '0 B'
const bytesValue = typeof bytes === 'string' ? parseFloat(bytes) : Number(bytes)
if (isNaN(bytesValue) || bytesValue < 0) return '0 B'
if (bytesValue === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytesValue) / Math.log(k))
return parseFloat((bytesValue / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatDate = (dateStr: string | number | undefined | null): string => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (isNaN(date.getTime())) return String(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
//
onMounted(() => {
fetchFiles()
})
//
defineExpose({
fetchFiles,
})
</script>
<style scoped>
.file-list-container {
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
min-height: 500px;
}
.search-filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 300px;
}
.category-select {
width: 180px;
}
.file-table {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
}
.icon-doc {
color: #409eff;
font-size: 18px;
}
.pagination {
display: flex;
justify-content: flex-end;
padding: 20px;
background: #fff;
border-radius: 8px;
}
.upload-form {
margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 20px;
}
.preview-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 20px;
}
.preview-dialog-title {
font-size: 18px;
font-weight: 600;
color: #303133;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-dialog :deep(.el-dialog__body) {
padding: 20px;
text-align: center;
}
.preview-dialog.is-fullscreen :deep(.el-dialog__body) {
height: calc(100vh - 120px);
overflow: auto;
}
.preview-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.preview-image-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
.preview-pdf-wrapper {
width: 100%;
height: 70vh;
}
.is-fullscreen .preview-pdf-wrapper {
height: calc(100vh - 140px);
}
.preview-word-wrapper {
width: 100%;
height: 70vh;
overflow: auto;
}
.is-fullscreen .preview-word-wrapper {
height: calc(100vh - 140px);
}
.word-preview-container {
width: 100%;
min-height: 70vh;
padding: 20px;
background: #fff;
}
.word-preview-container :deep(.docx-wrapper) {
background: #fff;
}
.word-preview-container :deep(.docx) {
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
margin: 0 auto;
background: #fff;
}
.preview-other {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 40px;
}
.preview-other p {
margin: 0;
color: #606266;
font-size: 16px;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 180px;
}
:deep(.el-button--small span,.el-button span){
margin-left: 0 !important;
}
</style>

View File

@ -0,0 +1,539 @@
<template>
<div class="upload-container">
<!-- 上传区域 -->
<div class="upload-area">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ category: uploadForm.category }"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-progress="handleUploadProgress"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
multiple
:file-list="fileList"
:auto-upload="autoUpload"
>
<el-icon class="el-icon--upload" style="font-size: 64px; color: #409eff">
<UploadFilled />
</el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击选择文件</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持批量上传单个文件大小不超过
<span style="color: #f56c6c; font-weight: 600">10MB</span>
支持拖拽排序
</div>
</template>
</el-upload>
</div>
<!-- 上传设置 -->
<div class="upload-settings">
<el-card>
<template #header>
<div class="card-header">
<span>上传设置</span>
</div>
</template>
<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-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="自动上传">
<el-switch v-model="autoUpload" />
<span class="tip-text">开启后选择文件即自动上传</span>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 文件列表预览 -->
<div class="file-preview" v-if="fileList.length > 0">
<el-card>
<template #header>
<div class="card-header">
<span>待上传文件列表{{ fileList.length }}个文件</span>
<el-button
size="small"
type="danger"
@click="clearFiles"
:disabled="uploading"
>
清空列表
</el-button>
</div>
</template>
<el-table :data="fileList" style="width: 100%" max-height="400">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
<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 size="small">{{ getFileType(row.name) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<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" fixed="right">
<template #default="{ row, $index }">
<el-button
size="small"
type="danger"
@click="removeFile($index)"
:disabled="row.status === 'uploading' || uploading"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="upload-actions" v-if="!autoUpload">
<el-button
type="primary"
@click="submitUpload"
:loading="uploading"
:disabled="fileList.length === 0"
size="large"
>
<el-icon><Upload /></el-icon>
{{ uploading ? '上传中...' : '开始上传' }}
</el-button>
</div>
</el-card>
</div>
<!-- 上传进度 -->
<div class="upload-progress" v-if="uploading || uploadedCount > 0">
<el-card>
<template #header>
<span>上传进度</span>
</template>
<el-progress
:percentage="uploadProgress"
:status="uploadStatus"
:stroke-width="20"
/>
<div class="progress-info">
<span>已上传: {{ uploadedCount }}/{{ totalCount }}</span>
<span>成功: {{ successCount }} </span>
<span>失败: {{ failedCount }} </span>
<span>成功率: {{ successRate }}%</span>
</div>
</el-card>
</div>
<!-- 上传提示 -->
<div class="upload-tips">
<el-alert
title="上传提示"
type="info"
:closable="false"
show-icon
>
<ul>
<li>支持批量拖拽上传文件</li>
<li>单个文件大小不超过 10MB</li>
<li>支持的文件类型图片文档视频音频等</li>
<li>建议先选择分类再上传文件</li>
<li>上传大文件时请保持网络稳定</li>
</ul>
</el-alert>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, type UploadInstance, type UploadFile } from 'element-plus'
import { UploadFilled, Upload } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/file'
// Props & Emits
const emit = defineEmits(['upload-success'])
//
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 failedCount = ref(0)
const totalCount = ref(0)
const autoUpload = ref(false)
//
const uploadForm = ref({
category: '文档',
})
//
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 && failedCount.value === 0) return 'success'
if (failedCount.value > 0 && uploadedCount.value === totalCount.value) return 'exception'
return undefined
})
const successRate = computed(() => {
if (totalCount.value === 0) return 0
return Math.round((successCount.value / totalCount.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) {
ElMessage.warning(`文件 "${file.name}" 已存在`)
return false
}
return true
}
const handleFileChange = (file: UploadFile, files: UploadFile[]) => {
fileList.value = files
}
const handleFileRemove = (file: UploadFile, files: UploadFile[]) => {
fileList.value = files
}
const removeFile = (index: number) => {
fileList.value.splice(index, 1)
}
const clearFiles = () => {
fileList.value = []
uploadProgress.value = 0
uploadedCount.value = 0
successCount.value = 0
failedCount.value = 0
totalCount.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
failedCount.value = 0
totalCount.value = fileList.value.length
// uploadFile API
for (let i = 0; i < fileList.value.length; i++) {
const fileItem = fileList.value[i]
const file = fileItem.raw as File
if (!file) {
uploadedCount.value++
failedCount.value++
fileItem.status = 'fail'
continue
}
try {
fileItem.status = 'uploading'
// FormData
const formData = new FormData()
formData.append('file', file)
formData.append('category', uploadForm.value.category)
// uploadFile API
const response = await uploadFile(formData, {
category: uploadForm.value.category,
})
if (response.success) {
fileItem.status = 'success'
successCount.value++
ElMessage.success(`文件 "${file.name}" 上传成功`)
} else {
fileItem.status = 'fail'
failedCount.value++
ElMessage.error(response.message || `文件 "${file.name}" 上传失败`)
}
} catch (error: any) {
fileItem.status = 'fail'
failedCount.value++
console.error(`文件 "${file.name}" 上传失败:`, error)
ElMessage.error(`文件 "${file.name}" 上传失败: ${error.message || error}`)
}
uploadedCount.value++
uploadProgress.value = Math.round((uploadedCount.value / totalCount.value) * 100)
}
//
uploading.value = false
if (successCount.value === totalCount.value) {
ElMessage.success('所有文件上传完成')
emit('upload-success')
setTimeout(() => {
clearFiles()
}, 2000)
} else if (successCount.value > 0) {
ElMessage.warning(
`上传完成,成功 ${successCount.value}/${totalCount.value} 个文件`
)
} else {
ElMessage.error('所有文件上传失败')
}
} catch (error) {
console.error('批量上传失败:', error)
ElMessage.error('批量上传失败')
uploading.value = false
}
}
const handleUploadProgress = (event: any, file: UploadFile, fileList: UploadFile[]) => {
// Element Plus upload
if (autoUpload.value) {
//
}
}
const handleUploadSuccess = (response: any, file: UploadFile) => {
uploadedCount.value++
if (response.success) {
file.status = 'success'
successCount.value++
ElMessage.success(`文件 "${file.name}" 上传成功`)
} else {
file.status = 'fail'
failedCount.value++
ElMessage.error(response.message || `文件 "${file.name}" 上传失败`)
}
//
uploadProgress.value = Math.round((uploadedCount.value / totalCount.value) * 100)
//
if (uploadedCount.value === totalCount.value) {
uploading.value = false
if (successCount.value === totalCount.value) {
ElMessage.success('所有文件上传完成')
emit('upload-success')
//
setTimeout(() => {
clearFiles()
}, 2000)
} else if (successCount.value > 0) {
ElMessage.warning(
`上传完成,成功 ${successCount.value}/${totalCount.value} 个文件`
)
} else {
ElMessage.error('所有文件上传失败')
}
}
}
const handleUploadError = (error: any, file: UploadFile) => {
uploadedCount.value++
failedCount.value++
file.status = 'fail'
console.error('上传失败:', error)
ElMessage.error(`文件 "${file.name}" 上传失败`)
//
uploadProgress.value = Math.round((uploadedCount.value / totalCount.value) * 100)
//
if (uploadedCount.value === totalCount.value) {
uploading.value = false
}
}
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 getFileType = (fileName: string) => {
const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase()
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
const docExts = ['.doc', '.docx', '.pdf', '.txt', '.xls', '.xlsx']
const videoExts = ['.mp4', '.avi', '.mov', '.wmv']
const audioExts = ['.mp3', '.wav', '.flac']
if (imageExts.includes(ext)) return '图片'
if (docExts.includes(ext)) return '文档'
if (videoExts.includes(ext)) return '视频'
if (audioExts.includes(ext)) return '音频'
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: #f5f7fa;
border-radius: 8px;
min-height: 500px;
}
.upload-area {
margin-bottom: 20px;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-settings {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.file-preview {
margin-bottom: 20px;
}
.upload-actions {
margin-top: 20px;
text-align: center;
padding: 20px 0;
border-top: 1px solid #eee;
}
.upload-progress {
margin-bottom: 20px;
}
.progress-info {
display: flex;
justify-content: space-around;
margin-top: 16px;
font-size: 14px;
color: #606266;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
.progress-info span {
font-weight: 500;
}
.upload-tips {
margin-top: 20px;
}
.upload-tips ul {
margin: 10px 0 0 20px;
padding: 0;
list-style: disc;
}
.upload-tips li {
margin: 6px 0;
color: #606266;
font-size: 14px;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
:deep(.el-upload__text em) {
color: #409eff;
font-style: normal;
font-weight: 600;
}
</style>

View File

@ -1,872 +1,130 @@
<template> <template>
<el-card class="box-card"> <el-card class="files-management">
<div class="header-bar"> <template #header>
<h2>文件管理</h2> <div class="card-header">
</div> <h2>
<el-icon><Folder /></el-icon>
<div v-if="loading" class="loading-state"> 文件管理系统
<div class="loading-spinner"></div> </h2>
<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>
</div>
<!-- 文件数据列表 -->
<div class="file-list stylish-panel">
<el-table
:data="paginatedFiles"
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> </div>
</template> </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-tabs v-model="activeTab" class="file-tabs" @tab-change="handleTabChange">
<el-pagination <!-- 文件列表 -->
v-model:current-page="currentPage" <el-tab-pane label="文件列表" name="list">
v-model:page-size="pageSize" <template #label>
:page-sizes="[10, 20, 50, 100]" <span class="custom-tabs-label">
:total="totalFiles" <el-icon><List /></el-icon>
layout="total, sizes, prev, pager, next, jumper" <span>文件列表</span>
@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> </span>
</template> </template>
</el-dialog> <FileList @refresh="handleRefresh" />
</el-tab-pane>
<!-- 文件详情对话框 --> <!-- 批量上传 -->
<el-dialog <el-tab-pane label="批量上传" name="upload">
v-model="showFileDetail" <template #label>
:title="currentFile ? currentFile.original_name : '文件详情'" <span class="custom-tabs-label">
width="650px" <el-icon><Upload /></el-icon>
class="file-detail-dialog" <span>批量上传</span>
>
<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> </span>
</template> </template>
</el-dialog> <FileUpload @upload-success="handleUploadSuccess" />
</div> </el-tab-pane>
<!-- 分类管理 -->
<el-tab-pane label="分类管理" name="category">
<template #label>
<span class="custom-tabs-label">
<el-icon><Grid /></el-icon>
<span>分类管理</span>
</span>
</template>
<FileCategory @category-change="handleCategoryChange" />
</el-tab-pane>
</el-tabs>
</el-card> </el-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref } from 'vue'
import { useRouter } from "vue-router"; import { Folder, List, Upload, Grid } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage } from 'element-plus'
import { import FileList from './FileList.vue'
Download, import FileUpload from './FileUpload.vue'
Delete, import FileCategory from './FileCategory.vue'
Search,
Document,
View,
Link,
UploadFilled,
} from "@element-plus/icons-vue";
import { getFilesByTenant, getFileById, deleteFile as deleteFileApi, searchFiles } from "@/api/file";
import { useAuthStore } from "@/stores/auth";
const router = useRouter(); //
const authStore = useAuthStore(); const activeTab = ref<string>('list')
// //
const allFiles = ref<any[]>([]); const handleTabChange = (tabName: string | number) => {
const categories = ref<string[]>([]); console.log('切换到标签页:', tabName)
const filteredFiles = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const searchKeyword = ref("");
const filterCategory = ref("");
const pageSize = ref(10);
const currentPage = ref(1);
const totalFiles = ref(0);
const showUploadDialog = ref(false);
// ID
const getCurrentTenantId = () => {
if (authStore.user && authStore.user.tenant_id) {
return authStore.user.tenant_id;
}
const userInfo = localStorage.getItem('userInfo');
if (userInfo) {
try {
const user = JSON.parse(userInfo);
return user.tenant_id || user.tenantId || 0;
} catch (e) {
console.error('Failed to parse user info:', e);
}
}
return 0;
};
//
async function fetchFiles() {
loading.value = true;
error.value = "";
try {
const tenantId = getCurrentTenantId();
const res = await getFilesByTenant(tenantId);
if (res.success && res.data) {
const files = Array.isArray(res.data) ? res.data : [];
allFiles.value = files.map((file: any) => ({
...file,
upload_time: file.upload_time || file.create_time,
}));
//
const uniqueCategories = new Set<string>();
files.forEach((file: any) => {
if (file.category) {
uniqueCategories.add(file.category);
}
});
categories.value = Array.from(uniqueCategories).sort();
applyFilters();
totalFiles.value = filteredFiles.value.length;
} else {
error.value = res.message || "获取文件列表失败";
allFiles.value = [];
totalFiles.value = 0;
}
} catch (err: any) {
error.value = err.message || "获取文件列表失败";
allFiles.value = [];
totalFiles.value = 0;
} finally {
loading.value = false;
}
} }
// //
function applyFilters() { const handleRefresh = () => {
let result = [...allFiles.value]; ElMessage.success('文件列表已刷新')
//
if (filterCategory.value) {
result = result.filter((file) => file.category === filterCategory.value);
} }
// //
if (searchKeyword.value.trim()) { const handleUploadSuccess = () => {
const keyword = searchKeyword.value.toLowerCase(); //
result = result.filter( activeTab.value = 'list'
(file) => ElMessage.success('文件上传成功,已返回文件列表')
(file.file_name && file.file_name.toLowerCase().includes(keyword)) ||
(file.original_name && file.original_name.toLowerCase().includes(keyword)) ||
(file.category && file.category.toLowerCase().includes(keyword))
);
} }
filteredFiles.value = result; //
totalFiles.value = result.length; const handleCategoryChange = () => {
ElMessage.success('分类已更新')
//
if (currentPage.value > 1 && result.length === 0) {
currentPage.value = 1;
} }
}
//
const formatFileSize = (bytes: number | string | undefined | null): string => {
if (!bytes && bytes !== 0) return "0 B";
const bytesValue = typeof bytes === 'string' ? parseFloat(bytes) : Number(bytes);
if (isNaN(bytesValue) || bytesValue < 0) return "0 B";
if (bytesValue === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytesValue) / Math.log(k));
return parseFloat((bytesValue / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
//
const formatDate = (dateStr: string | number | undefined | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
//
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 = () => {
applyFilters();
};
//
const handleFilter = () => {
fetchFiles();
};
//
const handleSizeChange = (val: number) => {
pageSize.value = val;
currentPage.value = 1;
};
//
const handleCurrentChange = (val: number) => {
currentPage.value = val;
};
//
const handleUploadClose = () => {
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
};
//
const handleUploadSuccess = (response: any, file: any) => {
if (response.success) {
ElMessage.success('文件上传成功!');
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
fetchFiles();
} else {
ElMessage.error(response.message || '文件上传失败!');
}
};
//
const handleUploadError = (error: Error, file: any) => {
ElMessage.error('文件上传失败:' + (error.message || error));
};
//
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
//
};
//
const viewFile = async (row: any) => {
try {
loading.value = true;
const res = await getFileById(row.id);
if (res.success && res.data) {
currentFile.value = res.data;
showFileDetail.value = true;
} else {
ElMessage.error(res.message || "获取文件详情失败");
}
} catch (err: any) {
ElMessage.error(err.message || "获取文件详情失败");
} finally {
loading.value = false;
}
};
//
const copyFileUrl = (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const fileUrl = `${baseUrl}/api/files/${file.id}/download`;
if (navigator.clipboard) {
navigator.clipboard.writeText(fileUrl).then(() => {
ElMessage.success('文件链接已复制到剪贴板');
}).catch(() => {
ElMessage.error('复制失败,请手动复制');
});
} else {
//
const textarea = document.createElement('textarea');
textarea.value = fileUrl;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
ElMessage.success('文件链接已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动复制');
}
document.body.removeChild(textarea);
}
};
//
const downloadFile = (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const downloadUrl = `${baseUrl}/api/files/${file.id}/download`;
const token = localStorage.getItem('token');
const link = document.createElement('a');
link.href = downloadUrl;
if (token) {
// token使fetchblob
fetch(downloadUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
link.href = url;
link.download = file.original_name || file.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('文件下载开始');
})
.catch(err => {
ElMessage.error('文件下载失败:' + err.message);
});
} else {
link.download = file.original_name || file.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
//
const deleteFile = async (file: any) => {
try {
await ElMessageBox.confirm(
`确定要删除文件「${file.original_name || file.file_name}」吗?删除后不可恢复。`,
'警告',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
}
);
loading.value = true;
const res = await deleteFileApi(file.id);
if (res.success) {
ElMessage.success('删除成功');
await fetchFiles();
} else {
ElMessage.error(res.message || '删除失败');
}
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败');
}
} finally {
loading.value = false;
}
};
//
const paginatedFiles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredFiles.value.slice(start, end);
});
//
watch([filteredFiles, currentPage, pageSize], () => {
// filteredFiles computed 使 paginatedFiles
}, { immediate: true });
onMounted(() => {
fetchFiles();
});
</script> </script>
<style scoped> <style scoped>
.fancy-bg { .files-management {
padding: 28px 14px 24px 14px; min-height: calc(100vh - 120px);
} }
.stylish-panel {
background: #fff; .card-header {
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; display: flex;
align-items: center; align-items: center;
padding: 26px 16px; justify-content: space-between;
min-height: 95px;
} }
.card-icon {
font-size: 34px; .card-header h2 {
width: 54px; margin: 0;
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; font-size: 20px;
color: #409eff;
margin-right: 8px;
}
.recent-title {
font-size: 17px;
font-weight: 600; font-weight: 600;
color: #293a46; color: #303133;
}
.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; display: flex;
align-items: center; align-items: center;
gap: 8px;
} }
.icon-doc {
margin-right: 8px; .file-tabs {
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; margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 20px;
} }
.file-detail-dialog .el-dialog__body {
padding-top: 18px; .custom-tabs-label {
background: #fafcff; display: flex;
align-items: center;
gap: 6px;
} }
.file-detail {
max-height: 380px; :deep(.el-tabs__header) {
overflow-y: auto; margin-bottom: 20px;
} }
.detail-primary {
color: #416cf7; :deep(.el-tabs__item) {
font-size: 15px;
padding: 0 20px;
height: 45px;
line-height: 45px;
}
:deep(.el-tabs__item.is-active) {
color: #409eff;
font-weight: 600; 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> </style>

View File

@ -145,8 +145,8 @@
<el-radio-button :value="3">权限按钮</el-radio-button> <el-radio-button :value="3">权限按钮</el-radio-button>
</el-radio-group> </el-radio-group>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);"> <div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
<div> 页面菜单有路由和组件地址可访问完整功能页面</div> <div> 页面菜单有路由和组件地址用于<span style="color: var(--el-color-primary);">页面管理</span></div>
<div> 目录菜单只有路由地址用于组织结构无实际页面</div> <div> 目录菜单只有路由地址用于<span style="color: var(--el-color-primary);">接口管理</span><span style="color: var(--el-color-primary);">目录管理</span></div>
<div> 权限按钮无路由和组件仅用于权限控制</div> <div> 权限按钮无路由和组件仅用于权限控制</div>
</div> </div>
</el-form-item> </el-form-item>

View File

@ -128,7 +128,7 @@
</el-form-item> </el-form-item>
<el-form-item label="角色" v-if="dialogTitle !== '修改密码'"> <el-form-item label="角色" v-if="dialogTitle !== '修改密码'">
<el-select <el-select
v-model="form.roleId" v-model="form.role"
placeholder="请选择角色" placeholder="请选择角色"
style="width: 100%" style="width: 100%"
:loading="loadingRoles" :loading="loadingRoles"
@ -167,6 +167,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh } from "@element-plus/icons-vue"; import { Plus, Refresh } from "@element-plus/icons-vue";
import { import {
getAllUsers, getAllUsers,
getTenantUsers,
addUser, addUser,
editUser, editUser,
deleteUser, deleteUser,
@ -269,8 +270,10 @@ const validateConfirmPassword = (password: string, confirmPassword: string) => {
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true; loading.value = true;
// tenantid
let tenantId = getCurrentTenantId ? getCurrentTenantId() : null;
try { try {
const res = await getAllUsers(); const res = await getTenantUsers(tenantId);
// //
let userList: any[] = []; let userList: any[] = [];
if (Array.isArray(res)) { if (Array.isArray(res)) {
@ -286,14 +289,12 @@ const fetchUsers = async () => {
users.value = userList.map((item: any) => { users.value = userList.map((item: any) => {
// //
let roleName = ''; let roleName = '';
let roleId = item.role_id || item.roleId || null; let roleValue = item.role || null; // role ID
if (roleId) {
const role = roleList.value.find(r => r.roleId === roleId); if (roleValue) {
roleName = role ? role.roleName : ''; // ID
} else if (item.role) { const roleInfo = roleList.value.find(r => r.roleId === roleValue);
// roleroleList roleName = roleInfo ? roleInfo.roleName : '';
const role = roleList.value.find(r => r.roleCode === item.role);
roleName = role ? role.roleName : (item.role === 'admin' ? '管理员' : '普通用户');
} }
return { return {
@ -301,8 +302,7 @@ const fetchUsers = async () => {
username: item.username, username: item.username,
nickname: item.nickname, nickname: item.nickname,
email: item.email, email: item.email,
role: item.role || '', role: roleValue, // role ID
roleId: roleId,
roleName: roleName, roleName: roleName,
status: item.status || "active", status: item.status || "active",
lastLoginTime: item.lastLoginTime lastLoginTime: item.lastLoginTime
@ -343,13 +343,12 @@ const dialogVisible = ref(false);
const dialogTitle = ref(""); const dialogTitle = ref("");
const isEdit = ref(false); const isEdit = ref(false);
const form = ref<any>({ const form = ref<any>({
id: 0, id: null,
username: "", username: "",
nickname: "", nickname: "",
password: "", password: "",
email: "", email: "",
roleId: null, role: null, // role ID
role: "",
status: "active", status: "active",
tenant_id: null, tenant_id: null,
}); });
@ -397,13 +396,12 @@ const handleAddUser = () => {
} }
form.value = { form.value = {
id: 0, id: 0, // ID0
username: "", username: "",
nickname: "", nickname: "",
password: "", password: "",
email: "", email: "",
roleId: null, role: null, // role ID
role: "",
status: "active", status: "active",
tenant_id: tenantId, tenant_id: tenantId,
}; };
@ -420,12 +418,15 @@ const handleEdit = async (user: User) => {
// ID // ID
const tenantId = getCurrentTenantId(); const tenantId = getCurrentTenantId();
// ID // data.role ID
let roleId = data.role_id || data.roleId || null; let roleValue = data.role || null;
if (!roleId && data.role) {
// roleroleListroleId // 01使"active""inactive"
const role = roleList.value.find(r => r.roleCode === data.role); let statusStr = "active";
roleId = role ? role.roleId : null; if (typeof data.status === 'number') {
statusStr = data.status === 1 ? "active" : "inactive";
} else if (typeof data.status === 'string') {
statusStr = data.status;
} }
form.value = { form.value = {
@ -434,9 +435,8 @@ const handleEdit = async (user: User) => {
nickname: data.nickname, nickname: data.nickname,
password: "", password: "",
email: data.email, email: data.email,
roleId: roleId, role: roleValue, // role ID
role: data.role || "", status: statusStr,
status: data.status || "active",
tenant_id: data.tenant_id || tenantId, tenant_id: data.tenant_id || tenantId,
}; };
} catch (e) { } catch (e) {
@ -502,25 +502,31 @@ const submitForm = async () => {
passwordError.value = res.message || "密码修改失败"; passwordError.value = res.message || "密码修改失败";
} }
} else if (isEdit.value) { } else if (isEdit.value) {
// 使roleId // ID
if (!form.value.id || form.value.id === 0) {
ElMessage.error("用户ID不能为空请重新选择用户");
return;
}
//
const submitData: any = { const submitData: any = {
id: form.value.id,
username: form.value.username, username: form.value.username,
nickname: form.value.nickname, nickname: form.value.nickname,
email: form.value.email, email: form.value.email,
status: form.value.status, status: form.value.status,
}; };
if (form.value.roleId) { // role ID
submitData.roleId = form.value.roleId; if (form.value.role) {
} else if (form.value.role) { submitData.role = form.value.role; // role
// roleIdrole
submitData.role = form.value.role;
} }
if (form.value.tenant_id) { if (form.value.tenant_id) {
submitData.tenant_id = form.value.tenant_id; submitData.tenant_id = form.value.tenant_id;
} }
console.log('编辑用户提交数据:', submitData); //
await editUser(form.value.id, submitData); await editUser(form.value.id, submitData);
ElMessage.success({ ElMessage.success({
message: "更新成功", message: "更新成功",
@ -533,7 +539,7 @@ const submitForm = async () => {
dialogVisible.value = false; dialogVisible.value = false;
fetchUsers(); fetchUsers();
} else { } else {
// 使roleId //
const submitData: any = { const submitData: any = {
username: form.value.username, username: form.value.username,
nickname: form.value.nickname, nickname: form.value.nickname,
@ -542,16 +548,16 @@ const submitForm = async () => {
status: form.value.status, status: form.value.status,
}; };
if (form.value.roleId) { // role ID
submitData.roleId = form.value.roleId; if (form.value.role) {
} else if (form.value.role) { submitData.role = form.value.role; // role
submitData.role = form.value.role;
} }
if (form.value.tenant_id) { if (form.value.tenant_id) {
submitData.tenant_id = form.value.tenant_id; submitData.tenant_id = form.value.tenant_id;
} }
console.log('添加用户提交数据:', submitData); //
await addUser(submitData); await addUser(submitData);
ElMessage.success({ ElMessage.success({
message: "添加成功", message: "添加成功",

View File

@ -1,7 +1,12 @@
package controllers package controllers
import ( import (
"crypto/md5"
"encoding/hex"
"encoding/json" "encoding/json"
"io"
"net/http"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@ -455,6 +460,12 @@ func (c *FileController) Post() {
username = "unknown" username = "unknown"
} }
// 从JWT中间件获取租户ID
tenantID := "default"
if tid, ok := c.Ctx.Input.GetData("tenantId").(int); ok && tid > 0 {
tenantID = strconv.Itoa(tid)
}
// 获取上传的文件 // 获取上传的文件
file, header, err := c.GetFile("file") file, header, err := c.GetFile("file")
if err != nil { if err != nil {
@ -473,18 +484,91 @@ func (c *FileController) Post() {
fileExt := strings.ToLower(filepath.Ext(originalName)) fileExt := strings.ToLower(filepath.Ext(originalName))
fileName := strings.TrimSuffix(originalName, fileExt) fileName := strings.TrimSuffix(originalName, fileExt)
// 读取文件内容到内存以计算MD5对于大文件可能需要优化
fileData := make([]byte, fileSize)
n, err := file.Read(fileData)
if err != nil && err != io.EOF {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "读取文件失败: " + err.Error(),
}
c.ServeJSON()
return
}
if int64(n) != fileSize {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "读取文件不完整",
}
c.ServeJSON()
return
}
// 计算文件MD5值
hash := md5.New()
hash.Write(fileData)
fileMD5 := hex.EncodeToString(hash.Sum(nil))
// 检查是否已存在相同MD5的文件
existingFile, err := models.GetFileByMD5AndTenant(fileMD5, tenantID)
if err == nil && existingFile != nil {
// 文件已存在,不保存文件,只创建数据库记录
// 生成日期路径(年/月/日)
now := time.Now()
// 创建文件信息记录(使用已存在的文件路径)
fileInfo := models.FileInfo{
TenantID: tenantID,
UserID: userID,
FileName: fileName,
OriginalName: originalName,
FilePath: existingFile.FilePath, // 使用已存在文件的路径
FileURL: existingFile.FileURL, // 使用已存在文件的URL
FileSize: fileSize,
FileType: getFileTypeByExt(fileExt),
FileExt: fileExt,
MD5: fileMD5,
Category: c.GetString("category"),
Status: 1,
UploadBy: username,
UploadTime: now,
}
// 如果分类为空,使用默认分类
if fileInfo.Category == "" {
fileInfo.Category = "未分类"
}
// 保存到数据库(只保存记录,不保存文件)
id, err := models.AddFile(&fileInfo)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "保存文件信息失败: " + err.Error(),
}
c.ServeJSON()
return
}
fileInfo.ID = id
// 返回成功响应
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "文件上传成功(重复文件,使用已有文件)",
"data": fileInfo,
}
c.ServeJSON()
return
}
// 文件不存在,正常上传流程
// 获取分类(可选) // 获取分类(可选)
category := c.GetString("category") category := c.GetString("category")
if category == "" { if category == "" {
category = "未分类" category = "未分类"
} }
// 获取租户ID可选从请求参数或中间件获取
tenantID := c.GetString("tenant_id")
if tenantID == "" {
tenantID = "default"
}
// 生成日期路径(年/月/日) // 生成日期路径(年/月/日)
now := time.Now() now := time.Now()
datePath := now.Format("2006/01/02") datePath := now.Format("2006/01/02")
@ -513,8 +597,8 @@ func (c *FileController) Post() {
// 计算相对路径(用于存储到数据库) // 计算相对路径(用于存储到数据库)
relativePath := path.Join("uploads", datePath, uniqueFileName) relativePath := path.Join("uploads", datePath, uniqueFileName)
// 保存文件 // 保存文件(将已读取的数据写入文件)
if err := c.SaveToFile("file", savePath); err != nil { if err := os.WriteFile(savePath, fileData, 0644); err != nil {
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
"success": false, "success": false,
"message": "保存文件失败: " + err.Error(), "message": "保存文件失败: " + err.Error(),
@ -524,19 +608,7 @@ func (c *FileController) Post() {
} }
// 获取文件类型 // 获取文件类型
fileType := "other" fileType := getFileTypeByExt(fileExt)
switch fileExt {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
fileType = "image"
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".txt":
fileType = "document"
case ".mp4", ".avi", ".mov", ".wmv":
fileType = "video"
case ".mp3", ".wav", ".flac":
fileType = "audio"
case ".zip", ".rar", ".7z":
fileType = "archive"
}
// 构造文件URL相对路径 // 构造文件URL相对路径
fileURL := "/" + relativePath fileURL := "/" + relativePath
@ -552,7 +624,9 @@ func (c *FileController) Post() {
FileSize: fileSize, FileSize: fileSize,
FileType: fileType, FileType: fileType,
FileExt: fileExt, FileExt: fileExt,
MD5: fileMD5,
Category: category, Category: category,
Status: 1, // 设置为正常状态
UploadBy: username, UploadBy: username,
UploadTime: now, UploadTime: now,
} }
@ -580,3 +654,351 @@ func (c *FileController) Post() {
} }
c.ServeJSON() c.ServeJSON()
} }
// DownloadFile 下载文件
func (c *FileController) DownloadFile() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "参数错误",
}
c.ServeJSON()
return
}
// 获取文件信息
file, err := models.GetFileById(id)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "文件不存在",
}
c.ServeJSON()
return
}
// 获取实际的文件记录如果文件不存在通过MD5查找
actualFile, err := getActualFile(file)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "获取文件信息失败",
}
c.ServeJSON()
return
}
// 检查文件是否存在(尝试多个可能的路径)
var filePath string
possiblePaths := []string{
actualFile.FilePath, // 直接使用相对路径
filepath.Join("server", actualFile.FilePath), // server目录前缀
filepath.Join(".", actualFile.FilePath), // 当前目录
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
filePath = path
break
}
}
if filePath == "" {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "文件不存在于服务器",
}
c.ServeJSON()
return
}
// 设置响应头
c.Ctx.Output.Header("Content-Description", "File Transfer")
c.Ctx.Output.Header("Content-Type", "application/octet-stream")
c.Ctx.Output.Header("Content-Disposition", "attachment; filename="+url.QueryEscape(actualFile.OriginalName))
c.Ctx.Output.Header("Content-Transfer-Encoding", "binary")
c.Ctx.Output.Header("Expires", "0")
c.Ctx.Output.Header("Cache-Control", "must-revalidate")
c.Ctx.Output.Header("Pragma", "public")
// 输出文件
http.ServeFile(c.Ctx.ResponseWriter, c.Ctx.Request, filePath)
}
// PreviewFile 预览文件
func (c *FileController) PreviewFile() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "参数错误",
}
c.ServeJSON()
return
}
// 获取文件信息
file, err := models.GetFileById(id)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "文件不存在",
}
c.ServeJSON()
return
}
// 获取实际的文件记录如果文件不存在通过MD5查找
actualFile, err := getActualFile(file)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "获取文件信息失败",
}
c.ServeJSON()
return
}
// 检查文件是否可预览
if !actualFile.CanPreview() {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "该文件类型不支持预览",
}
c.ServeJSON()
return
}
// 检查文件是否存在(尝试多个可能的路径)
var filePath string
possiblePaths := []string{
actualFile.FilePath, // 直接使用相对路径
filepath.Join("server", actualFile.FilePath), // server目录前缀
filepath.Join(".", actualFile.FilePath), // 当前目录
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
filePath = path
break
}
}
if filePath == "" {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "文件不存在于服务器",
}
c.ServeJSON()
return
}
// 设置正确的 Content-Type
contentType := getContentType(actualFile.FileExt)
c.Ctx.Output.Header("Content-Type", contentType)
c.Ctx.Output.Header("Content-Disposition", "inline; filename="+url.QueryEscape(actualFile.OriginalName))
// 打开文件
fileHandle, err := os.Open(filePath)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "打开文件失败: " + err.Error(),
}
c.ServeJSON()
return
}
defer fileHandle.Close()
// 复制文件内容到响应
io.Copy(c.Ctx.ResponseWriter, fileHandle)
}
// PublicPreviewFile 公开预览文件(用于 Office Online Viewer无需认证但仅用于预览
func (c *FileController) PublicPreviewFile() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "参数错误",
}
c.ServeJSON()
return
}
// 获取文件信息
file, err := models.GetFileById(id)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "文件不存在",
}
c.ServeJSON()
return
}
// 获取实际的文件记录
actualFile, err := getActualFile(file)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "获取文件信息失败",
}
c.ServeJSON()
return
}
// 检查文件是否可预览
if !actualFile.CanPreview() {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "该文件类型不支持预览",
}
c.ServeJSON()
return
}
// 检查文件是否存在
var filePath string
possiblePaths := []string{
actualFile.FilePath,
filepath.Join("server", actualFile.FilePath),
filepath.Join(".", actualFile.FilePath),
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
filePath = path
break
}
}
if filePath == "" {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "文件不存在于服务器",
}
c.ServeJSON()
return
}
// 设置正确的 Content-Type
contentType := getContentType(actualFile.FileExt)
c.Ctx.Output.Header("Content-Type", contentType)
c.Ctx.Output.Header("Content-Disposition", "inline; filename="+url.QueryEscape(actualFile.OriginalName))
// 允许跨域访问Office Online Viewer 需要)
c.Ctx.Output.Header("Access-Control-Allow-Origin", "*")
// 打开文件
fileHandle, err := os.Open(filePath)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "打开文件失败: " + err.Error(),
}
c.ServeJSON()
return
}
defer fileHandle.Close()
// 复制文件内容到响应
io.Copy(c.Ctx.ResponseWriter, fileHandle)
}
// getFileTypeByExt 根据文件扩展名获取文件类型
func getFileTypeByExt(ext string) string {
ext = strings.ToLower(ext)
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
return "image"
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".txt":
return "document"
case ".mp4", ".avi", ".mov", ".wmv":
return "video"
case ".mp3", ".wav", ".flac":
return "audio"
case ".zip", ".rar", ".7z":
return "archive"
default:
return "other"
}
}
// getContentType 根据文件扩展名获取 Content-Type
func getContentType(ext string) string {
ext = strings.ToLower(ext)
contentTypes := map[string]string{
// 图片
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
".svg": "image/svg+xml",
// 文档
".pdf": "application/pdf",
".txt": "text/plain; charset=utf-8",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
// 视频
".mp4": "video/mp4",
".webm": "video/webm",
// 音频
".mp3": "audio/mpeg",
".wav": "audio/wav",
}
if contentType, ok := contentTypes[ext]; ok {
return contentType
}
return "application/octet-stream"
}
// getActualFile 获取实际的文件记录如果当前记录的文件不存在通过MD5查找唯一文件
func getActualFile(file *models.FileInfo) (*models.FileInfo, error) {
// 检查当前记录的文件是否存在(尝试多个可能的路径)
possiblePaths := []string{
file.FilePath, // 直接使用相对路径
filepath.Join("server", file.FilePath), // server目录前缀
filepath.Join(".", file.FilePath), // 当前目录
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
// 文件存在,返回当前记录
return file, nil
}
}
// 文件不存在通过MD5查找唯一文件
if file.MD5 == "" {
return file, nil
}
actualFile, err := models.GetFileByMD5(file.MD5)
if err == nil && actualFile != nil {
// 检查找到的文件是否存在
possiblePaths = []string{
actualFile.FilePath,
filepath.Join("server", actualFile.FilePath),
filepath.Join(".", actualFile.FilePath),
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
return actualFile, nil
}
}
}
// 如果找不到,返回原始记录
return file, nil
}

View File

@ -25,6 +25,8 @@ func (c *UserController) GetAllUsers() {
"avatar": user.Avatar, "avatar": user.Avatar,
"nickname": user.Nickname, "nickname": user.Nickname,
"tenant_id": user.TenantId, "tenant_id": user.TenantId,
"status": user.Status,
"role": user.Role,
"lastLoginTime": user.LastLoginTime, "lastLoginTime": user.LastLoginTime,
}) })
} }
@ -37,6 +39,56 @@ func (c *UserController) GetAllUsers() {
c.ServeJSON() c.ServeJSON()
} }
// GetTenantUsers 获取指定租户下的所有用户(排除已删除的用户)
func (c *UserController) GetTenantUsers() {
// 从URL参数获取租户ID
tenantId, err := c.GetInt(":tenantId")
if err != nil || tenantId <= 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "租户ID无效",
"data": nil,
}
c.ServeJSON()
return
}
// 调用模型层方法查询
users, err := models.GetTenantUsers(tenantId)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": err.Error(),
"data": nil,
}
c.ServeJSON()
return
}
// 格式化返回数据
userList := make([]map[string]interface{}, 0)
for _, user := range users {
userList = append(userList, map[string]interface{}{
"id": user.Id,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"nickname": user.Nickname,
"tenant_id": user.TenantId,
"status": user.Status,
"role": user.Role,
"last_login_time": user.LastLoginTime,
})
}
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "获取租户用户列表成功",
"data": userList,
}
c.ServeJSON()
}
// ChangePassword 修改用户密码 // ChangePassword 修改用户密码
func (c *UserController) ChangePassword() { func (c *UserController) ChangePassword() {
// 从URL获取用户ID // 从URL获取用户ID
@ -144,6 +196,8 @@ func (c *UserController) GetUserInfo() {
"avatar": user.Avatar, "avatar": user.Avatar,
"nickname": user.Nickname, "nickname": user.Nickname,
"tenant_id": user.TenantId, "tenant_id": user.TenantId,
"role": user.Role,
"status": user.Status,
}, },
} }
c.ServeJSON() c.ServeJSON()
@ -159,6 +213,7 @@ func (c *UserController) AddUser() {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
TenantId int `json:"tenant_id"` TenantId int `json:"tenant_id"`
Role int `json:"role"` // 角色ID
} }
// 解析请求体JSON数据 // 解析请求体JSON数据
@ -210,6 +265,7 @@ func (c *UserController) AddUser() {
userData.Nickname, userData.Nickname,
userData.Avatar, userData.Avatar,
userData.TenantId, userData.TenantId,
userData.Role, // 添加 role 参数
) )
if err != nil { if err != nil {
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
@ -243,6 +299,8 @@ func (c *UserController) EditUser() {
Email string `json:"email"` Email string `json:"email"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Status string `json:"status"`
Role int `json:"role"` // 改为 role存储角色ID
} }
// 解析请求体JSON // 解析请求体JSON
@ -275,6 +333,8 @@ func (c *UserController) EditUser() {
updateData.Email, updateData.Email,
updateData.Nickname, updateData.Nickname,
updateData.Avatar, updateData.Avatar,
updateData.Status,
updateData.Role, // 改为 Role
) )
if err != nil { if err != nil {
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{

View File

@ -63,5 +63,6 @@ func JWTAuthMiddleware() web.FilterFunc {
// 将用户信息存储在上下文 // 将用户信息存储在上下文
ctx.Input.SetData("userId", claims.UserID) ctx.Input.SetData("userId", claims.UserID)
ctx.Input.SetData("username", claims.Username) ctx.Input.SetData("username", claims.Username)
ctx.Input.SetData("tenantId", claims.TenantId)
} }
} }

View File

@ -1,7 +1,9 @@
package models package models
import ( import (
"strings"
"time" "time"
"github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/client/orm"
) )
@ -22,6 +24,7 @@ type FileInfo struct {
FileSize int64 `orm:"column(file_size);default(0)" json:"file_size"` FileSize int64 `orm:"column(file_size);default(0)" json:"file_size"`
FileType string `orm:"column(file_type);size(50)" json:"file_type"` FileType string `orm:"column(file_type);size(50)" json:"file_type"`
FileExt string `orm:"column(file_ext);size(20)" json:"file_ext"` FileExt string `orm:"column(file_ext);size(20)" json:"file_ext"`
MD5 string `orm:"column(md5);size(32);null" json:"md5"`
// 分类信息 // 分类信息
Category string `orm:"column(category);size(100)" json:"category"` Category string `orm:"column(category);size(100)" json:"category"`
@ -34,6 +37,7 @@ type FileInfo struct {
// 上传信息 // 上传信息
UploadBy string `orm:"column(upload_by);size(100)" json:"upload_by"` UploadBy string `orm:"column(upload_by);size(100)" json:"upload_by"`
UploadTime time.Time `orm:"column(upload_time);type(datetime);auto_now_add" json:"upload_time"` UploadTime time.Time `orm:"column(upload_time);type(datetime);auto_now_add" json:"upload_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time,omitempty"`
// 关联的用户信息(非数据库字段) // 关联的用户信息(非数据库字段)
User *User `orm:"-" json:"user,omitempty"` User *User `orm:"-" json:"user,omitempty"`
@ -44,6 +48,31 @@ func (f *FileInfo) TableName() string {
return "yz_files" return "yz_files"
} }
// CanPreview 判断文件是否可以在线预览
func (f *FileInfo) CanPreview() bool {
previewableExts := map[string]bool{
// 图片格式
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".bmp": true,
".webp": true,
".svg": true,
// 文档格式(仅支持 .docx不支持旧的 .doc、Excel、PPT
".pdf": true,
".txt": true,
".docx": true,
// 视频格式
".mp4": true,
".webm": true,
// 音频格式
".mp3": true,
".wav": true,
}
return previewableExts[strings.ToLower(f.FileExt)]
}
// GetAllFiles 获取所有文件信息 // GetAllFiles 获取所有文件信息
func GetAllFiles() ([]*FileInfo, error) { func GetAllFiles() ([]*FileInfo, error) {
o := orm.NewOrm() o := orm.NewOrm()
@ -109,7 +138,7 @@ func UpdateFile(file *FileInfo) error {
return err return err
} }
// DeleteFile 删除文件信息软删除设置状态为0 // DeleteFile 删除文件信息软删除设置状态为0并记录删除时间
func DeleteFile(id int64) error { func DeleteFile(id int64) error {
o := orm.NewOrm() o := orm.NewOrm()
file := &FileInfo{ID: id} file := &FileInfo{ID: id}
@ -117,7 +146,9 @@ func DeleteFile(id int64) error {
return err return err
} }
file.Status = 0 file.Status = 0
_, err := o.Update(file, "Status") now := time.Now()
file.DeleteTime = &now
_, err := o.Update(file, "Status", "DeleteTime")
return err return err
} }
@ -159,22 +190,45 @@ func GetFileStatistics(tenantID string) (map[string]interface{}, error) {
}, nil }, nil
} }
// SearchFiles 搜索文件 // SearchFiles 搜索文件(通过原始文件名搜索)
func SearchFiles(keyword string, tenantID string) ([]*FileInfo, error) { func SearchFiles(keyword string, tenantID string) ([]*FileInfo, error) {
o := orm.NewOrm() o := orm.NewOrm()
var files []*FileInfo var files []*FileInfo
// 构建查询条件 // 构建查询条件 - 只通过原始文件名搜索
qs := o.QueryTable("yz_files").Filter("tenant_id", tenantID).Filter("status", 1) qs := o.QueryTable("yz_files").Filter("tenant_id", tenantID).Filter("status", 1)
// 搜索文件名、原始文件名、分类使用or条件 // 搜索原始文件名
cond := orm.NewCondition() qs = qs.Filter("original_name__icontains", keyword)
cond = cond.Or("file_name__icontains", keyword).
Or("original_name__icontains", keyword).
Or("category__icontains", keyword)
qs = qs.SetCond(cond)
_, err := qs.OrderBy("-upload_time").All(&files) _, err := qs.OrderBy("-upload_time").All(&files)
return files, err return files, err
} }
// GetFileByMD5 根据MD5获取文件信息获取唯一的文件记录
func GetFileByMD5(md5 string) (*FileInfo, error) {
o := orm.NewOrm()
var file FileInfo
err := o.QueryTable("yz_files").Filter("md5", md5).Filter("status", 1).OrderBy("upload_time").One(&file)
if err == orm.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &file, nil
}
// GetFileByMD5AndTenant 根据MD5和租户ID获取文件信息
func GetFileByMD5AndTenant(md5 string, tenantID string) (*FileInfo, error) {
o := orm.NewOrm()
var file FileInfo
err := o.QueryTable("yz_files").Filter("md5", md5).Filter("tenant_id", tenantID).Filter("status", 1).OrderBy("upload_time").One(&file)
if err == orm.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &file, nil
}

View File

@ -24,6 +24,8 @@ type User struct {
Email string Email string
Avatar string Avatar string
Nickname string Nickname string
Status int `orm:"column(status);default(1)" json:"status"`
Role int `orm:"column(role);default(0)" json:"role"`
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"` DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
LastLoginTime *time.Time `orm:"column(last_login_time);null;type(datetime)" json:"last_login_time"` LastLoginTime *time.Time `orm:"column(last_login_time);null;type(datetime)" json:"last_login_time"`
} }
@ -147,6 +149,20 @@ func GetAllUsers(tenantId int) []*User {
return users return users
} }
// GetTenantUsers 获取指定租户下的所有用户(排除已删除的用户)
func GetTenantUsers(tenantId int) ([]*User, error) {
o := orm.NewOrm()
var users []*User
// 查询指定租户下未删除的用户
_, err := o.Raw("SELECT * FROM yz_users WHERE tenant_id = ? AND delete_time IS NULL ORDER BY id DESC", tenantId).QueryRows(&users)
if err != nil {
return nil, fmt.Errorf("查询租户用户失败: %v", err)
}
return users, nil
}
// GetUserInfo 根据用户ID或用户名获取用户 // GetUserInfo 根据用户ID或用户名获取用户
func GetUserInfo(userId int, username string, tenantId int) (*User, error) { func GetUserInfo(userId int, username string, tenantId int) (*User, error) {
o := orm.NewOrm() o := orm.NewOrm()
@ -221,7 +237,7 @@ func ValidateUser(username, password string, tenantName string) (*User, error) {
} }
// AddUser 向数据库添加新用户 // AddUser 向数据库添加新用户
func AddUser(username, password, email, nickname, avatar string, tenantId int) (*User, error) { func AddUser(username, password, email, nickname, avatar string, tenantId, role int) (*User, error) {
// 1. 验证租户是否存在且有效 // 1. 验证租户是否存在且有效
o := orm.NewOrm() o := orm.NewOrm()
var tenantExists bool var tenantExists bool
@ -258,11 +274,12 @@ func AddUser(username, password, email, nickname, avatar string, tenantId int) (
user := &User{ user := &User{
TenantId: tenantId, TenantId: tenantId,
Username: username, Username: username,
Password: hashedPassword, // 存储加密后的密码 Password: hashedPassword,
Salt: salt, // 存储盐值(用于后续验证) Salt: salt,
Email: email, Email: email,
Nickname: nickname, Nickname: nickname,
Avatar: avatar, Avatar: avatar,
Role: role, // 设置角色ID
} }
// 5. 插入数据库(使用之前定义的 o // 5. 插入数据库(使用之前定义的 o
@ -276,7 +293,7 @@ func AddUser(username, password, email, nickname, avatar string, tenantId int) (
} }
// EditUser 更新用户信息 // EditUser 更新用户信息
func EditUser(id int, username, email, nickname, avatar string) (*User, error) { func EditUser(id int, username, email, nickname, avatar, status string, roleId int) (*User, error) {
// 根据ID查询用户 // 根据ID查询用户
o := orm.NewOrm() o := orm.NewOrm()
user := &User{} user := &User{}
@ -307,6 +324,20 @@ func EditUser(id int, username, email, nickname, avatar string) (*User, error) {
user.Avatar = avatar user.Avatar = avatar
} }
// 更新状态(将字符串转换为数字)
if status != "" {
if status == "active" {
user.Status = 1
} else if status == "inactive" {
user.Status = 0
}
}
// 更新角色ID
if roleId > 0 {
user.Role = roleId
}
// 执行数据库更新 // 执行数据库更新
_, err = o.Update(user) _, err = o.Update(user)
if err != nil { if err != nil {

1640
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
server/package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"vue-office": "^0.0.5"
}
}

View File

@ -3,6 +3,7 @@ package routers
import ( import (
"server/controllers" "server/controllers"
"server/middleware" "server/middleware"
"strings"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context" "github.com/beego/beego/v2/server/web/context"
@ -44,6 +45,11 @@ func init() {
} }
} }
// 检查是否为公开预览接口(/api/files/public-preview/:id
if strings.HasPrefix(path, "/api/files/public-preview/") {
skipAuth = true
}
if !skipAuth { if !skipAuth {
middleware.JWTAuthMiddleware()(ctx) middleware.JWTAuthMiddleware()(ctx)
} }
@ -63,6 +69,7 @@ func init() {
beego.Router("/api/deleteUser/:id", &controllers.UserController{}, "delete:DeleteUser") beego.Router("/api/deleteUser/:id", &controllers.UserController{}, "delete:DeleteUser")
beego.Router("/api/changePassword/:id", &controllers.UserController{}, "post:ChangePassword") beego.Router("/api/changePassword/:id", &controllers.UserController{}, "post:ChangePassword")
beego.Router("/api/reset-password", &controllers.UserController{}, "post:ResetPassword") beego.Router("/api/reset-password", &controllers.UserController{}, "post:ResetPassword")
beego.Router("/api/tenantUsers/:tenantId", &controllers.UserController{}, "get:GetTenantUsers")
// 认证路由 // 认证路由
beego.Router("/api/login", &controllers.AuthController{}, "post:Login") beego.Router("/api/login", &controllers.AuthController{}, "post:Login")
@ -84,6 +91,9 @@ func init() {
beego.Router("/api/files", &controllers.FileController{}, "get:GetAllFiles") beego.Router("/api/files", &controllers.FileController{}, "get:GetAllFiles")
beego.Router("/api/files", &controllers.FileController{}, "post:Post") beego.Router("/api/files", &controllers.FileController{}, "post:Post")
beego.Router("/api/files/my", &controllers.FileController{}, "get:GetMyFiles") beego.Router("/api/files/my", &controllers.FileController{}, "get:GetMyFiles")
beego.Router("/api/files/download/:id", &controllers.FileController{}, "get:DownloadFile")
beego.Router("/api/files/preview/:id", &controllers.FileController{}, "get:PreviewFile")
beego.Router("/api/files/public-preview/:id", &controllers.FileController{}, "get:PublicPreviewFile")
beego.Router("/api/files/:id", &controllers.FileController{}, "get:GetFileById") beego.Router("/api/files/:id", &controllers.FileController{}, "get:GetFileById")
beego.Router("/api/files/tenant", &controllers.FileController{}, "get:GetFilesByTenant") beego.Router("/api/files/tenant", &controllers.FileController{}, "get:GetFilesByTenant")
beego.Router("/api/files/:id", &controllers.FileController{}, "put:UpdateFile") beego.Router("/api/files/:id", &controllers.FileController{}, "put:UpdateFile")

View File

@ -1,42 +0,0 @@
package test
import (
"net/http"
"net/http/httptest"
"testing"
"runtime"
"path/filepath"
"github.com/beego/beego/v2/core/logs"
_ "server/routers"
beego "github.com/beego/beego/v2/server/web"
. "github.com/smartystreets/goconvey/convey"
)
func init() {
_, file, _, _ := runtime.Caller(0)
apppath, _ := filepath.Abs(filepath.Dir(filepath.Join(file, ".." + string(filepath.Separator))))
beego.TestBeegoInit(apppath)
}
// TestBeego is a sample to run an endpoint test
func TestBeego(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
beego.BeeApp.Handlers.ServeHTTP(w, r)
logs.Trace("testing", "TestBeego", "Code[%d]\n%s", w.Code, w.Body.String())
Convey("Subject: Test Station Endpoint\n", t, func() {
Convey("Status Code Should Be 200", func() {
So(w.Code, ShouldEqual, 200)
})
Convey("The Result Should Not Be Empty", func() {
So(w.Body.Len(), ShouldBeGreaterThan, 0)
})
})
}

View File

@ -1,105 +0,0 @@
package tests
import (
"testing"
"server/models"
)
func TestFileModel(t *testing.T) {
// 测试创建文件信息
file := &models.FileInfo{
TenantID: "test-tenant-001",
FileName: "test-file.txt",
OriginalName: "original-test-file.txt",
FilePath: "/uploads/test/test-file.txt",
FileURL: "http://localhost:8080/uploads/test/test-file.txt",
FileSize: 1024,
FileType: "text/plain",
FileExt: "txt",
Category: "test",
SubCategory: "unit-test",
Status: 1,
IsPublic: 0,
UploadBy: "test-user",
}
// 测试添加文件
id, err := models.AddFile(file)
if err != nil {
t.Errorf("添加文件失败: %v", err)
}
t.Logf("文件添加成功ID: %d", id)
// 测试根据ID获取文件
retrievedFile, err := models.GetFileById(id)
if err != nil {
t.Errorf("获取文件失败: %v", err)
}
if retrievedFile.FileName != file.FileName {
t.Errorf("文件名不匹配,期望: %s, 实际: %s", file.FileName, retrievedFile.FileName)
}
// 测试更新文件
retrievedFile.FileName = "updated-test-file.txt"
err = models.UpdateFile(retrievedFile)
if err != nil {
t.Errorf("更新文件失败: %v", err)
}
// 测试根据租户获取文件
files, err := models.GetFilesByTenant("test-tenant-001")
if err != nil {
t.Errorf("根据租户获取文件失败: %v", err)
}
if len(files) == 0 {
t.Error("根据租户获取文件为空")
}
// 测试根据分类获取文件
filesByCategory, err := models.GetFilesByCategory("test")
if err != nil {
t.Errorf("根据分类获取文件失败: %v", err)
}
if len(filesByCategory) == 0 {
t.Error("根据分类获取文件为空")
}
// 测试搜索文件
searchFiles, err := models.SearchFiles("test", "test-tenant-001")
if err != nil {
t.Errorf("搜索文件失败: %v", err)
}
if len(searchFiles) == 0 {
t.Error("搜索文件结果为空")
}
// 测试文件统计
stats, err := models.GetFileStatistics("test-tenant-001")
if err != nil {
t.Errorf("获取文件统计失败: %v", err)
}
t.Logf("文件统计: %+v", stats)
// 测试软删除文件
err = models.DeleteFile(id)
if err != nil {
t.Errorf("软删除文件失败: %v", err)
}
// 验证软删除后的状态
deletedFile, err := models.GetFileById(id)
if err != nil {
t.Errorf("获取软删除后的文件失败: %v", err)
}
if deletedFile.Status != 0 {
t.Errorf("软删除后文件状态应为0实际为: %d", deletedFile.Status)
}
// 测试硬删除文件
err = models.HardDeleteFile(id)
if err != nil {
t.Errorf("硬删除文件失败: %v", err)
}
t.Log("文件模型测试完成")
}