diff --git a/web/package.json b/web/package.json index 9ce27a3..1396af4 100644 --- a/web/package.json +++ b/web/package.json @@ -10,13 +10,18 @@ "prettier": "prettier --write ." }, "dependencies": { - "@element-plus/icons-vue": "^2.0.10", + "@element-plus/icons-vue": "^2.3.1", + "@headlessui/vue": "^1.7.12", + "@heroicons/vue": "^2.0.17", + "@soerenmartius/vue3-clipboard": "^0.1.2", "@tinymce/tinymce-vue": "^5.1.1", "@types/js-cookie": "^3.0.2", "@vueuse/core": "^9.1.1", + "@vueuse/integrations": "^9.13.0", "@wangeditor/editor": "^5.0.0", "@wangeditor/editor-for-vue": "^5.1.10", "axios": "^1.2.0", + "beautify-qrcode": "^1.0.3", "better-scroll": "^2.4.2", "default-passive-events": "^2.0.0", "docx-preview": "^0.3.0", @@ -28,6 +33,7 @@ "js-cookie": "^3.0.1", "jsencrypt": "^3.3.2", "jspdf": "^2.5.1", + "minimatch": "^5.1.0", "nprogress": "^0.2.0", "path-browserify": "^1.0.1", "path-to-regexp": "^6.2.0", @@ -35,6 +41,9 @@ "screenfull": "^6.0.0", "sortablejs": "^1.14.0", "tinymce": "^7.0.0", + "ua-browser": "^0.1.5", + "v-contextmenu": "^3.0.0", + "v3-img-preview-enhance": "^1.1.18", "vue": "^3.2.40", "vue-clipboard3": "^2.0.0", "vue-i18n": "^9.1.9", diff --git a/web/public/image/beijing.jpg b/web/public/image/beijing.jpg new file mode 100644 index 0000000..6201f90 Binary files /dev/null and b/web/public/image/beijing.jpg differ diff --git a/web/public/image/logo.png b/web/public/image/logo.png index cfc91d1..231b704 100644 Binary files a/web/public/image/logo.png and b/web/public/image/logo.png differ diff --git a/web/public/webconfig.js b/web/public/webconfig.js index 273a8ad..1d6c69e 100644 --- a/web/public/webconfig.js +++ b/web/public/webconfig.js @@ -7,7 +7,7 @@ window.webConfig = { "content1": "杭州明眸慧眼科技有限公司", "content2": "浙江省杭州市余杭区祥茂路166号华滋科欣设计创意园4号楼", "content3": "400-6588695", - "bgImg": "loginbg.jpg", + "bgImg": "beijing.jpg", "loginLogo": "uplogo.png", "headerLogo": "logo.png", "ItutionIds": "a35ce60b10e425df77f401ebf1af80ac", diff --git a/web/src/api/document/index.ts b/web/src/api/document/index.ts index 58b5300..3698f7a 100644 --- a/web/src/api/document/index.ts +++ b/web/src/api/document/index.ts @@ -37,4 +37,12 @@ export function deleteNodesById(queryParams:any) { method: 'post', params:queryParams }); +} +//获取表格内容 +export function getFilesPage(queryParams:any) { + return request({ + url: '/specialDocument/sd_files/page', + method: 'get', + params:queryParams + }); } \ No newline at end of file diff --git a/web/src/api/file-operator.js b/web/src/api/file-operator.js new file mode 100644 index 0000000..57ad610 --- /dev/null +++ b/web/src/api/file-operator.js @@ -0,0 +1,54 @@ +// import axios from "~/http/request" +import request from '@/utils/request'; +import useFilePwd from "@/components/file/file/useFilePwd"; +let { getPathPwd } = useFilePwd(); + +// 新建文件夹 +export function newFolderReq(data){ + data.password = getPathPwd(data.path); + return request({ + url: `/api/file/operator/mkdir`, + method: "post", + data:data + }) + +} +// 批量删除文件/文件夹 +export function batchDeleteReq(data){ + return request({ + url: `/api/file/operator/delete/batch`, + method: "post", + data:data + }) +} + + +// 重命名文件 +export function renameFileReq(data){ + data.password = getPathPwd(data.path); + return request({ + url: `/api/file/operator/rename/file`, + method: "post", + data:data + }) +} + +// 重命名文件夹 +export function renameFolderReq(data){ + data.password = getPathPwd(data.path); + return request({ + url: `/api/file/operator/rename/folder`, + method: "post", + data:data + }) +} + +// 获取文件上传链接 +export function uploadFileReq(data){ + data.password = getPathPwd(data.path); + return request({ + url: `/api/file/operator/upload/file`, + method: "post", + data:data + }) +} \ No newline at end of file diff --git a/web/src/api/header.js b/web/src/api/header.js new file mode 100644 index 0000000..81800b0 --- /dev/null +++ b/web/src/api/header.js @@ -0,0 +1,20 @@ +// import axios from "~/http/request" +import request from '@/utils/request'; + +// 重置密码 +export function resetPasswordReq(data){ + return request({ + url: "/api/site/reset-password", + method: "get", + params:data + }) +} + +// 获取已启用的存储源列表 +export function getSourceListReq(data){ + return request({ + url: "/api/storage/list", + method: "get", + params:data + }) +} diff --git a/web/src/api/home.js b/web/src/api/home.js new file mode 100644 index 0000000..6f68164 --- /dev/null +++ b/web/src/api/home.js @@ -0,0 +1,53 @@ +// import axios from '~/http/request'; +import request from '@/utils/request'; + +// 获取存储源文件列表 +export function loadFileListReq(data) { + return request({ + url: `/api/storage/files`, + method: 'post', + data:data, + config: { + showDefaultMsg: false + } + }); +}; + +// 获取存储源文件详情 +export function loadFileItemReq(data) { + return request({ + url: `/api/storage/file/item`, + method: 'post', + data:data, + config: { + responseIntercept: false, + showDefaultMsg: false + } + }); +}; + +// 获取全局站点设置 +export const loadGlobalSiteConfigReq = () => { + return request({ + url: `/api/site/config/global`, + method: 'get' + }); +}; + +// 获取存储源设置 +export function loadStorageConfigReq(data) { + return request({ + url: `/api/site/config/storage`, + method: 'post', + data:data + }); +}; + +// 批量生成直/短链 +export function batchGenerateShortLinkReq(data) { + return request({ + url: `/api/short-link/batch/generate`, + method: 'post', + data:data + }); +}; diff --git a/web/src/assets/images/delete.png b/web/src/assets/images/delete.png new file mode 100644 index 0000000..ff63866 Binary files /dev/null and b/web/src/assets/images/delete.png differ diff --git a/web/src/assets/images/qv.png b/web/src/assets/images/qv.png new file mode 100644 index 0000000..6579ce2 Binary files /dev/null and b/web/src/assets/images/qv.png differ diff --git a/web/src/assets/images/shangging.png b/web/src/assets/images/shangging.png new file mode 100644 index 0000000..4eb4e69 Binary files /dev/null and b/web/src/assets/images/shangging.png differ diff --git a/web/src/assets/images/shua.png b/web/src/assets/images/shua.png new file mode 100644 index 0000000..225bc6c Binary files /dev/null and b/web/src/assets/images/shua.png differ diff --git a/web/src/assets/images/wendang.png b/web/src/assets/images/wendang.png new file mode 100644 index 0000000..6d82af8 Binary files /dev/null and b/web/src/assets/images/wendang.png differ diff --git a/web/src/assets/login/90.jfif b/web/src/assets/login/90.jfif new file mode 100644 index 0000000..19a1751 Binary files /dev/null and b/web/src/assets/login/90.jfif differ diff --git a/web/src/assets/login/beijing.jpg b/web/src/assets/login/beijing.jpg new file mode 100644 index 0000000..6201f90 Binary files /dev/null and b/web/src/assets/login/beijing.jpg differ diff --git a/web/src/components/file/SvgIcon.vue b/web/src/components/file/SvgIcon.vue new file mode 100644 index 0000000..ed84b89 --- /dev/null +++ b/web/src/components/file/SvgIcon.vue @@ -0,0 +1,31 @@ + + + \ No newline at end of file diff --git a/web/src/components/file/ZUpload.vue b/web/src/components/file/ZUpload.vue new file mode 100644 index 0000000..4b888f3 --- /dev/null +++ b/web/src/components/file/ZUpload.vue @@ -0,0 +1,164 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/file/common.js b/web/src/components/file/common.js new file mode 100644 index 0000000..6fed130 --- /dev/null +++ b/web/src/components/file/common.js @@ -0,0 +1,147 @@ +// 文件分类 +const fileTypeMap = { + image: ['gif', 'jpg', 'jpeg', 'png', 'bmp', 'webp', 'ico'], + video: ['mp4', 'webm', 'm3u8', 'rmvb', 'avi', 'swf', '3gp', 'mkv', 'flv'], + audio: ['mp3', 'wav', 'wma', 'ogg', 'aac', 'flac', 'm4a'], + text: ['scss', 'sass', 'kt', 'gitignore', 'bat', 'properties', 'yml', 'css', 'js', 'md', 'xml', 'txt', 'py', 'go', 'html', 'less', 'php', 'rb', 'rust', 'script', 'java', 'sh', 'sql'], + executable: ['exe', 'dll', 'com', 'vbs'], + archive: ['7z', 'zip', 'rar', 'tar', 'gz'], + pdf: ['pdf'], + office: ['doc', 'docx', 'csv', 'xls', 'xlsx', "ppt", 'pptx'], + three3d: ['dae', 'fbx', 'gltf', 'glb', 'obj', 'ply', 'stl'], + document: ['txt', 'pages', 'epub', 'numbers', 'keynote'] +}; + +// 可预览的文件类型 +const previewFileType = ['image', 'video', 'audio', 'text', 'office', 'pdf', 'three3d']; + +import config from '/package.json' + +// 自动对 /src/assets/icons 目录下的文件图标进行显示 +const iconFileType = []; +import ids from 'virtual:svg-icons-names' +ids.forEach(id => { + iconFileType.push(id.replace(/^icon-file-type-/, '')); +}); + +let common = { + responseCode: { + SUCCESS: 0, + FAIL: -1, + REQUIRED_PASSWORD: 405, + INVALID_PASSWORD: 406 + }, + storageType: { + s3Type: ['s3', 'tencent', 'aliyun', 'qiniu', 'minio', 'huawei', 'doge-cloud'], + proxyType: ['local', 'webdav', 'ftp', 'sftp', 'google-drive'], + micrsoftType: ['sharepoint', 'sharepoint-china', 'onedrive', 'onedrive-china'] + }, + version: config.version, + constant: { + fileTypeMap, + iconFileType, + previewFileType + }, + openPage: (url) => { + window.open(url); + }, + fileSizeFormat: (bytes) => { + if (bytes === 0) return '-'; + if (bytes === -1) return '未知'; + let k = 1024; + let sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + let i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; + }, + fileSizeFilter: (row, column, bytes) => { + if (row.type === "BACK") return ''; + if (row.type === "FOLDER" && !row.size) return '-'; + if (bytes === 0) return '0 B'; + if (bytes === -1) return '未知'; + let k = 1024; + let sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + let i = Math.floor(Math.log(bytes) / Math.log(k)); + return common.fileSizeFormat(bytes); + }, + getFileIconName(file) { + let iconName; + if (file.type === 'BACK' || file.type === 'FOLDER' || file.type === 'ROOT') { + return file.type.toLowerCase(); + } else { + let fileSuffix = this.getFileSuffix(file.name); + let fileType = this.getFileType(file.name); + + if (iconFileType.indexOf(fileSuffix) !== -1) { + iconName = fileSuffix; + } else if (fileType) { + iconName = fileType; + } else { + iconName = 'file'; + } + } + return iconName; + }, + getFileSuffix(name) { + let lastIndex = name.lastIndexOf('.'); + if (lastIndex === -1) { + return 'other'; + } + return name.substring(lastIndex + 1).toLowerCase(); + }, + getFileName(name) { + let lastIndex = name.lastIndexOf('.'); + if (lastIndex === -1) { + return ''; + } + return name.substring(0, lastIndex); + }, + getFileType(name) { + let fileType; + for (let key in fileTypeMap) { + let suffix = this.getFileSuffix(name); + if (fileTypeMap[key].indexOf(suffix) !== -1) { + fileType = key; + break; + } + } + return fileType; + }, + removeDuplicateSeparator(path) { + let result = ''; + + if (path.indexOf("http://") === 0) { + result = "http://"; + } else if (path.indexOf("https://") === 0) { + result = "https://"; + } + + for (let i = result.length; i < path.length - 1; i++) { + let current = path.charAt(i); + let next = path.charAt(i + 1); + if (!(current === '/' && next === '/')) { + result += current; + } + } + result += path.charAt(path.length - 1); + return result; + }, + isMobile() { + let flag = navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i) + return flag || window.innerWidth < 768; + }, + dateFormat:function(time) { + if (!time) { + return time; + } + let date = new Date(time); + let year = date.getFullYear(); + let month = date.getMonth() + 1 < 10 ? "0" + (date.getMonth() + 1) : date.getMonth() + 1; + let day = date.getDate() < 10 ? "0" + date.getDate() : date.getDate(); + let hours = date.getHours() < 10 ? "0" + date.getHours() : date.getHours(); + let minutes = date.getMinutes() < 10 ? "0" + date.getMinutes() : date.getMinutes(); + let seconds = date.getSeconds() < 10 ? "0" + date.getSeconds() : date.getSeconds(); + return year + "-" + month + "-" + day + " " + hours + ":" + minutes + ":" + seconds; + } +}; + +export default common; \ No newline at end of file diff --git a/web/src/components/file/file/useFileContextMenu.js b/web/src/components/file/file/useFileContextMenu.js new file mode 100644 index 0000000..d124ccf --- /dev/null +++ b/web/src/components/file/file/useFileContextMenu.js @@ -0,0 +1,70 @@ +import "v-contextmenu/dist/themes/default.css"; + +import useFileSelect from "@/components/file/file/useFileSelect"; +const { selectRows, clearSelection, toggleRowSelection } = useFileSelect(); + +import useRouterData from "@/components/file/useRouterData"; +let { storageKey } = useRouterData(); + +import useFileDataStore from "@/components/file/stores/file-data"; +let fileDataStore = useFileDataStore(); +import { ref } from 'vue'; +const contextMenuTargetFile = ref(false); +const contextMenuTargetBlank = ref(false); + +let contextmenuRef; + +export default function useFileContextMenu() { + + const showFileMenu = (row, column, event) => { + if (!storageKey.value) { + return; + } + + // 如果右键的不是空白区域,则不显示菜单 + if (row instanceof Event) { + event = row; + let parentDom = document.querySelector(".zfile-index-body-wrapper"); + let ignoreDom = document.querySelector(".el-dialog"); + if (!parentDom.contains(event.target) || ignoreDom?.contains(event.target)) { + return; + } + contextMenuTargetBlank.value = true; + } else { + if (row.type === 'BACK') { + return; + } + fileDataStore.updateCurrentRightClickRow(row); + + if (!selectRows.value.includes(row)) { + clearSelection(); + toggleRowSelection(row, true); + } + contextMenuTargetFile.value = true; + } + + event.preventDefault(); + event.stopPropagation(); + + contextmenuRef.show({ + top: event.clientY, + left: event.clientX + }); + + window.onclick = () => { + contextmenuRef.hide(); + contextMenuTargetBlank.value = false; + contextMenuTargetFile.value = false; + }; + + contextmenuRef.$el.hidden = false; + } + + const initContextMenu = (ref) => { + contextmenuRef = ref.value; + } + + return { + initContextMenu, showFileMenu, contextMenuTargetFile, contextMenuTargetBlank + } +} \ No newline at end of file diff --git a/web/src/components/file/file/useFileData.js b/web/src/components/file/file/useFileData.js new file mode 100644 index 0000000..5c7f3ff --- /dev/null +++ b/web/src/components/file/file/useFileData.js @@ -0,0 +1,282 @@ +import {ElMessage} from "element-plus"; +// import { ref,reactive,useTitle,computed } from 'vue'; +import { ref,reactive,computed} from "vue"; +import MessageBox from "@/components/file/messageBox/messageBox"; +import path from "path-browserify"; +import {removeDuplicateSlashes} from "fast-glob/out/managers/patterns"; + +import {loadFileListReq, loadStorageConfigReq} from "@/api/home"; + +import common from "@/components/file/common"; +import useCommon from "@/components/file/useCommon"; +const { encodeAllIgnoreSlashes } = useCommon(); + +import useRouterData from "@/components/file/useRouterData"; +let { routerRef, fullpath, storageKey, currentPath } = useRouterData() + +import useFilePwd from "@/components/file/file/useFilePwd"; +let { getPathPwd, putPathPwd } = useFilePwd(); + +import useHeaderStorageList from "@/components/header/useHeaderStorageList"; +const { storageListAsFileList } = useHeaderStorageList(); + +import useFileDataStore from "@/components/file/stores/file-data"; +let fileDataStore = useFileDataStore(); + +import useStorageConfigStore from "@/components/file/stores/storage-config"; +let storageConfigStore = useStorageConfigStore(); +import { useTitle } from '@vueuse/core'; +const title = useTitle(storageConfigStore.globalConfig.siteName); + +import useGlobalConfigStore from "@/components/file/stores/global-config"; +let globalConfigStore = useGlobalConfigStore(); + +// 引入文件预览组件 +import useFilePreview from '@/components/file/file/useFilePreview'; +const { openAudio, openImage, openOffice, openPdf, openText, openVideo, open3d } = useFilePreview(); + +// 文件操作相关 useFileOperator +import useFileOperator from '@/components/file/file/useFileOperator'; + +// ------------- loading start ------------ + +const loading = ref(false); +// 是否是已经调用了首次 loading +const firstLoading = ref(false); +let skeletonData = reactive([]); +if (skeletonData.length === 0) { + // 动态配置骨架屏行数 + for (let i = 0; i < globalConfigStore.zfileConfig.skeleton.size; i++) { + skeletonData.push({}) + } +} +// ------------- loading end ------------ + + +// 文件列表查询条件 +let searchParam = reactive({ + path: '', + password: '', + orderBy: '', + orderDirection: '' +}); + +const initStorageConfig = ref(false); +import useFileSelect from "@/components/file/file/useFileSelect"; +let { selectRows, clearSelection } = useFileSelect(); +export default function useFileData() { + + // 排序并重新加载数据 + const sortChangeMethod = ({prop, order}) => { + searchParam.orderBy = prop; + searchParam.orderDirection = order === "descending" ? "desc" : "asc"; + loadFile(); + }; + + // 加载数据 + const loadFile = (initParam) => { + // 未指定 storageKey 时, 不执行任何操作. + if (!storageKey.value) { + return; + } + loading.value = true; + + searchParam.path = currentPath.value; + + let param = initParam || {}; + param.storageKey = storageKey.value; + param.path = currentPath.value; + param.password = param.password || getPathPwd(); + param.orderBy = searchParam.orderBy || storageConfigStore.globalConfig.defaultSortField; + param.orderDirection = searchParam.orderDirection || storageConfigStore.globalConfig.defaultSortOrder; + + let requestStorageId = storageKey.value; + loadFileListReq(param).then((response) => { + + let passwordPattern = response.data.passwordPattern; + + if (initParam?.rememberPassword) { + putPathPwd(passwordPattern, param.password); + } + + // 如果请求的 storageKey 和当前的 storageKey 不一致 + // 则表示再加载数据期间,修改了 storageKey, 为了防止数据错乱, 取消本次渲染. + if (requestStorageId !== storageKey.value) { + return; + } + + let fileList = response.data.files; + + // 如果不是根路径, 则构建 back 上级路径的数据. + let searchPath = searchParam.path; + if (searchPath !== '' && searchPath !== '/') { + let parentPathName = path.basename(path.resolve(currentPath.value, "../")); + fileList.unshift({ + name: parentPathName ? parentPathName : '/', + path: path.resolve(searchPath, '../'), + type: 'BACK' + }); + } + + fileDataStore.updateFileList(fileList); + loading.value = false; + firstLoading.value = true; + selectRows.value = []; + + // 修改标题 + if (fullpath.value) { + title.value = storageConfigStore.globalConfig.siteName + ' | ' + fullpath.value[fullpath.value.length - 1]; + } else { + title.value = storageConfigStore.globalConfig.siteName + ' | 首页'; + } + + loadFileConfig(param); + }).catch((error) => { + let data = error.response.data; + // 如果需要密码或密码错误进行提示, 并弹出输入密码的框. + if (data.code === common.responseCode.INVALID_PASSWORD) { + ElMessage.warning('密码错误,请重新输入!'); + popPassword(); + } else if (data.code === common.responseCode.REQUIRED_PASSWORD) { + popPassword(); + } else { + ElMessage.error(data.msg); + } + }); + } + + // 加载存储器设置 + const loadFileConfig = (loadFileParam) => { + + let param = { + storageKey: storageKey.value, + path: currentPath.value, + password: loadFileParam.password + } + loadStorageConfigReq(param).then((res) => { + storageConfigStore.updateFolderConfig(res.data); + + // 如果切换了存储器 ID, 则 + if (storageKey.value !== fileDataStore.oldStorageKey) { + fileDataStore.updateOldStorageKey(storageKey.value); + } + + }).finally(() => { + initStorageConfig.value = true; + }); + } + + + // 点击文件时,判断是文件夹则进入文件夹,是文件则进行预览 + const openRow = (row) => { + if (!row.name) { + return; + } + fileDataStore.updateCurrentClickRow(row); + // 如果是文件且格式支持预览, 则进行预览, 格式不支持预览, 则直接进行下载 (ftp 模式不支持预览, 全部是下载) + if (row.type === 'FILE') { + const { batchDownloadFile } = useFileOperator(); + + // 获取文件类型 + let fileType = row.fileType; + + + switch (fileType) { + case 'video': openVideo(); break; + case 'image': openImage(row); break; + case 'text': openText(); break; + case 'audio': openAudio(row); break; + case 'office': openOffice(row); break; + case 'pdf': openPdf(row); break; + case 'three3d': open3d(row); break; + default: batchDownloadFile(row); + } + + clearSelection(); + } else { + if (row.type === 'ROOT') { + routerRef.value.push(row.path); + } else if (row.type === 'BACK') { + let fullPath = removeDuplicateSlashes('/' + storageKey.value + '/' + row.path); + fullPath = encodeAllIgnoreSlashes(fullPath); + routerRef.value.push(fullPath); + } else { + let fullPath = removeDuplicateSlashes('/' + storageKey.value + '/' + row.path + '/' + row.name); + fullPath = encodeAllIgnoreSlashes(fullPath); + routerRef.value.push(fullPath); + } + } + } + + // ------------- loading start ------------ + + // 是否启用骨架屏 loading + let skeletonLoading = computed(() => { + let skeletonEnable = globalConfigStore.zfileConfig.skeleton.enable; + let skeletonShow = globalConfigStore.zfileConfig.skeleton.show; + + // 如果启用了骨架屏 + if (skeletonEnable) { + // 如果骨架屏模式为是 '始终加载' + if (skeletonShow === 'always') { + return loading.value; + } else { // 如果骨架屏模式为是仅 '首次加载' + // 已经首次加载后, 则不使用骨架屏 + return firstLoading.value ? false : loading.value; + } + } else { // 如果未启用骨架屏, 则直接返回 false + return false; + } + }); + // 是否显示普通 loading + let basicLoading = computed(() => { + return skeletonLoading.value ? false : loading.value; + }); + + // ------------- loading end ------------ + + + + + // ------------- folder password start ------------ + + + // 显示密码输入框 + let popPassword = () => { + // 如果输入了密码, 则写入到 sessionStorage 缓存中, 并重新调用加载文件. + MessageBox.prompt('此文件夹已加密,请输入密码:', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + inputType: 'password', + checkbox: true, + defaultChecked: storageConfigStore.globalConfig.defaultSavePwd, + inputDefault: getPathPwd(), + checkboxLabel: '记住密码', + inputValidator(val) { + return !!val + }, + inputErrorMessage: '密码不能为空.' + }).then(({value, checkbox}) => { + loadFile({password: value, rememberPassword: checkbox}); + }).catch(() => { + if ((searchParam.path === '/' || searchParam.path === '') && storageConfigStore.globalConfig.rootShowStorage === true) { + fileDataStore.updateFileList(storageListAsFileList.value); + routerRef.value.push("/"); + title.value = storageConfigStore.globalConfig.siteName + ' | 首页'; + loading.value = false; + } else { + let parentPath = path.resolve(searchParam.path, '../'); + routerRef.value.push("/" + storageKey.value + parentPath); + } + }); + } + + // ------------- folder password end ------------ + + return { + loadFile, openRow, searchParam, sortChangeMethod, + skeletonLoading, skeletonData, basicLoading, loading, + initStorageConfig, loadFileConfig + } + +} diff --git a/web/src/components/file/file/useFileLink.js b/web/src/components/file/file/useFileLink.js new file mode 100644 index 0000000..f0b4394 --- /dev/null +++ b/web/src/components/file/file/useFileLink.js @@ -0,0 +1,157 @@ +import common from "@/components/file/common"; +import { batchGenerateShortLinkReq } from "@/api/home"; + +import { toClipboard } from '@soerenmartius/vue3-clipboard' +import { encodeData, rendererRect, rendererRound, + rendererDSJ, rendererLine, rendererFuncB } from 'beautify-qrcode'; + +const generateLinkLoading = ref(false); +const generateLinkDialogVisible = ref(false); +const generateLinkFormData = reactive({ + expireTime: null, +}); + +import useStorageConfigStore from "@/components/file/stores/storage-config"; +let storageConfigStore = useStorageConfigStore(); + +import useRouterData from "@/components/file/useRouterData"; +let { storageKey } = useRouterData() + +const dataList = ref([]); +let data = computed(() => { + return dataList.value.length > 0 ? dataList.value[0] : null; +}); +let generateLinkResultDialogVisible = ref(false); +let generateLinkResultLoading = ref(false); + +const linkDialogVisible = computed(() => { + return generateLinkDialogVisible.value && generateLinkResultDialogVisible.value; +}); + +export default function useFileLink() { + + /* + * 打开生成链接弹窗 + */ + const openGenerateLinkDialog = () => { + generateLinkDialogVisible.value = true; + }; + + /** + * 关闭生成链接弹窗 + */ + const closeGenerateLinkDialog = () => { + generateLinkDialogVisible.value = false; + }; + + /** + * 提交生成链接表单 + */ + const submitGenerateLinkForm = () => { + closeGenerateLinkDialog(); + openGenerateLinkResultDialog(); + }; + + + + /** + * 打开直链生成结果弹窗 + */ + const openGenerateLinkResultDialog = () => { + generateLinkResultDialogVisible.value = true; + } + + /** + * 复制直链 + */ + let copyText = (text) => { + toClipboard(text).then(() => { + ElMessage.success('复制成功'); + }); + } + + /** + * svg 转为 src data uri + * @param svgText svg 文本 + * @returns {string} src data uri + */ + const svgToDataUri = (svgText) => { + let xmlElement = document.createElement("xml") + xmlElement.innerHTML = svgText; + + // 增加 svg 底色, 防止复制后是透明. + let rectElement = document.createElement("rect") + rectElement.setAttribute('width', '100%'); + rectElement.setAttribute('height', '100%'); + rectElement.style.fill = '#ffffff'; + + xmlElement.children[0].prepend(rectElement); + + return 'data:image/svg+xml;utf8,' + encodeURIComponent(xmlElement.innerHTML); + } + + /** + * 生成直链 + * + * @param files 要生成的直链列表 + */ + const generateALlLink = (files) => { + generateLinkResultLoading.value = true; + + let param = { + storageKey: storageKey.value, + paths: [], + expireTime: generateLinkFormData.expireTime + } + + files.forEach((row) => { + let pathAndName = common.removeDuplicateSeparator("/" + row.path + "/" + row.name); + param.paths.push(pathAndName); + }) + + batchGenerateShortLinkReq(param).then((res) => { + let size = res.data.length; + res.data.forEach((item, index) => { + // 生成二维码 + const qrcode = encodeData({ + text: storageConfigStore.permission.shortLink ? item.shortLink : item.pathLink, + correctLevel: 2, + isSpace: false + }); + if (size === 1) { + item.row = { + name: files[index].name, + size: common.fileSizeFormat(files[index].size), + time: files[index].time + }; + item.qrcode = { + a1: svgToDataUri(rendererRect(qrcode)), + a2: svgToDataUri(rendererRound(qrcode)), + sp1: svgToDataUri(rendererDSJ(qrcode)), + aa1: svgToDataUri(rendererLine(qrcode)), + ab2: svgToDataUri(rendererFuncB(qrcode)), + } + item.currentImg = item.qrcode.a1; + } else { + item.name = files[index].name + } + dataList.value.push(item); + }) + }).finally(() => { + generateLinkResultLoading.value = false; + }) + } + + return { + generateLinkDialogVisible, + openGenerateLinkDialog, + closeGenerateLinkDialog, + generateLinkFormData, + generateLinkLoading, + submitGenerateLinkForm, + + linkDialogVisible, + generateLinkResultDialogVisible, generateLinkResultLoading, openGenerateLinkResultDialog, copyText, data, dataList, generateALlLink + } + +} diff --git a/web/src/components/file/file/useFileOperator.js b/web/src/components/file/file/useFileOperator.js new file mode 100644 index 0000000..5a4c4e6 --- /dev/null +++ b/web/src/components/file/file/useFileOperator.js @@ -0,0 +1,291 @@ +import { + batchDeleteReq, + newFolderReq, + renameFileReq, + renameFolderReq, +} from "@/api/file-operator"; + +import useFileDataStore from "@/components/file/stores/file-data"; +let fileDataStore = useFileDataStore(); + +import useStorageConfigStore from "@/components/file/stores/storage-config"; +let storageConfigStore = useStorageConfigStore(); + +import useFileData from "@/components/file/file/useFileData"; +import useRouterData from "@/components/file/useRouterData"; +let { storageKey, currentPath } = useRouterData(); + +import useFileSelect from "@/components/file/file/useFileSelect"; +let { selectRows, selectRow, selectFolders, selectFiles } = useFileSelect(); + +import useFilePwd from "@/components/file/file/useFilePwd"; +let { getPathPwd } = useFilePwd(); + +// 检测浏览器类型 +import uaBrowser from 'ua-browser' +import { ElLoading } from "element-plus"; +const browserInfo = uaBrowser(); + +export default function useFileOperator() { + + const { loadFile } = useFileData(); + + /** + * 批量下载已选择的所有文件 + * @param {Object} row 已选择的文件 + */ + const batchDownloadFile = (row) => { + if (!selectRows.value && selectRows.value.length === 0) { + ElMessage.warning("请至少选择一个文件"); + return; + } + + let confirmMsg; + + const checkBrowser = () => { + let result = { + isChrome: false, + tips: '' + } + + let currentBrowser = browserInfo.browser; + let currentBrowserVersion = browserInfo.version; + + if (currentBrowser === 'Chrome') { + result.isChrome = true; + } else { + result.tips = `
检测到当前浏览器为 ${currentBrowser}-${currentBrowserVersion}, 可能不支持此功能,建议使用谷歌浏览器!`; + } + return result; + } + + const checkBrowserResult = checkBrowser(); + + if (row?.name) { + confirmMsg = `是否确认下载文件 ${row.name} ?`; + } else if (selectRows.value.length === 1) { + confirmMsg = `是否确认下载文件 ${selectRows.value[0].name} ?`; + row = selectRows.value[0]; + } else if (selectRows.value.length > 1) { + confirmMsg = `是否确认批量下载 ${selectRows.value.length} 个文件?`; + if (!checkBrowserResult.isChrome) { + confirmMsg += checkBrowserResult.tips; + } + } + + ElMessageBox.confirm(confirmMsg, '提示', { + dangerouslyUseHTMLString: true, + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'info', + callback: (action) => { + if (action === 'confirm') { + // 单个文件下载, 直接下载 + if (row?.name) { + console.log('进行指定文件下载, 文件:', row); + downloadFileUseWindowOpenMode(row.url) + } else { + // 批量下载 + selectRows.value.forEach((item) => { + if (item.type === 'FILE') { + console.log('批量选中文件下载, 文件:', item); + downloadFileUseIframeMode(item.url); + } + }) + } + } + } + }) + } + + /** + * 使用 windows.open 模式下载文件 + * + * @param url 下载文件 url + */ + const downloadFileUseWindowOpenMode = (url) => { + window.open(url); + } + + /** + * 使用 iframe 模式下载文件 + * + * @param url 下载文件 url + */ + const downloadFileUseIframeMode = (url) => { + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; // 防止影响页面 + iframe.style.height = 0; // 防止影响页面 + iframe.src = url; + document.body.appendChild(iframe); + setTimeout(()=>{ + iframe.remove(); + }, 5 * 60 * 1000); + } + + // 新建文件夹 + const newFolder = () => { + ElMessageBox.prompt(`在 ${currentPath.value} 下创建文件夹,请输入要创建的文件夹名称`, '提示', { + dangerouslyUseHTMLString: true, + confirmButtonText: '确定', + cancelButtonText: '取消', + draggable: true, + inputValidator(val) { + if (!val) { + return '文件夹名称不能为空'; + } + + if (val.includes("/")) { + return '文件夹名称不能包含 /'; + } + return true; + }, + }).then(({value}) => { + let param = { + storageKey: storageKey.value, + path: currentPath.value, + name: value + } + newFolderReq(param).then(() => { + ElMessage.success('创建成功'); + }).finally(() => { + loadFile(); + }); + }); + } + + // 重命名文件夹 + const rename = () => { + let row = selectRow.value; + if (row === null) { + ElMessage.warning('请先选中一个文件或文件夹!'); + return; + } + ElMessageBox.prompt(`将 ${row.name} 修改为:`, '提示', { + dangerouslyUseHTMLString: true, + confirmButtonText: '确定', + cancelButtonText: '取消', + inputValue: row.name, + inputValidator(val) { + return !!val + }, + inputErrorMessage: '模板名称不能为空.' + }).then(({value}) => { + let param = { + storageKey: storageKey.value, + path: row.path, + name: row.name, + newName: value, + } + + let reqMethod; + if (row.type === 'FILE') { + reqMethod = renameFileReq; + } else if (row.type === 'FOLDER') { + reqMethod = renameFolderReq; + } + + const renameLoadingInstance = ElLoading.service({ + fullscreen: true, + text: '重命名中...', + background: 'rgba(255, 255, 255, 0.6)' + }) + + reqMethod(param).then(() => { + ElMessage.success('重命名成功'); + }).finally(() => { + renameLoadingInstance.close(); + loadFile(); + }); + + }); + } + + // 移动至重命名 + const moveTo = () => { + ElMessage.warning("暂未实现"); + } + + // 复制至 + const copyTo = () => { + ElMessage.warning("暂未实现"); + } + + + + // 删除相关 start + const batchDelete = () => { + if (!storageConfigStore.permission.delete) { + return; + } + + if (selectRows.value.length === 0) { + ElMessage.warning('请先至少选中一个文件或文件夹!'); + return; + } + + let deleteConfirmMsg = selectRows.value.length === 1 ? '是否确认删除 ' : '是否确认批量删除 '; + + let notSupportDeleteNotEmptyFolderType = ['s3', 'tencent', 'aliyun', 'qiniu', 'minio', 'huawei', 'upyun']; + + if (selectFolders.value.length > 0) { + deleteConfirmMsg += (' ' + selectFolders.value.length + ' 个文件夹'); + } + + if (selectFolders.value.length > 0 && selectFiles.value.length > 0) { + deleteConfirmMsg += ','; + } + + if (selectFiles.value.length > 0) { + deleteConfirmMsg += (selectFiles.value.length + ' 个文件'); + } + + if (selectFolders.value.length > 0 && notSupportDeleteNotEmptyFolderType.includes(fileDataStore.currentStorageSource.type.key)) { + deleteConfirmMsg += (' (不支持删除非空文件夹)'); + } + + deleteConfirmMsg += "?" + + ElMessageBox.confirm(deleteConfirmMsg, '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + draggable: true, + callback: action => { + if (action === 'confirm') { + let param = { + storageKey: storageKey.value, + deleteItems: [] + }; + + selectRows.value.forEach((item) => { + param.deleteItems.push({ + path: item.path, + name: item.name, + type: item.type, + password: getPathPwd(item.path) + }); + }) + + // 打开全屏 loading + const loadingInstance = ElLoading.service({ + text: '删除中...', + background: 'rgba(0, 0, 0, .3)' + }) + + batchDeleteReq(param).then((res) => { + ElMessage.success(res.msg); + loadFile(); + }).finally(() => { + loadingInstance.close(); + }); + } + } + }); + } + // 删除相关 end + + return { + batchDownloadFile, rename, newFolder, moveTo, copyTo, + batchDelete + } +} \ No newline at end of file diff --git a/web/src/components/file/file/useFilePreview.js b/web/src/components/file/file/useFilePreview.js new file mode 100644 index 0000000..d466b98 --- /dev/null +++ b/web/src/components/file/file/useFilePreview.js @@ -0,0 +1,80 @@ +import { v3ImgPreviewFn } from 'v3-img-preview-enhance' +import { ref,computed} from "vue"; + +// 基础依赖引入 +import useGlobalConfigStore from "@/components/file/stores/global-config"; +let globalConfigStore = useGlobalConfigStore(); + +import useFileDataStore from "@/components/file/stores/file-data"; +let fileDataStore = useFileDataStore(); + +// 视频预览, 打开 dialog. +export let dialogVideoVisible = ref(false); +// 文本预览 +export let dialogTextVisible = ref(false); +// office 预览 +export let dialogOfficeVisible = ref(false); +// pdf 预览 +export let dialogPdfVisible = ref(false); +// 3d 预览 +export let dialog3dVisible = ref(false); + +export default function useFilePreview() { + + const openVideo = () => { + dialogVideoVisible.value = true; + } + + const openAudio = () => { + fileDataStore.updateAudioList(fileDataStore.filterFileByType('audio')); + } + + const openImage = (row) => { + // 过滤当前页面中所有图片,并记录当前打开的文件的索引位置 + let images = []; + let currIndex = 0; + let imagePreviewMode = globalConfigStore.zfileConfig.imagePreview.mode; + if (imagePreviewMode === 'only') { + images.push(row.url); + } else { + fileDataStore.filterFileByType('image').forEach((image, index) => { + if (row.name === image.name) { + currIndex = index; + } + images.push(image.url); + }) + } + + v3ImgPreviewFn({ + images: images, + index: currIndex + }) + } + + const openText = () => { + dialogTextVisible.value = true; + } + + const openOffice = () => { + dialogOfficeVisible.value = true; + } + + const openPdf = () => { + dialogPdfVisible.value = true; + } + + const open3d = () => { + dialog3dVisible.value = true; + } + + return { + openVideo, dialogVideoVisible, + openText, dialogTextVisible, + openOffice, dialogOfficeVisible, + openImage, + openAudio, + openPdf, dialogPdfVisible, + open3d, dialog3dVisible + } + +} \ No newline at end of file diff --git a/web/src/components/file/file/useFilePwd.js b/web/src/components/file/file/useFilePwd.js new file mode 100644 index 0000000..b6d93cd --- /dev/null +++ b/web/src/components/file/file/useFilePwd.js @@ -0,0 +1,52 @@ +import minimatch from "minimatch"; +import useRouterData from "@/components/file/useRouterData"; +import { removeDuplicateSlashes } from "fast-glob/out/managers/patterns"; +import common from "@/components/file/common"; +import { useStorage } from '@vueuse/core'; +let { storageKey, currentPath } = useRouterData() + +const zfilePasswordCache = useStorage('zfile-pwd-cache', {}); + +export default function useFilePwd() { + + // 向缓存中写入当前路径密码 + let putPathPwd = (pattern, password) => { + if (pattern) { + // 如果表达式开头没写 / ,则自动补全 + pattern = pattern.startsWith('/') ? pattern : '/' + pattern; + + if (!zfilePasswordCache.value[storageKey.value]) { + zfilePasswordCache.value[storageKey.value]= {}; + } + + // 修正 glob 表达式兼容性和服务端不同的 bug + if (pattern.endsWith("**") && !pattern.endsWith("/**")) { + pattern = removeDuplicateSlashes(pattern.substring(0, pattern.length - 2) + "/**"); + console.log('检测到密码文件夹通配符 ** 前未写 /,自动将其修正为为:', pattern); + } + zfilePasswordCache.value[storageKey.value][pattern] = password; + } + }; + + // 获取当前路径缓存中的密码 + let getPathPwd = (path) => { + let currentPathValue = path || currentPath.value; + currentPathValue = common.removeDuplicateSeparator('/' + currentPathValue + "/"); + for (let storageTag of Object.keys(zfilePasswordCache.value)) { + if (storageTag === storageKey.value) { + for (let key of Object.keys(zfilePasswordCache.value[storageTag])) { + if (minimatch(currentPathValue, key)) { + return zfilePasswordCache.value[storageTag][key]; + } + } + } + } + return ''; + }; + + return { + putPathPwd, + getPathPwd + } + +} \ No newline at end of file diff --git a/web/src/components/file/file/useFileSelect.js b/web/src/components/file/file/useFileSelect.js new file mode 100644 index 0000000..6ca5f41 --- /dev/null +++ b/web/src/components/file/file/useFileSelect.js @@ -0,0 +1,91 @@ +const selectRows = ref([]); +let vueInstance = null; +import { ref,computed} from "vue"; +export default function useFileSelect(currentInstance) { + + if (currentInstance) { + vueInstance = currentInstance; + } + + const clearSelection = () => { + vueInstance.proxy.$refs.fileTableRef.clearSelection(); + } + + const toggleRowSelection = (row, selected) => { + if (row?.type === 'BACK') { + return; + } + vueInstance.proxy.$refs.fileTableRef.toggleRowSelection(row, selected); + } + + const toggleAllSelection = () => { + vueInstance.proxy.$refs.fileTableRef.toggleAllSelection(); + } + + + // 文件是否可被选择 + const checkSelectable = (row) => { + return row.type === "FILE" || row.type === "FOLDER"; + }; + + // 当前最后选中的文件行 + const selectRow = computed(() => { + if (selectRows.value.length > 0) { + return selectRows.value[selectRows.value.length - 1]; + } else { + return null; + } + }); + + // 当前选中的文件 + const selectFiles = computed(() => { + return selectRows.value.filter((row) => { + return row.type === "FILE"; + }); + }); + + // 当前选中的文件夹 + const selectFolders = computed(() => { + return selectRows.value.filter((row) => { + return row.type === "FOLDER"; + }); + }); + + // 更新选中的文件列表 + const selectRowsChange = (selection) => { + selectRows.value = selection; + }; + + // 行选中 class + const tableRowClassName = ({ row, rowIndex }) => { + row.index = rowIndex; + return selectRows.value.indexOf(row) !== -1 ? "select-row" : ""; + }; + + // 多选统计信息 + const selectStatistics = computed(() => { + let selectRowsLength = selectRows.value.length; + let selectFilesLength = selectFiles.value.length; + let selectFoldersLength = selectFolders.value.length; + + let isSingleSelect = selectRowsLength === 1; + let isMultiSelect = selectRowsLength > 1; + let isAllFile = selectFilesLength === selectRowsLength; + let isAllFolder = selectFoldersLength === selectRowsLength; + + return { + isSingleSelect, + isMultiSelect, + isAllFile, + isAllFolder + }; + }); + + return { + checkSelectable, tableRowClassName, selectRowsChange, + selectRow, selectRows, + selectFiles, selectFolders, + selectStatistics, + clearSelection, toggleRowSelection, toggleAllSelection + }; +} \ No newline at end of file diff --git a/web/src/components/file/file/useFileUpload.js b/web/src/components/file/file/useFileUpload.js new file mode 100644 index 0000000..5d2e526 --- /dev/null +++ b/web/src/components/file/file/useFileUpload.js @@ -0,0 +1,749 @@ +import {ElLoading} from "element-plus"; +import { ref,reactive,computed,nextTick,watch} from "vue"; +import {uploadFileReq} from "@/api/file-operator"; +import axios from "axios"; +import common from "@/components/file/common"; +import {removeDuplicateSlashes} from "fast-glob/out/managers/patterns"; +import { useEventBus } from '@vueuse/core'; +import useFileDataStore from "@/components/file/stores/file-data"; +let fileDataStore = useFileDataStore(); + +import useStorageConfigStore from "@/components/file/stores/storage-config"; +let storageConfigStore = useStorageConfigStore(); + +import {hasDialog, hasPasswordInputFocus} from "@/components/file/file/useTableOperator"; + +// 拖拽上传状态, true 表示正有文件拖拽悬浮在上传框上 +let dropState = ref(false); +import useRouterData from "@/components/file/useRouterData"; + + +// 是否已经初始化监听 watch, 防止重复引用该文件导致的重复 watch +let isInitWatch = false; + +// 是否显示上传框. +const visible = ref(false); + +// 当前上传模式, 'file' or 'folder' +const uploadMode = ref(''); + +// 正在上传或已完成文件列表 +const uploadingFileList = reactive([ +]); + +// 等待上传文件列表 +const waitingFileList = reactive([]); + +// 取消上传文件的 cancelTokenSource 映射表 +const cancelTokenSourceMap = new Map(); + +// 正在上传或已完成文件映射表 key: fileInfo.index value: fileInfo +const uploadingFileMap = new Map(); + +// 文件上传 index +let uploadIndex = 0; + +// 文件上传状态顺序 +const uploadFileTypeSortMap = { + "error": 1, + "uploading": 2, + "waiting": 3, + "finished": 4, +}; + +export default function useFileUpload() { + + let { storageKey, currentPath } = useRouterData(); + const maxFileUploads = storageConfigStore.globalConfig.maxFileUploads; + + // 监听文件拖拽上传事件 + const listenDropFile = (dropArea) => { + // 拖拽进入, 显示提示. + dropArea = document.querySelector('body'); + + const dropOrPasteUpload = (e) => { + let isDrop = e.dataTransfer?.files?.length > 0; + + // 关闭提示 + removeDragClass(); + dropState.value = false; + + // 如果是拖拽上传,但已经打开了 dialog,则不处理上传. + if (hasDialog() && isDrop) { + return; + } + + // 如果 focus 在密码输入框,则不处理上传. + if (hasPasswordInputFocus()) { + return; + } + + if (!storageKey.value) { + return; + } + + // 如果不允许文件操作,直接返回. + if (!storageConfigStore.permission.upload) { + return; + } + + //阻止事件冒泡 + e.stopPropagation(); + //阻止事件的默认行为 + e.preventDefault(); + + // 打开全屏 loading + const loadingInstance = ElLoading.service({ + text: '文件读取中...', + background: 'rgba(0, 0, 0, .3)' + }) + + const items = e.clipboardData?.items || e.dataTransfer?.items; + + getFilesByDataTransferItemList(items).then((fileList) => { + // 关闭 loading + nextTick(() => { + // Loading should be closed asynchronously + loadingInstance.close() + }) + + if (fileList.length === 0) { + return; + } + + const uploadFileAction = () => { + visible.value = true; + fileList.forEach((item) => { + beforeUpload({ + file: item + }); + }) + } + + // 上传文件过多时,提示. + if (fileList.length > 100) { + ElMessageBox.confirm(`文件数量为 ${fileList.length} 个,是否确认上传?`, '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'success', + callback: (action) => { + if (action === 'confirm') { + uploadFileAction(); + } + } + }); + } else { + uploadFileAction(); + } + }) + } + + // 最后拖拽的目标元素 + let lastEnterTarget = null; + // 拖拽进入时,记录当前拖拽的目标元素,并添加防止拖拽穿透的 class + dropArea.addEventListener('dragenter', (event) => { + if (!storageKey.value) { + return; + } + + if (visible.value) { + return; + } + + // 如果不允许文件操作,直接返回. + if (!storageConfigStore.permission.upload) { + return; + } + + lastEnterTarget = event.target; + addDragClass(); + event.stopPropagation(); + event.preventDefault(); + dropState.value = true; + }, false); + + // 拖拽进入后, 移动位置时, 记录拖拽状态 + dropArea.addEventListener("dragover", function(event) { + if (!storageKey.value) { + return; + } + + if (visible.value) { + return; + } + + // 如果不允许文件操作,直接返回. + if (!storageConfigStore.permission.upload) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + dropState.value = true; + }, false); + + // 拖拽离开, 移除防拖拽穿透 class, 并关闭拖拽提示 + dropArea.addEventListener("dragleave", function(event) { + if (!storageKey.value) { + return; + } + + if (visible.value) { + return; + } + + // 如果不允许文件操作,直接返回. + if (!storageConfigStore.permission.upload) { + return; + } + + if(lastEnterTarget === event.target){ + removeDragClass(); + dropState.value = false; + } + + event.stopPropagation(); + event.preventDefault(); + }, false); + + // 拖拽放下时, 移除防拖拽穿透 class, 并关闭拖拽提示, 开始上传 + dropArea.addEventListener('drop', dropOrPasteUpload, false); + + // ctrl + v 粘贴文件时, 开始上传 + dropArea.addEventListener('paste', dropOrPasteUpload, false); + + // 根据拖拽或粘贴事件的 DataTransferItemList 列表获取文件列表 + const getFilesByDataTransferItemList = async (dataTransferItemList) => { + // 储存获取到的文件列表 + let fileList = []; + let DirectoryEntryList = []; + + if (dataTransferItemList) { + // 拖拽对象列表转换成数组 + let items = new Array(...dataTransferItemList); + // 获得 DirectoryEntry 对象列表 + for (let index = 0; index < items.length; index++) { + let e = items[index]; + let item = null; + // 兼容不同内核的浏览器 + if (e.webkitGetAsEntry) { + item = e.webkitGetAsEntry(); + if (!item) { + item = e.getAsFile(); + if (item) { + fileList.push(item); + continue; + } + } + } else if (e.getAsEntry) { + item = e.getAsEntry(); + } else { + ElMessage.warning("浏览器不支持拖拽上传"); + return; + } + DirectoryEntryList.push(item); + } + if (DirectoryEntryList.length > 0) { + for (let index = 0; index < DirectoryEntryList.length; index++) { + let item = DirectoryEntryList[index]; + if (item) { + //获取文件夹目录 + let FileTree = await getFileTree(item); + // 拿到目录下的所有文件 + if (Array.isArray(FileTree)) { + //展平文件夹 + flattenArray(FileTree, fileList); + } else { + //方便后续处理,单文件时也包装成数组 + fileList.push(FileTree); + } + } + } + } + } + + return fileList; + } + + // 添加 class, 防止拖拽穿透到子元素上 + const addDragClass = () => { + dropArea.classList.add('dragging-over'); + } + + // 拖拽完成, 移除 class + const removeDragClass = () => { + dropArea.classList.remove('dragging-over'); + } + + /** + * 获取文件 + */ + function fileSync(item) { + return new Promise((resolve, reject) => { + item.file(res => { + resolve(res); + }); + }); + } + + // 读取文件夹下的文件 + function readEntriesSync(dirReader) { + return new Promise((rel, rej) => { + dirReader.readEntries(res => { + rel(res); + }); + }); + } + + /** + * 展平数组 + * @param array 需要展平的数组 + * @param result 展平后的数组 + * + */ + function flattenArray(array, result) { + for (let i = 0; i < array.length; i++) { + if (Array.isArray(array[i])) { + flattenArray(array[i], result); + } else { + result.push(array[i]); + } + } + } + + /** + * 获取文件目录结构树 + * + */ + async function getFileTree(item) { + let path = item.fullPath || ""; + let dir = []; + if (item.isFile) { + let resFile = await fileSync(item); + resFile.dropUploadPath = path; + return resFile; + // item 为文件夹时 + } else if (item.isDirectory) { + let dirReader = item.createReader(); + let entries = await readEntriesSync(dirReader); + for (let i = 0; i < entries.length; i++) { + let proItem = await getFileTree(entries[i]); + dir.push(proItem); + } + return dir; + } + } + } + + // 打开上传文件 dialog + const openUploadDialog = () => { + visible.value = true; + uploadMode.value = 'file'; + } + + // 打开上传文件夹 dialog + const openUploadFolderDialog = () => { + visible.value = true; + uploadMode.value = 'folder'; + nextTick(() => { + document.getElementsByClassName('el-upload__input')[0].webkitdirectory = true; + }) + } + + /** + * 上传文件前的一些操作 + * @param param + */ + const beforeUpload = (param) => { + uploadFile(param.file, param.uploadBasePath); + } + // 文件上传操作. + const uploadFile = (file, uploadBasePath) => { + const fileIndex = uploadIndex++; + + uploadBasePath = uploadBasePath || currentPath.value; + + let uploadToPath = uploadBasePath; + + // 如果包含 webkitRelativePath, 则表示是文件夹上传, 需要获取文件完整路径 + if (file.webkitRelativePath || file.dropUploadPath) { + let pathStr = file.webkitRelativePath || file.dropUploadPath; + if (!pathStr.startsWith('/')) { + pathStr = '/' + pathStr; + } + let pathList = pathStr.split('/'); + pathList.forEach((item, index) => { + let isFirstItem = 0 === index; + let isLastItem = pathList.length - 1 === index; + + if (isFirstItem || isLastItem) { + return; + } + + if (item) { + uploadToPath += ('/' + item); + } + }) + } + + let param = { + storageKey: 'minio', + path: localStorage.getItem('filepath'), + name: file.name, + size: file.size + } + + console.log('当前上传信息:', param, ', 当前同时上传文件数:',uploadProgressInfoStatistics.value.totalUploading, '限制同时上传文件数:', maxFileUploads); + if (uploadProgressInfoStatistics.value.totalUploading >= maxFileUploads) { + console.log(`上传文件数超出 ${maxFileUploads}, 等待上传`); + waitingFileList.push({ + index: fileIndex, + file: file, + uploadBasePath: uploadBasePath, + }); + return; + } + + let uploadFileInfo = { + name: file.name, + size: file.size, + speed: '-', + progress: 0, + loaded: 0, + status: 'uploading', + startTime: Date.now(), + file: file, + index: fileIndex + } + uploadingFileList.push(uploadFileInfo); + cancelTokenSourceMap.set(fileIndex, axios.CancelToken.source()); + uploadingFileMap.set(fileIndex, uploadingFileList[uploadingFileList.length - 1]); + + uploadFileReq(param).then((res) => { + const { on } = useEventBus(`cancel-upload-${fileIndex}`); + on(() => { + let cancelTokenSource = cancelTokenSourceMap.get(fileIndex); + if (cancelTokenSource) { + cancelTokenSource.cancel(); + uploadingFileList.find((item, index) => { + let b = item.name === file.name; + if (b) { + uploadingFileList.splice(index, 1); + } + return b; + }); + } + }); + + + let proxyUploadType = common.storageType.proxyType; + let s3UploadType = common.storageType.s3Type; + let onedriveUploadType = common.storageType.micrsoftType; + + if (proxyUploadType.includes( param.storageKey)) { + fileProxyUpload(file, res.data, fileIndex); + } else if (s3UploadType.includes( param.storageKey)) { + s3FileUpload(file, res.data, fileIndex); + } else if (onedriveUploadType.includes( param.storageKey)) { + onedriveUpload(file, res.data, fileIndex); + } else if ( param.storageKey === 'upyun') { + upyunFileUpload(file, res.data, fileIndex); + } + }).catch((err) => { + baseOnUploadError(fileIndex, err) + }); + } + + // 服务器代理上传 + const fileProxyUpload = (file, uploadUrl, fileIndex) => { + let formData = new FormData(); + formData.append("file", file); + axios.post(uploadUrl, formData, { + cancelToken: cancelTokenSourceMap.get(fileIndex).token, + onUploadProgress: (progressEvent) => { + baseOnUploadProgress(progressEvent, fileIndex, true); + }, + }).then(() => { + baseOnUploadFinish(fileIndex); + }).catch((err) => { + baseOnUploadError(fileIndex, err); + }); + } + + // s3 通用上传 + const s3FileUpload = (file, uploadUrl, fileIndex) => { + axios.put(uploadUrl, file, { + withCredentials: false, + cancelToken: cancelTokenSourceMap.get(fileIndex).token, + onUploadProgress: (progressEvent) => { + baseOnUploadProgress(progressEvent, fileIndex); + } + }).then(() => { + baseOnUploadFinish(fileIndex); + }).catch((err) => { + baseOnUploadError(fileIndex, err); + }); + } + + // OneDrive SharePoint 上传 + const onedriveUpload = (file, uploadUrl, fileIndex) => { + let index = 1; // 当前块数 + let start = 0; // 开始字节数 + let end = 0; // 结束字节数 + let fileSize = file.size; // 文件大小 + const MAX_FILE_SIZE = 104857599; // 每块大小 100M + + if (fileSize === 0) { + baseOnUploadError(fileIndex, '当前存储类型不支持上传空文件'); + return; + } + + // 分块上传 + const uploadBlock = () => { + // 计算每块的开始和结束位置 + if (start + MAX_FILE_SIZE >= fileSize) { + end = fileSize; + } else { + end = start + MAX_FILE_SIZE; + } + + if (index > 1) { + cancelTokenSourceMap.set(fileIndex, axios.CancelToken.source()); + } + + // 截取文件每块上传 + let fileBlock = file.slice(start, end); + axios.put(`${uploadUrl}`, fileBlock, { + cancelToken: cancelTokenSourceMap.get(fileIndex).token, + timeout: 10000000, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Range': `bytes ${start}-${end - 1}/${file.size}` + }, + type: 'sync', + withCredentials: false, + onUploadProgress: progressEvent => { + if (progressEvent.lengthComputable) { + let uploadFileInfo = uploadingFileMap.get(fileIndex); + + const realLoaded = progressEvent.loaded + start; + + uploadFileInfo.size = fileSize; + uploadFileInfo.loaded = realLoaded; + uploadFileInfo.progress = Math.round(realLoaded / fileSize * 100); + uploadFileInfo.speed = common.fileSizeFormat(Math.round(realLoaded / (Date.now() - uploadFileInfo.startTime) * 1000)); + } + } + }).then((response) => { + if (response.status === 202) { + start += MAX_FILE_SIZE; + index += 1; + uploadBlock(); + } else if (response.status === 201 || response.status === 200) { + // console.log('file upload full success.', start, end); + baseOnUploadFinish(fileIndex); + } + }).catch((e) => { + baseOnUploadError(fileIndex, e) + }); + } + + uploadBlock(); + } + + // 又拍云上传 + const upyunFileUpload = (file, uploadUrl, fileIndex) => { + let uploadInfo = JSON.parse(uploadUrl); + let formData = new FormData(); + formData.append('name', file.name); + formData.append("authorization", uploadInfo.signature); + formData.append("policy", uploadInfo.policy); + formData.append("file", file); + + axios.post(uploadInfo.url, formData, { + withCredentials: false, + cancelToken: cancelTokenSourceMap.get(fileIndex).token, + onUploadProgress: (progressEvent) => { + baseOnUploadProgress(progressEvent, fileIndex); + } + }).then(() => { + baseOnUploadFinish(fileIndex); + }).catch((err) => { + baseOnUploadError(fileIndex, err); + }); + } + + // 通用上传结束设置. + const baseOnUploadError = (fileIndex, err) => { + let uploadFileInfo = uploadingFileMap.get(fileIndex); + uploadFileInfo.status = 'error'; + uploadFileInfo.endTime = Date.now(); + uploadFileInfo.msg = err?.response?.data || err; + } + + // 通用上传结束设置. + const baseOnUploadFinish = (fileIndex) => { + let uploadFileInfo = uploadingFileMap.get(fileIndex); + uploadFileInfo.progress = 100; + uploadFileInfo.status = 'finished'; + uploadFileInfo.endTime = Date.now(); + uploadFileInfo.msg = '上传成功'; + } + + // 通用上传进度条处理 + const baseOnUploadProgress = (progressEvent, fileIndex, isProxyUpload) => { + let uploadFileInfo = uploadingFileMap.get(fileIndex); + uploadFileInfo.size = progressEvent.total; + uploadFileInfo.loaded = progressEvent.loaded; + uploadFileInfo.progress = Math.round(progressEvent.loaded / progressEvent.total * 100); + uploadFileInfo.speed = common.fileSizeFormat(Math.round(progressEvent.loaded / (Date.now() - uploadFileInfo.startTime) * 1000)); + + console.log('uploadFileInfo', uploadFileInfo, isProxyUpload); + if (isProxyUpload && uploadFileInfo.progress === 100) { + uploadFileInfo.msg = '上传完成, 服务器中转中...'; + } + } + + // 取消上传请求 + const cancelUpload = (item) => { + ElMessageBox.confirm(`是否确定取消文件 ${item.name} 上传?`, '提示', { + confirmButtonText: '确定', + cancelButtonText: '返回', + type: 'warning', + callback: action => { + if (action === 'confirm') { + useEventBus(`cancel-upload-${item.index}`).emit(); + } + } + }); + } + + // 上传文件信息 + const uploadProgressInfoStatistics = computed(() => { + let totalSize = uploadingFileList.length; + let totalUploading = 0; + let totalFinish = 0; + uploadingFileList.forEach((item) => { + if (item.status === 'uploading') { + totalUploading++; + } else if (item.status === 'finished') { + totalFinish++; + } + }) + + let totalUploadingAndWaiting = totalUploading + waitingFileList.length; + + + return { + totalSize, + totalUploading, + totalFinish, + totalUploadingAndWaiting + }; + }) + + // 上传文件排序结果 + const uploadProgressInfoSorted = computed(() => { + let result = []; + result.push(...uploadingFileList); + waitingFileList.forEach((item) => { + result.push({ + name: item.file.name, + size: item.file.size, + status: 'waiting', + msg: '排队中...', + index: item.index + }) + }) + + result.sort((a, b) => { + let aStatus = a.status; + let bStatus = b.status; + if (aStatus !== bStatus) { + return uploadFileTypeSortMap[aStatus] - uploadFileTypeSortMap[bStatus]; + } + + if (a.startTime !== b.startTime) { + return a.startTime - b.startTime; + } + + // 如果状态一样,则按照开始时间排序 + return a.endTime - b.endTime; + }); + console.log('uploadProgressInfoSorted', result); + return result; + }) + + if (!isInitWatch) { + watch(() => uploadProgressInfoStatistics.value.totalUploading, (newValue) => { + if (newValue < maxFileUploads) { + console.log('检测到上传中的文件个数小于最大上传限制.'); + if (waitingFileList.length === 0) { + console.log('等待上传的文件数为 0, 无需继续上传.'); + } else { + let spliceList = waitingFileList.splice(0, 1); + let fileItem = spliceList[0]; + beforeUpload({ + file: fileItem.file, + uploadBasePath: fileItem.uploadBasePath + }); + console.log('开始从等待队列中获取上传文件: ', fileItem.file.name); + } + } + }); + isInitWatch = true; + } + + const removeUploadFileByIndex = (fileIndex) => { + if (fileIndex === null || fileIndex === undefined) { + return; + } + + let removeIndex = uploadingFileList.findIndex((item, index) => { + if (item.index === fileIndex) { + uploadingFileList.splice(index, 1); + return true; + } + }) + + if (removeIndex === -1) { + removeIndex = waitingFileList.findIndex((item, index) => { + if (item.index === fileIndex) { + waitingFileList.splice(index, 1); + return true; + } + }) + } + + if (removeIndex !== -1) { + uploadingFileMap.delete(fileIndex) + cancelTokenSourceMap.delete(fileIndex); + } + } + + const clearALlFinishedUploadFile = () => { + let deleteCount = 0; + for (let i = uploadingFileList.length - 1; i >= 0; i--) { + let item = uploadingFileList[i]; + if (item.status === 'finished') { + deleteCount++; + uploadingFileList.splice(i, 1); + uploadingFileMap.delete(item.index) + cancelTokenSourceMap.delete(item.index); + } + } + return deleteCount; + } + + const retryUpload = (item) => { + console.log('重新上传文件', item); + removeUploadFileByIndex(item.index); + uploadFile(item.file); + } + + return { + visible, uploadMode, openUploadDialog, openUploadFolderDialog, cancelUpload, dropState, listenDropFile, + beforeUpload, uploadFile, uploadProgressInfoSorted, uploadProgressInfoStatistics, + clearALlFinishedUploadFile, removeUploadFileByIndex, retryUpload + } +} \ No newline at end of file diff --git a/web/src/components/file/file/useTableOperator.js b/web/src/components/file/file/useTableOperator.js new file mode 100644 index 0000000..1132e07 --- /dev/null +++ b/web/src/components/file/file/useTableOperator.js @@ -0,0 +1,193 @@ +import { useKeyModifier } from '@vueuse/core' +import {removeDuplicateSlashes} from "fast-glob/out/managers/patterns"; + +import useGlobalConfigStore from "@/components/file/stores/global-config"; +let globalConfigStore = useGlobalConfigStore(); + +import useStorageConfigStore from "@/components/file/stores/storage-config"; +let storageConfigStore = useStorageConfigStore(); + +import useFileDataStore from "@/components/file/stores/file-data"; +let fileDataStore = useFileDataStore(); +import { ref,computed,watch} from "vue"; +// 按键监听 +const mateState = useKeyModifier('Meta'); +const controlState = useKeyModifier('Control'); +const shiftState = useKeyModifier('Shift'); + +// 是否按住了 Ctrl(Windows) 或者 Command(Mac) 键 +let isMultiSelectState = computed(() => { + return mateState.value || controlState.value; +}); + +import useFileOperator from "@/components/file/file/useFileOperator"; +let { batchDelete } = useFileOperator(); + +import useFileData from "@/components/file/file/useFileData"; +const { skeletonLoading, openRow } = useFileData(); + +import useFileSelect from "@/components/file/file/useFileSelect"; +const { selectRows, selectRow, clearSelection, toggleRowSelection, toggleAllSelection } = useFileSelect(); + +// ctrl + a 全选 +window.addEventListener("keydown", function(e) { + if (e.key === 'Escape' && allowShortcuts()) { + clearSelection(); + } else if (e.key === 'a' && (e.metaKey || e.ctrlKey) && allowShortcuts()) { + e.preventDefault(); + toggleAllSelection(); + } else if (e.key === 'Delete' && allowShortcuts()) { // 如果按了删除键,且当前状态允许快捷键操作 + if (batchDelete && selectRows?.value?.length > 0) { + e.preventDefault(); + batchDelete(); + } + } else if (e.key === 'Backspace' && allowShortcuts()) { // 如果按了删除键,且当前状态允许快捷键操作 + if (fileDataStore.fileList.length > 0 && fileDataStore.fileList[0].type === 'BACK') { + tableClickRow(fileDataStore.fileList[0]); + } + } +}, false); + +export const allowShortcuts = () => { + // 仅鼠标悬浮在 table 上时且没有打开 dialog 时,才允许快捷键操作. + return hoverBody() && hasDialog() === false; +} + +// 是否悬浮在首页上 +export const hoverBody = () => { + // 仅鼠标悬浮在 table 上时且没有打开 dialog 时,才允许快捷键操作. + return document.querySelector(".zfile-index-body:hover") +} + +// 当前是否打开了 dialog +export const hasDialog = () => { + return !!document.querySelector(".el-popup-parent--hidden") +} + +// 当前是否聚焦在密码输入框 +export const hasPasswordInputFocus = () => { + return document.querySelector(".is-message-box .el-input__inner") === document.activeElement; +} + +// 拖拽选择相关 +// 开始拖拽的文件行索引 +const startDragClickIndex = ref(-1); +// 结束拖拽的文件行索引 +const endDragClickIndex = ref(-1); +// 是否按下鼠标左键 +import { useMousePressed } from '@vueuse/core' +const { pressed } = useMousePressed() + +watch(() => pressed.value, (value, oldValue) => { + // 如果之前是点击状态, 现在松开了则清除记录的拖拽索引 + if (value === false && oldValue === true) { + startDragClickIndex.value = -1; + endDragClickIndex.value = -1; + } +}) + + +let tableClickRow; + +export default function useTableOperator() { + + // 文件单击事件 + tableClickRow = (row, event) => { + if (event === undefined) { + openRow(row); + return; + } + + let isClickSelection = event.type === 'selection'; + + // 如果点击的是文件或文件夹, 且点击的不是 checkbox 列, 且操作习惯是单击打开, 则打开文件/文件夹 + if (!isClickSelection && storageConfigStore.globalConfig.fileClickMode === 'click') { + openRow(row); + return; + } + + // 加载骨架屏时,点击无效. + if (skeletonLoading.value) { + return; + } + + let isClickSelf = selectRows.value.length === 1 && selectRow.value?.name === row.name; + + // 如果按住了 shift 选中 + if (shiftState.value) { + + // 上一个选择的 index + let prevSelectIndex = fileDataStore.fileList.findIndex(value => value.name === selectRow.value.name); + + // 如果选中行之前也按 shift 选中过行,表示要进行多选 + if (prevSelectIndex !== null) { + let currentShiftIndex = fileDataStore.fileList.findIndex(value => value.name === row.name); + + let start = Math.min(currentShiftIndex, prevSelectIndex); + let end = Math.max(currentShiftIndex, prevSelectIndex); + + for (let i = start + 1; i < end; i++) { + let item = fileDataStore.fileList[i]; + toggleRowSelection(item, true); + } + } + } + // 如果不是以下情况,则取消所有选择: + // 1. 多选状态:按住 command (Mac) 或 Ctrl (Windows) + // 2. 选中当前文件: 如目前只选中了一个文件,且就是当前文件, 则不需要取消所有选择, 取消后, 无法 toggle 当前行. + // 3. 点击的区域不是 selection, 如果点击的是 selection, 则可能为误触, 实际想点击的是 checkbox + else if (!isMultiSelectState.value && !isClickSelf && !isClickSelection) { + clearSelection(); + } + toggleRowSelection(row); + } + + // 文件双击事件 + const tableDbClickRow = (row) => { + // 加载骨架屏时,点击无效. + if (skeletonLoading.value) { + return; + } + openRow(row); + } + + // 进入悬浮事件 + const tableHoverRow = (row, column, cell, event) => { + // 如果当前是点击状态,且开始拖拽选中索引不为 -1, 表示要进行拖拽了. + if (event.buttons === 1 && startDragClickIndex.value !== -1) { + // 将开始拖拽的行选中 + if (endDragClickIndex.value === -1) { + clearSelection(); + let item = fileDataStore.fileList[startDragClickIndex.value]; + toggleRowSelection(item, true); + } + + + let newEndDragClickIndex = row.index; + + let oldEndDragClickIndex = endDragClickIndex.value >= 0 ? endDragClickIndex.value : newEndDragClickIndex - 1; + + // 可能是倒序拖拽的,所以要区分 start 和 end + let start = Math.min(oldEndDragClickIndex, newEndDragClickIndex); + let end = Math.max(oldEndDragClickIndex, newEndDragClickIndex); + for (let i = start; i <= end; i++) { + let item = fileDataStore.fileList[i]; + toggleRowSelection(item, true); + } + + endDragClickIndex.value = newEndDragClickIndex; + } + } + + // 离开悬浮事件 + const tableLeaveRow = (row, column, cell, event) => { + if (event.buttons === 1 && startDragClickIndex.value === -1) { + startDragClickIndex.value = row.index; + } + } + + return { + tableClickRow, tableDbClickRow, + tableHoverRow, tableLeaveRow + } +} diff --git a/web/src/components/file/messageBox/confirm/confirm.vue b/web/src/components/file/messageBox/confirm/confirm.vue new file mode 100644 index 0000000..a0ba07a --- /dev/null +++ b/web/src/components/file/messageBox/confirm/confirm.vue @@ -0,0 +1,112 @@ + + + diff --git a/web/src/components/file/messageBox/confirm/index.ts b/web/src/components/file/messageBox/confirm/index.ts new file mode 100644 index 0000000..43ed9fe --- /dev/null +++ b/web/src/components/file/messageBox/confirm/index.ts @@ -0,0 +1,27 @@ +import { createApp } from "vue"; +import Confirm from "./confirm.vue"; +import { Props } from "./types"; + +const createDialog = (message: string, title: string, options: Props = {}) => new Promise((resolve, reject) => { + const mountNode = document.createElement('div') + const Instance = createApp(Confirm, { + show: true, + message, + title, + ...options, + onClose: (type: string) => { + if (type === 'confirm') { + resolve(type); + } else { + reject(type); + } + Instance.unmount(); + document.body.removeChild(mountNode); + } + }) + + document.body.appendChild(mountNode) + Instance.mount(mountNode) +}); + +export default createDialog; diff --git a/web/src/components/file/messageBox/confirm/types.ts b/web/src/components/file/messageBox/confirm/types.ts new file mode 100644 index 0000000..d133b94 --- /dev/null +++ b/web/src/components/file/messageBox/confirm/types.ts @@ -0,0 +1,12 @@ +export declare type MessageType = '' | 'success' | 'warning' | 'info' | 'error'; + +// 声明 Options 类型 +export interface Props { + type?: MessageType; + title?: String; + message?: String; + confirmButtonText?: String; + cancelButtonText?: String; + dangerouslyUseHTMLString?: Boolean; + +} diff --git a/web/src/components/file/messageBox/messageBox.ts b/web/src/components/file/messageBox/messageBox.ts new file mode 100644 index 0000000..316d672 --- /dev/null +++ b/web/src/components/file/messageBox/messageBox.ts @@ -0,0 +1,9 @@ +import openPrompt from "./prompt/index"; +import openConfirm from "./confirm/index"; + +const MessageBox = { + prompt: openPrompt, + confirm: openConfirm +} + +export default MessageBox; diff --git a/web/src/components/file/messageBox/prompt/index.ts b/web/src/components/file/messageBox/prompt/index.ts new file mode 100644 index 0000000..a237271 --- /dev/null +++ b/web/src/components/file/messageBox/prompt/index.ts @@ -0,0 +1,27 @@ +import { createApp } from "vue"; +import Prompt from "./prompt.vue"; +import { ConfirmResult, Props } from "./types"; + +const createDialog = (message: string, title: string, options: Props = {}) => new Promise((resolve, reject) => { + const mountNode = document.createElement('div') + const Instance = createApp(Prompt, { + show: true, + message, + title, + ...options, + onClose: (res: ConfirmResult) => { + if (res.type === 'confirm') { + resolve(res); + } else { + reject(res); + } + Instance.unmount(); + document.body.removeChild(mountNode); + } + }) + + document.body.appendChild(mountNode) + Instance.mount(mountNode) +}); + +export default createDialog; diff --git a/web/src/components/file/messageBox/prompt/prompt.vue b/web/src/components/file/messageBox/prompt/prompt.vue new file mode 100644 index 0000000..ff80230 --- /dev/null +++ b/web/src/components/file/messageBox/prompt/prompt.vue @@ -0,0 +1,171 @@ + + + diff --git a/web/src/components/file/messageBox/prompt/types.ts b/web/src/components/file/messageBox/prompt/types.ts new file mode 100644 index 0000000..ac9fe6d --- /dev/null +++ b/web/src/components/file/messageBox/prompt/types.ts @@ -0,0 +1,25 @@ +// 声明返回值类型 +export interface ConfirmResult { + checkbox: boolean + value: string + type: 'cancel' | 'close' | 'confirm' +} + + +// 声明 Options 类型 +export interface Props { + title?: String; + message?: String; + confirmButtonText?: String; + cancelButtonText?: String; + inputType?: "text" | "password"; + inputPlaceholder?: String; + inputDefault?: String; + showPassword?: Boolean; + checkbox?: Boolean; + checkboxLabel?: String; + readonly defaultChecked?: Boolean; + inputValidator?: Function; + inputErrorMessage?: String; + dangerouslyUseHTMLString?: Boolean; +} diff --git a/web/src/components/file/stores/file-data.ts b/web/src/components/file/stores/file-data.ts new file mode 100644 index 0000000..c112d83 --- /dev/null +++ b/web/src/components/file/stores/file-data.ts @@ -0,0 +1,107 @@ +import { defineStore } from 'pinia' + +// @ts-ignore +import common from "@/components/file/common"; +import useStorageConfigStore from "./storage-config"; + +// 当前存储源的配置信息,数据来源为服务端配置。请求存储源后会获取其配置信息。 +const useFileDataStore = defineStore('fileDataStore', { + state: () => { + return { + currentClickRow: {}, + currentRightClickRow: {}, + currentStorageSource: { + id: null, + type: { + description: '', + key: '' + } + }, + imgMode: false, + newImgMode: false, + oldStorageKey: null, + searchParam: '', + fileListSource: [], + loadFileSize: -1, + audioArray: [], + audioIndex: 0, + } + }, + getters: { + filterFileByType: (state) => { + return (type: string) => { + return state.fileListSource.filter(function (item:any) { + if (item.type === 'BACK') { + return false + } + let name = item.name; + let suffix = name.substr(name.lastIndexOf('.') + 1).toLowerCase(); + return common.constant.fileTypeMap[type].indexOf(suffix) !== -1; + }); + }; + }, + fileList: state => { + if (state.loadFileSize === -1) return []; + let firstIsBack = state.fileListSource[0]?.type === 'BACK'; + let toSize = firstIsBack ? state.loadFileSize + 1 : state.loadFileSize; + toSize = toSize > state.fileListSource.length ? state.fileListSource.length : toSize; + let tableData = state.fileListSource.slice(0, toSize); + tableData.forEach((item:any) => { + // 生成图标 + if (!item.icon) { + item['icon'] = common.getFileIconName(item); + } + if (item.preview !== null) { + // 获取文件类型 + let fileType = common.getFileType(item.name); + if (fileType) { + // 获取文件是否可预览 + item['fileType'] = fileType; + item.preview = common.constant.previewFileType.indexOf(fileType) !== -1; + } else { + item.preview = false; + } + } + }); + return tableData; + }, + getFileUrlByName: state => { + return (name: string) => { + let item = state.fileListSource.find((item:any) => item.name === name); + if (item) { + return item.url; + } + return ''; + }; + } + }, + actions: { + updateCurrentStorageSource(val: any) { + this.currentStorageSource = val; + }, + updateAudioIndex(val: any) { + this.audioIndex = val; + }, + updateAudioList(val: any) { + this.audioArray = val; + }, + updateCurrentClickRow(val: any) { + this.currentClickRow = val; + }, + updateCurrentRightClickRow(val: any) { + this.currentRightClickRow = val; + }, + updateFileList(val: any) { + this.fileListSource = val; + this.loadFileSize = useStorageConfigStore().globalConfig.maxShowSize; + }, + updateOldStorageKey(val: any) { + this.oldStorageKey = val; + }, + updateLoadFileSize(val: number) { + this.loadFileSize = val; + } + }, +}) + +export default useFileDataStore; \ No newline at end of file diff --git a/web/src/components/file/stores/global-config.ts b/web/src/components/file/stores/global-config.ts new file mode 100644 index 0000000..1e5faf9 --- /dev/null +++ b/web/src/components/file/stores/global-config.ts @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia' + +// 全局配置信息, 初始化自 /public/zfile.config.json 文件中. +const useGlobalConfigStore = defineStore('globalConfigStore', { + state: () => { + return { + zfileConfig: { + baseUrl: "", + router: { + mode: "history" + }, + skeleton: { + enable: true, + show: "always", + size: 20 + }, + gallery: { + mobileColumn: 5, + column: 3, + columnSpacing: 50, + rowSpacing: 10, + showInfo: true, + showInfoMode: "hover", + roundedBorder: true, + showBackTop: true, + }, + imagePreview: { + mode: "full", + gallery: true + }, + officePreview: {} + } + } + }, + actions: { + updateZfileConfig(val: any) { + this.zfileConfig = val + } + }, +}) + +export default useGlobalConfigStore; \ No newline at end of file diff --git a/web/src/components/file/stores/storage-config.ts b/web/src/components/file/stores/storage-config.ts new file mode 100644 index 0000000..d24e02c --- /dev/null +++ b/web/src/components/file/stores/storage-config.ts @@ -0,0 +1,77 @@ +import { defineStore } from "pinia"; + +import useFileSelect from "@/components/file/file/useFileSelect"; + +let { selectStatistics } = useFileSelect(); + + +// 当前存储源的配置信息,数据来源为服务端配置。请求存储源后会获取其配置信息。 +const useStorageConfigStore = defineStore('storageConfigStore', { + state: () => { + return { + globalConfig: { + siteName: '', + directLinkPrefix: '', + infoEnable: false, + showLinkBtn: false, + recordDownloadLog: false, + showShortLink: false, + showPathLink: false, + tableSize: 'small', + rootShowStorage: true, + fileClickMode: 'dbclick', + showDocument: false, + debugMode: false, + domain: '', + icp: '', + avatar: '', + announcement: '', + layout: 'full', + showAnnouncement: false, + searchEnable: false, + showLogin: false, + siteHomeName: '首页', + siteHomeLogo: '', + siteHomeLogoLink: '', + siteHomeLogoTargetMode: '', + maxShowSize: 1000, + loadMoreSize: 50, + defaultSortField: 'name', + defaultSortOrder: 'asc', + linkExpireTimes: '' + }, + folderConfig: { + readmeText: null, + readmeDisplayMode: null, + defaultSwitchToImgMode: false, + enableFileOperator: false, + } + } + }, + getters: { + permission: (state) => { + return { + open: selectStatistics.value.isSingleSelect && selectStatistics.value.isAllFolder, + preview: selectStatistics.value.isAllFile && selectStatistics.value.isSingleSelect, + download: selectStatistics.value.isAllFile, + link: selectStatistics.value.isAllFile && state.globalConfig.showLinkBtn && (state.globalConfig.showShortLink || state.globalConfig.showPathLink), + rename: state.folderConfig.enableFileOperator && selectStatistics.value.isSingleSelect, + delete: state.folderConfig.enableFileOperator, + newFolder: state.folderConfig.enableFileOperator, + upload: state.folderConfig.enableFileOperator, + pathLink: state.globalConfig.showPathLink, + shortLink: state.globalConfig.showShortLink, + } + } + }, + actions: { + updateGlobalConfig(val: any) { + this.globalConfig = val; + }, + updateFolderConfig(val: any) { + this.folderConfig = val; + } + }, +}) + +export default useStorageConfigStore; diff --git a/web/src/components/file/useCommon.js b/web/src/components/file/useCommon.js new file mode 100644 index 0000000..903b0f2 --- /dev/null +++ b/web/src/components/file/useCommon.js @@ -0,0 +1,80 @@ +// 获取窗口宽高 +import { useWindowSize } from '@vueuse/core' +import { ref,computed} from "vue"; +const { width, height } = useWindowSize() + +export default function useCommon() { + + const isMobile = computed(() => { + return width.value < 768; + }) + + const isNotMobile = computed(() => { + return width.value >= 768; + }) + + /** + * encodeURIComponent 编码所有 url, 但忽略 / 字符 + * @param str + * @returns {string} + */ + const encodeAllIgnoreSlashes = (str) => { + if (strIsEmpty(str)) { + return ''; + } + + let result = ''; + let prevIndex = -1; + + for (let i = 0; i < str.length; i++) { + const c = str.charAt(i); + if (c === '/') { + if (prevIndex < i) { + let subStr = str.substring(prevIndex + 1, i); + result += encodeURIComponent(subStr); + prevIndex = i; + } + result += c; + } + + if (i === str.length - 1 && prevIndex < i) { + let subStr = str.substring(prevIndex + 1, i + 1); + result += encodeURIComponent(subStr); + } + } + return result; + } + + const strIsEmpty = (str) => { + return str === null || str === undefined || str === ''; + } + + const strIsNotEmpty = (str) => { + return !strIsEmpty(str); + } + + const removeDuplicateSeparator = (path) => { + let result = ''; + + if (path.indexOf("http://") === 0) { + result = "http://"; + } else if (path.indexOf("https://") === 0) { + result = "https://"; + } + + for (let i = result.length; i < path.length - 1; i++) { + let current = path.charAt(i); + let next = path.charAt(i + 1); + if (!(current === '/' && next === '/')) { + result += current; + } + } + result += path.charAt(path.length - 1); + return result; + } + + return { + isMobile, isNotMobile, height, encodeAllIgnoreSlashes, strIsEmpty, strIsNotEmpty, removeDuplicateSeparator + } + +} \ No newline at end of file diff --git a/web/src/components/file/useRouterData.js b/web/src/components/file/useRouterData.js new file mode 100644 index 0000000..2768d9b --- /dev/null +++ b/web/src/components/file/useRouterData.js @@ -0,0 +1,39 @@ +const routerRef = ref(null); +const routeRef = ref(null); +import { ref,computed} from "vue"; +export default function useRouterData(initRouter, initRoute) { + + if (initRouter && !routerRef.value) { + routerRef.value = initRouter; + } + + if (initRoute && !routeRef.value) { + routeRef.value = initRoute; + } + + // 当前所在驱动器 key + const storageKey = computed(() => { + return routeRef.value?.params.storageKey; + }); + + // 当前所在目录 + const currentPath = computed(() => { + if (routeRef.value?.params.fullpath) { + return '/' + routeRef.value.params.fullpath.join('/'); + } else { + return '/'; + } + }); + + const fullpath = computed(() => { + return routeRef.value?.params.fullpath; + }); + + return { + routerRef, + storageKey, + currentPath, + fullpath + }; + +} \ No newline at end of file diff --git a/web/src/components/header/useHeaderBreadcrumb.js b/web/src/components/header/useHeaderBreadcrumb.js new file mode 100644 index 0000000..e04805a --- /dev/null +++ b/web/src/components/header/useHeaderBreadcrumb.js @@ -0,0 +1,84 @@ +import { removeDuplicateSlashes } from "fast-glob/out/managers/patterns"; +import useHeaderStorageList from "./useHeaderStorageList"; + +import useStorageConfigStore from "@/components/file/stores/storage-config"; +let storageConfigStore = useStorageConfigStore(); + +import useRouterData from "@/components/file/useRouterData"; +let { fullpath, storageKey } = useRouterData(); + +import useCommon from "@/components/file/useCommon"; +const { encodeAllIgnoreSlashes } = useCommon(); + +// 面包屑数据 +let breadcrumbData = ref([]); +let initialized = false; +export default function useBreadcrumb() { + + let rootShowStorage = storageConfigStore.globalConfig.rootShowStorage; + + // 构建面包屑 + let buildBreadcrumbData = () => { + if (!rootShowStorage && !storageKey.value) { + breadcrumbData.value = []; + return; + } + breadcrumbData.value = [ + { + name: storageConfigStore.globalConfig.siteHomeName || '首页', + href: rootPath.value, + disable: false + } + ]; + + // 如果为包含根目录模式,则面包屑显示驱动器 + if (rootShowStorage) { + let { findStorageByKey } = useHeaderStorageList(); + let storageByKey = findStorageByKey(storageKey.value); + if (storageByKey) { + breadcrumbData.value.push({ + name: storageByKey.name, + href: encodeAllIgnoreSlashes('/' + storageByKey.key) + }) + } + } + + if (fullpath.value) { + fullpath.value.forEach((item, index, arr) => { + if (item) { + let breadcrumbItem = { + name: item, + href: encodeAllIgnoreSlashes(removeDuplicateSlashes('/' + storageKey.value + '/' + arr.slice(0, index + 1).join('/'))), + disable: index === arr.length - 1 + } + breadcrumbData.value.push(breadcrumbItem); + } + }) + } + }; + + /** + * 根目录路径 + */ + let rootPath = computed(() => '/' + (rootShowStorage ? '' : storageKey.value)); + + if (!initialized) { + // 当 URL 变化, 则自动重新 build 面包屑 + watch(() => fullpath.value, () => { + buildBreadcrumbData(); + }) + + // 当 URL 变化, 则自动重新 build 面包屑 + watch(() => storageKey.value, () => { + buildBreadcrumbData(); + }) + } + initialized = true; + + return { + rootPath, + breadcrumbData, + buildBreadcrumbData + } + +} diff --git a/web/src/components/header/useHeaderDebugMode.js b/web/src/components/header/useHeaderDebugMode.js new file mode 100644 index 0000000..3a1299d --- /dev/null +++ b/web/src/components/header/useHeaderDebugMode.js @@ -0,0 +1,26 @@ +import {resetPasswordReq} from "@/api/header"; + +export default function useHeaderDebugMode() { + // debug 模式重置管理员密码 + const resetAdminPwd = () => { + ElMessageBox.confirm('是否确认重置后台管理员密码?重置后用户名/密码将强制修改为 admin 123456', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning', + callback: action => { + if (action === 'confirm') { + resetPasswordReq().then((response) => { + if (response.code === 0) { + ElMessage.success("重置成功,请及时关闭 debug 功能,防止出现安全问题!"); + } + }); + } + } + }); + } + + return { + resetAdminPwd + } + +} \ No newline at end of file diff --git a/web/src/components/header/useHeaderStorageList.js b/web/src/components/header/useHeaderStorageList.js new file mode 100644 index 0000000..2546991 --- /dev/null +++ b/web/src/components/header/useHeaderStorageList.js @@ -0,0 +1,130 @@ +import { getSourceListReq } from "@/api/header"; +import { ref,computed,watch} from "vue"; +import useFileDataStore from "@/components/file/stores/file-data"; +let fileDataStore = useFileDataStore(); + +import useStorageConfigStore from "@/components/file/stores/storage-config"; +let storageConfigStore = useStorageConfigStore(); + +import useRouterData from "@/components/file/useRouterData"; +let { routerRef, fullpath, storageKey } = useRouterData(); + +let storageList = ref([]); +let currentStorageKey = ref(); + +let initialized = false; + +export default function useHeaderStorageList() { + + let rootShowStorage = storageConfigStore.globalConfig.rootShowStorage; + + + // 加载存储源列表 + let loadStorageSourceList = () => { + return new Promise((resolve) => { + // 请求获取所有存储器列表 + getSourceListReq().then((response) => { + storageList.value = response.data; + // 如果没有存储源, 则直接提示是否添加 + if (storageList.value.length === 0) { + ElMessageBox.confirm('当前无可用存储源,是否跳转至管理员页面添加存储源?', '提示', { + confirmButtonText: '确定', cancelButtonText: '取消', type: 'info', callback: action => { + if (action === 'confirm') { + routerRef.value.push('/login'); + } + } + }); + return; + } + + rootPathAction(rootShowStorage); + resolve(response); + }); + }) + } + + // 如果路由处理根目录的操作. + const rootPathAction = (rootShowStorage) => { + // 如果当前 URL 参数中有存储源 ID, 则直接用当前的. + if (storageKey.value) { + // 判断 url 中的 storageKey 是否存在于 storageList 中. 如果不存在, 则跳转到首页 + let storageByKey = findStorageByKey(storageKey.value); + + if (storageByKey) { + currentStorageKey.value = storageKey.value; + } else { + // 否则读取存储源列表中的第一个, 并跳转到响应的 URL 中. + routerToFirstStorage(); + } + } else { + if (rootShowStorage) { + fileDataStore.updateFileList(storageListAsFileList.value); + document.title = storageConfigStore.globalConfig.siteName + ' | 首页'; + } else { + routerToFirstStorage(); + } + } + refreshCurrentStorageSource(); + } + + // 将存储源类别转换到文件列表 + const storageListAsFileList = computed(() => { + let fileList = []; + storageList.value.forEach((item) => { + fileList.push({ + name: item.name, path: item.key, size: 0, time: '-', type: 'ROOT' + }) + }) + return fileList; + }) + + // 更新当前存储策略到 pinia 中 + const refreshCurrentStorageSource = () => { + storageList.value.some((item) => { + if (item.key === currentStorageKey.value) { + fileDataStore.updateCurrentStorageSource(item); + } + }); + } + + if (!initialized) { + // 当存储策略变化时, 切换路由中的值 + watch(() => currentStorageKey.value, (newVal, oldVal) => { + // 如果切换到了新存储源,且没有指定路径,则进行切换 + if ((newVal && !fullpath.value) || oldVal !== undefined) { + routerRef.value.push('/' + newVal); + refreshCurrentStorageSource(); + } + }); + + watch(() => storageKey.value, (val) => { + rootPathAction(true); + }) + initialized = true; + } + + // 跳转到第一个存储器 + const routerToFirstStorage = () => { + // 否则读取存储源列表中的第一个, 并跳转到响应的 URL 中. + if (storageList.value.length > 0) { + let firstStorageKey = storageList.value[0].key; + currentStorageKey.value = firstStorageKey; + routerRef.value.push('/' + firstStorageKey); + } + } + + /** + * 根据存储源 key 查找对象 + */ + const findStorageByKey = (key) => { + return storageList.value.find(item => { + if (item.key == key) { + return item; + } + }); + } + + return { + loadStorageSourceList, storageList, findStorageByKey, currentStorageKey, storageListAsFileList + } +} \ No newline at end of file diff --git a/web/src/components/header/useSetting.js b/web/src/components/header/useSetting.js new file mode 100644 index 0000000..bbeb138 --- /dev/null +++ b/web/src/components/header/useSetting.js @@ -0,0 +1,55 @@ +const visible = ref(false); +import { useStorage } from '@vueuse/core'; +import useCommon from "@/components/file/useCommon"; +const { isMobile } = useCommon(); + +// 获取默认值 +import useGlobalConfigStore from "@/components/file/stores/global-config"; +let globalConfigStore = useGlobalConfigStore(); +let zfileConfig = globalConfigStore.zfileConfig; + +let baseData = { + view: { + size: 2 + }, + gallery: { + column: isMobile.value ? zfileConfig.gallery.mobileColumn : zfileConfig.gallery.column, + columnSpacing: zfileConfig.gallery.columnSpacing, + rowSpacing: zfileConfig.gallery.rowSpacing, + showInfo: zfileConfig.gallery.showInfo, + showInfoMode: zfileConfig.gallery.showInfoMode, + roundedBorder: zfileConfig.gallery.roundedBorder, + showBackTop: zfileConfig.gallery.showBackTop + }, + imagePreview: { + mode: zfileConfig.imagePreview.mode, + gallery: zfileConfig.imagePreview.gallery + } +}; + +const extend = (a, b) => { + b = b || this; + + for(let key in a) { + if (b[key] === undefined) { + b[key] = a[key]; + } else if (a[key] instanceof Object && b[key] instanceof Object) { + extend(a[key], b[key]); + } + } +} + +const zfileSettingCache = useStorage('zfile-setting-cache', baseData); + +export default function useSetting() { + + const openSettingVisible = () => { + visible.value = true; + } + + extend(baseData, zfileSettingCache.value) + + return { + visible, zfileSettingCache, openSettingVisible + } +} \ No newline at end of file diff --git a/web/src/styles/peeling.scss b/web/src/styles/peeling.scss index f3307f7..d0dd8a2 100644 --- a/web/src/styles/peeling.scss +++ b/web/src/styles/peeling.scss @@ -1,5 +1,5 @@ html .el-dialog__header{ - background-color: rgba(248, 248, 248, 1); + // background-color: rgb(250, 250, 250); margin-right: 0px; } html.dark .fixed-header{ diff --git a/web/src/views/dashboard/index.vue b/web/src/views/dashboard/index.vue index 38d5db9..38b15bf 100644 --- a/web/src/views/dashboard/index.vue +++ b/web/src/views/dashboard/index.vue @@ -587,183 +587,9 @@ function tableMouseLeave(){ @@ -295,7 +285,7 @@ $light_gray: #eee; } .login-container { - background: url('../../assets/login/loginbg.jpg') no-repeat; + background: url('../../assets/login/beijing.jpg') no-repeat; background-size: cover; background-position: center center; min-height: 100%; diff --git a/web/src/views/login/木林森登录页面.vue b/web/src/views/login/木林森登录页面.vue index c4947bc..a023300 100644 --- a/web/src/views/login/木林森登录页面.vue +++ b/web/src/views/login/木林森登录页面.vue @@ -287,7 +287,7 @@ $light_gray: #eee; } .login-container { - background: url('../../assets/login/loginbg.jpg') no-repeat; + background: url('../../assets/login/beijing.jpg') no-repeat; background-size: cover; background-position: center center; min-height: 100%; diff --git a/web/src/views/special/document/index.vue b/web/src/views/special/document/index.vue index fa58169..cdbc659 100644 --- a/web/src/views/special/document/index.vue +++ b/web/src/views/special/document/index.vue @@ -9,16 +9,12 @@ import { ref, onMounted, nextTick } from "vue"; import { useAppStore } from '@/store/modules/app'; import { ElMessageBox, ElMessage } from "element-plus"; import Page from '@/components/Pagination/page.vue'; -import { projectList, getNodesTree, addNodes, updateNodes, deleteNodesById } from "@/api/document"; -import uploadFiles from '@/components/uploadFiles/index.vue' - -const handleSuccess = (file: any) => { - console.log('上传成功:', file.name) -} - -const handleError = ({ file, error }) => { - console.error('上传失败:', file.name, error) -} +import { projectList, getNodesTree, addNodes, updateNodes, deleteNodesById,getFilesPage } from "@/api/document"; +import ZUpload from '@/components/file/ZUpload.vue' +import useFileUpload from "@/components/file/file/useFileUpload"; +import useHeaderStorageList from "@/components/header/useHeaderStorageList"; +const { openUploadDialog, openUploadFolderDialog, uploadProgressInfoStatistics } = useFileUpload(); +const { currentStorageKey } = useHeaderStorageList(); onMounted(() => { getProject() }); @@ -71,8 +67,9 @@ function gettreedata() { treeloading.value = false }) } +const pathid = ref() function handleNodeClick(data: any, node: any) { - + pathid.value = data.id } //子项目配置 const frame = ref(false) @@ -210,7 +207,7 @@ const queryParams: any = ref({ name: '' }); //定义表格数据 -const tableData: any = ref([{}, {}]); +const tableData: any = ref([]); const total = ref(0); // 表格加载 const loading = ref(false) @@ -221,12 +218,38 @@ function getdata() { //上传组件 const upfile = ref(false) function openFile() { + localStorage.setItem('filepath', findPathById(treedata.value,pathid.value)); upfile.value = true } function fileClose() { upfile.value = false } - +function findPathById(array:any, targetId:any) { + // 辅助函数,用于递归查找路径 + function recursiveSearch(items:any, target:any, path:any) { + for (let item of items) { + // 将当前对象的name添加到路径中 + let newPath = [...path, item.nodeName]; + + // 检查当前对象的id是否匹配目标id + if (item.id === target) { + return newPath.join('/'); // 找到匹配项,返回路径字符串 + } + + // 如果没有找到匹配项,继续检查children数组 + if (item.children && item.children.length > 0) { + let result = recursiveSearch(item.children, target, newPath); + if (result) { // 如果在children中找到了匹配项,返回结果 + return result; + } + } + } + return null; // 如果没有找到匹配项,返回null + } + // 从顶层数组开始搜索 + let patharr = recursiveSearch(array, targetId, []) + return '/'+patharr; // 注意这里我们传入了一个空数组作为初始路径 +}