From f577d9d1e498c7344ecdacecef5c868f381c2e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BE=99=E9=BE=99=E9=BE=99?= Date: Thu, 16 Nov 2023 21:12:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal-web):=20=E9=97=A8=E6=88=B7=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD=20(#942)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 文件编辑功能 ## 1. 查看页面采取预览模态框,取消现有的网页形式,同时还能解决现在文件较大时查看可能会崩溃的情况; ![image](https://github.com/PKUHPC/SCOW/assets/140392039/30bb6918-d125-4523-aa80-a01d2fb0191c) ![image](https://github.com/PKUHPC/SCOW/assets/140392039/ff18fdd0-0f07-496e-b5b9-09bfbb5819e8) ## 2. 点击文件为查看操作,此时需判断该文件是否支持在线查看,判断标准:文件格式,ppt、doc、exe等确定无法查看的文件 不允许查看,文件大小超标的(管理员配置,默认50M)的不允许查看。点击后弹出提示“文件过大或者格式不支持,请下载后查看”,不再自动下载文件。其余文件允许查看(仍然可能是乱码,但是没关系,除了明确无法查看的应该尽量允许用户查看)。 ![image](https://github.com/PKUHPC/SCOW/assets/140392039/469507b3-7b99-488c-a6b2-12b4f074e1ca) ![image](https://github.com/PKUHPC/SCOW/assets/140392039/113ba9bd-9ec8-47e8-a879-06873ae09977) ## 3. 在查看文件页面,右下角增加编辑按钮,右上角增加全屏/取消全屏和关闭按钮,点击编辑后进入编辑模式,右下角变为退出编辑和保存按钮; ![image](https://github.com/PKUHPC/SCOW/assets/140392039/752919f3-9848-4c0b-a1e1-afab6a96bf2d) ![image](https://github.com/PKUHPC/SCOW/assets/140392039/689d9732-6a28-4e6b-9184-c67cb1b12cbc) ## 4. 点击编辑时判断该文件是否支持在线编辑,判断标准:文件大小,文件大小超标的(管理员配置,默认10M)不允许编辑,点击后弹出提示“文件过大,请下载后编辑”。 ![image](https://github.com/PKUHPC/SCOW/assets/140392039/69ab36f0-6e1c-4ea7-8b49-eb3843bbe1ce) ## 5. 编辑页面,关闭按钮关闭页面,取消按钮退出编辑模式返回查看页面,点击关闭或取消时如果有编辑过内容,则弹出提示“文件未保存,是否保存该文件”; ![image](https://github.com/PKUHPC/SCOW/assets/140392039/31cf6866-f088-4d5a-8cde-cc079f4d7fcb) ## 6. 可编辑文件大小可配置 在 `portal.yaml` 中新增配置项: ```yaml # 文件管理 file: # 文件预览功能 preview: # 大小限制 # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 50m limitSize: "40m" # 文件编辑功能 edit: # 文件编辑大小限制 # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 1m limitSize: "2m" ``` 默认可预览文件大小为 50m 默认可编辑文件大小为 1m 目前只有 js、ts、json 等有代码提示,其余语言暂时只有语法高亮 ### 文件修改后显示已编辑状态 ![image](https://github.com/PKUHPC/SCOW/assets/140392039/962fc661-1d01-40fe-beed-7cc1492b9c21) --- .changeset/small-cars-approve.md | 7 + apps/cli/assets/config/portal.yaml | 13 + apps/portal-web/.eslintrc.json | 3 +- apps/portal-web/.gitignore | 1 + apps/portal-web/config.js | 4 + apps/portal-web/package.json | 19 +- apps/portal-web/scripts/copyMonacoToPublic.js | 41 ++ apps/portal-web/src/i18n/en.ts | 20 + apps/portal-web/src/i18n/zh_cn.ts | 20 + .../filemanager/FileEditModal.tsx | 411 ++++++++++++++++++ .../filemanager/FileManager.tsx | 71 ++- .../filemanager/ImagePreviewer.tsx | 83 ++++ apps/portal-web/src/utils/config.ts | 4 + apps/portal-web/src/utils/languageMap.ts | 44 ++ .../src/utils/nonEditableExtensions.ts | 62 +++ apps/portal-web/src/utils/staticFiles.ts | 28 ++ dev/vagrant/config/portal.yaml | 13 + docs/docs/deploy/config/portal/intro.md | 14 + libs/config/src/portal.ts | 10 + pnpm-lock.yaml | 36 ++ 20 files changed, 884 insertions(+), 20 deletions(-) create mode 100644 .changeset/small-cars-approve.md create mode 100644 apps/portal-web/scripts/copyMonacoToPublic.js create mode 100644 apps/portal-web/src/pageComponents/filemanager/FileEditModal.tsx create mode 100644 apps/portal-web/src/pageComponents/filemanager/ImagePreviewer.tsx create mode 100644 apps/portal-web/src/utils/languageMap.ts create mode 100644 apps/portal-web/src/utils/nonEditableExtensions.ts diff --git a/.changeset/small-cars-approve.md b/.changeset/small-cars-approve.md new file mode 100644 index 0000000000..6ffe667932 --- /dev/null +++ b/.changeset/small-cars-approve.md @@ -0,0 +1,7 @@ +--- +"@scow/portal-web": minor +"@scow/config": minor +"@scow/cli": minor +--- + +门户系统文件管理新增文件编辑功能 diff --git a/apps/cli/assets/config/portal.yaml b/apps/cli/assets/config/portal.yaml index dfc9635e95..5f2cd203ef 100644 --- a/apps/cli/assets/config/portal.yaml +++ b/apps/cli/assets/config/portal.yaml @@ -43,6 +43,19 @@ homeText: # 是否启用终端功能 shell: true +# # 文件管理 +# file: +# # 文件预览功能 +# preview: +# # 大小限制 +# # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 50m +# limitSize: "50m" +# # 文件编辑功能 +# edit: +# # 文件编辑大小限制 +# # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 1m +# limitSize: "1m" + # 提交作业的默认工作目录。使用{{ name }}代替作业名称。相对于用户的家目录 # submitJobDefaultPwd: scow/jobs/{{ name }} diff --git a/apps/portal-web/.eslintrc.json b/apps/portal-web/.eslintrc.json index 3024b32cae..f1128f5d1b 100644 --- a/apps/portal-web/.eslintrc.json +++ b/apps/portal-web/.eslintrc.json @@ -2,5 +2,6 @@ "extends": [ "@ddadaal/eslint-config/react", "../../.eslintrc.js" - ] + ], + "ignorePatterns": ["public/monaco-assets/**"] } diff --git a/apps/portal-web/.gitignore b/apps/portal-web/.gitignore index 7d413601ff..47d7a543de 100644 --- a/apps/portal-web/.gitignore +++ b/apps/portal-web/.gitignore @@ -25,5 +25,6 @@ yarn-error.log* .next/ public/novnc +public/monaco-assets api-routes-schemas.json diff --git a/apps/portal-web/config.js b/apps/portal-web/config.js index 86e0946eb6..841167c86d 100644 --- a/apps/portal-web/config.js +++ b/apps/portal-web/config.js @@ -194,6 +194,10 @@ const buildRuntimeConfig = async (phase, basePath) => { CLIENT_MAX_BODY_SIZE: config.CLIENT_MAX_BODY_SIZE, + FILE_EDIT_SIZE: portalConfig.file?.edit.limitSize, + + FILE_PREVIEW_SIZE: portalConfig.file?.preview.limitSize, + CROSS_CLUSTER_FILE_TRANSFER_ENABLED: Object.values(clusters).filter( (cluster) => cluster.crossClusterFileTransfer?.enabled).length > 1, diff --git a/apps/portal-web/package.json b/apps/portal-web/package.json index 07c3c65848..6fa5f972d9 100644 --- a/apps/portal-web/package.json +++ b/apps/portal-web/package.json @@ -6,11 +6,12 @@ "dev": "cross-env NEXT_PUBLIC_USE_MOCK=1 TS_NODE_PROJECT=tsconfig.server.json node --watch -r ts-node/register -r tsconfig-paths/register server/index.ts", "dev:server": "cross-env NEXT_PUBLIC_USE_MOCK=0 TS_NODE_PROJECT=tsconfig.server.json node --watch -r ts-node/register -r tsconfig-paths/register server/index.ts", "serve": "node build/server/index.js", - "build": "npm run build:next && npm run build:ts", + "build": "node scripts/copyMonacoToPublic.js && npm run build:next && npm run build:ts", "build:next": "next build", "build:ts": "rimraf build && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json", "test": "jest --passWithNoTests", - "client": "ntar client" + "client": "ntar client", + "prepareDev": "node scripts/copyMonacoToPublic.js" }, "files": [ ".next", @@ -26,6 +27,7 @@ "license": "Mulan PSL v2", "repository": "https://glithub.com/PKUHPC/SCOW", "dependencies": { + "@ant-design/cssinjs": "1.16.2", "@ant-design/icons": "5.2.5", "@codemirror/language": "6.9.0", "@codemirror/legacy-modes": "6.3.3", @@ -34,18 +36,18 @@ "@ddadaal/tsgrpc-client": "0.17.6", "@ddadaal/tsgrpc-common": "0.2.4", "@grpc/grpc-js": "1.9.5", + "@monaco-editor/react": "^4.6.0", "@scow/config": "workspace:*", "@scow/lib-auth": "workspace:*", "@scow/lib-config": "workspace:*", "@scow/lib-decimal": "workspace:*", + "@scow/lib-operation-log": "workspace:*", "@scow/lib-ssh": "workspace:*", "@scow/lib-web": "workspace:*", "@scow/protos": "workspace:*", - "@scow/utils": "workspace:*", - "@scow/lib-operation-log": "workspace:*", "@scow/rich-error-model": "workspace:*", + "@scow/utils": "workspace:*", "@sinclair/typebox": "0.31.1", - "@ant-design/cssinjs": "1.16.2", "@uiw/codemirror-theme-github": "4.21.9", "@uiw/react-codemirror": "4.21.9", "antd": "5.8.4", @@ -64,14 +66,14 @@ "react-async": "10.0.1", "react-dom": "18.2.0", "react-is": "18.2.0", + "react-typed-i18n": "2.3.0", "simstate": "3.0.1", "styled-components": "6.0.7", "tslib": "2.6.2", "typescript": "5.1.6", "ws": "8.13.0", "xterm": "5.2.1", - "xterm-addon-fit": "0.7.0", - "react-typed-i18n": "2.3.0" + "xterm-addon-fit": "0.7.0" }, "devDependencies": { "@ddadaal/next-typed-api-routes-cli": "0.9.1", @@ -94,6 +96,9 @@ "ts-log": "2.2.5", "webpack": "5.88.2" }, + "peerDependencies": { + "monaco-editor": "0.44.0" + }, "browserslist": { "production": [ ">0.2%", diff --git a/apps/portal-web/scripts/copyMonacoToPublic.js b/apps/portal-web/scripts/copyMonacoToPublic.js new file mode 100644 index 0000000000..208f65f10d --- /dev/null +++ b/apps/portal-web/scripts/copyMonacoToPublic.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +const fs = require("fs-extra"); +const path = require("path"); + +const sourcePath = path.join(__dirname, "../node_modules/monaco-editor/min/vs"); +const targetPath = path.join(__dirname, "../public/monaco-assets/vs"); + +// Check if the source directory exists +if (!fs.existsSync(sourcePath)) { + console.error( + `Error: Source directory ${sourcePath} does not exist. Ensure the target package is correctly installed.`); + process.exit(1); +} + +// Ensure the target path exists, if not, create it +fs.ensureDirSync(targetPath); + +// Attempt to copy +try { + fs.copySync(sourcePath, targetPath, { + overwrite: true, + errorOnExist: false, + }); + console.log(`Success: Copied from ${sourcePath} to ${targetPath}.`); +} catch (error) { + console.error(`Error: An issue occurred during the copy process. Details: ${error.message}`); + process.exit(1); +} + +console.log(`Copied files from ${sourcePath} to ${targetPath}`); diff --git a/apps/portal-web/src/i18n/en.ts b/apps/portal-web/src/i18n/en.ts index b17be8d286..e6f57e2b1d 100644 --- a/apps/portal-web/src/i18n/en.ts +++ b/apps/portal-web/src/i18n/en.ts @@ -186,6 +186,23 @@ export default { }, }, fileManagerComp: { + fileEditModal: { + edit: "Edit", + prompt: "Prompt", + save: "Save", + doNotSave: "Do Not Save", + notSaved: "Not Saved", + notSavePrompt: "The file has not been saved, do you want to save this file?", + fileEdit: "File Edit", + filePreview: "File Preview", + fileLoading: "File is loading...", + exitEdit: "Exit Edit Mode", + failedGetFile: "Failed to get file: {}", + cantReadFile: "Cannot read file: {}", + saveFileFail: "File save failed: {}", + saveFileSuccess: "File saved successfully", + fileSizeExceeded: "File too large (maximum {}), please download and edit", + }, createFileModal: { createErrorMessage: "File or directory with the same name already exists!", createSuccessMessage: "Created successfully", @@ -194,6 +211,9 @@ export default { fileName: "File Name", }, fileManager: { + preview: { + cantPreview: "File too large (maximum {}) or format not supported, please download to view", + }, moveCopy: { copy: "Copy", move: "Move", diff --git a/apps/portal-web/src/i18n/zh_cn.ts b/apps/portal-web/src/i18n/zh_cn.ts index 732da8733d..edd9c5c151 100644 --- a/apps/portal-web/src/i18n/zh_cn.ts +++ b/apps/portal-web/src/i18n/zh_cn.ts @@ -186,6 +186,23 @@ export default { }, }, fileManagerComp: { + fileEditModal: { + edit: "编辑", + prompt: "提示", + save: "保存", + doNotSave: "不保存", + notSaved: "未保存", + notSavePrompt: "文件未保存,是否保存该文件?", + fileEdit: "文件编辑", + filePreview: "文件预览", + fileLoading: "文件正在加载...", + exitEdit: "退出编辑", + failedGetFile: "获取文件: {} 失败", + cantReadFile: "无法读取文件: {}", + saveFileFail: "文件保存失败: {}", + saveFileSuccess: "文件保存成功", + fileSizeExceeded: "文件过大(最大{}),请下载后编辑", + }, createFileModal: { createErrorMessage: "同名文件或者目录已经存在!", createSuccessMessage: "创建成功", @@ -194,6 +211,9 @@ export default { fileName: "文件名", }, fileManager: { + preview: { + cantPreview: "文件过大(最大{})或者格式不支持,请下载后查看", + }, moveCopy: { copy: "复制", move: "移动", diff --git a/apps/portal-web/src/pageComponents/filemanager/FileEditModal.tsx b/apps/portal-web/src/pageComponents/filemanager/FileEditModal.tsx new file mode 100644 index 0000000000..999d6af21e --- /dev/null +++ b/apps/portal-web/src/pageComponents/filemanager/FileEditModal.tsx @@ -0,0 +1,411 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { CloseOutlined, FullscreenExitOutlined, FullscreenOutlined } from "@ant-design/icons"; +import Editor, { loader } from "@monaco-editor/react"; +import { App, Badge, Button, Modal, Space, Spin, Tabs, Tooltip } from "antd"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { api } from "src/apis"; +import { prefix, useI18nTranslateToString } from "src/i18n"; +import { publicConfig } from "src/utils/config"; +import { convertToBytes } from "src/utils/format"; +import { getLanguage } from "src/utils/staticFiles"; +import { styled } from "styled-components"; + +import { urlToUpload } from "./api"; + +const StyledTabs = styled(Tabs)` + .ant-tabs-nav { + margin: 0 !important; + } +`; + +enum Mode { + EDIT = "EDIT", + PREVIEW = "PREVIEW", +}; + +enum ExitType { + EXIT_EDIT, + CLOSE, +} + +const FullScreenModalStyle = styled.div` + .ant-modal { + transition: width 0.3s ease, height 0.3s ease; + } + + &.fullscreen { + .ant-modal { + width: 98vw !important; + height: 100vh !important; + top: 0 !important; + padding: 0 !important; + margin: auto !important; + max-width: 100vw !important; + } + + .ant-modal-content { + height: 100%; + } + + .ant-modal-body { + height: calc(100% - 100px); + overflow-y: auto; + } + } +`; + +interface PreviewFileProps { + open: boolean; + filename: string; + fileSize: number; + filePath: string; + clusterId: string; +} +interface Props { + previewFile: PreviewFileProps; + setPreviewFile: Dispatch>; +} + +interface ConfirmModalProps { + open: boolean; + saving: boolean; + onSave: () => Promise; + onClose: () => void; +} + +interface FilenameProps { + isEdit: boolean; + filename: string; +} + +const DEFAULT_FILE_EDIT_LIMIT_SIZE = "1m"; + +const p = prefix("pageComp.fileManagerComp.fileEditModal."); + +loader.config({ + paths: { + vs: publicConfig.BASE_PATH + "monaco-assets/vs", + }, +}); + +function ConfirmModal({ open, saving, onSave, onClose }: ConfirmModalProps) { + + const t = useI18nTranslateToString(); + + const handleSave = async () => { + await onSave(); + onClose(); + }; + + return ( + + {t(p("notSavePrompt"))} + + ); +} + +const FilenameComponent: React.FC = ({ isEdit, filename }) => { + + const t = useI18nTranslateToString(); + + return ( + +
+ +
{filename}
+ { isEdit && } +
+
+
+ ); +}; + +export const FileEditModal: React.FC = ({ previewFile, setPreviewFile }) => { + + const t = useI18nTranslateToString(); + + const { open, filename, fileSize, filePath, clusterId } = previewFile; + + const [mode, setMode] = useState(Mode.PREVIEW); + const [fileContent, setFileContent] = useState(""); + const [isEdit, setIsEdit] = useState(false); + const [loading, setLoading] = useState(false); + const [downloading, setDownloading] = useState(false); + const [saving, setSaving] = useState(false); + const [confirm, setConfirm] = useState(false); + const [exitType, setExitType] = useState(ExitType.CLOSE); + const [isFullScreen, setIsFullScreen] = useState(false); + + + const [options, setOptions] = useState({ + readOnly: true, + lineNumbersMinChars: 7, + }); + + useEffect(() => { + if (open) { + downloadFile(); + } + }, [open]); + + const { message } = App.useApp(); + + const handleEdit = (content) => { + if (content && !downloading) { + setIsEdit(true); + setFileContent(content); + } + }; + + const closeProcess = () => { + setConfirm(false); + setIsEdit(false); + setMode(Mode.PREVIEW); + setFileContent(""); + setOptions({ + ...options, + readOnly: true, + }); + setPreviewFile({ + ...previewFile, + open: false, + }); + setIsFullScreen(false); + }; + + const handleClose = () => { + if (!isEdit) { + closeProcess(); + return; + } + + setExitType(ExitType.CLOSE); + setConfirm(true); + }; + + const exitEditModeProcess = () => { + downloadFile(); + setConfirm(false); + setIsEdit(false); + setMode(Mode.PREVIEW); + setOptions({ + ...options, + readOnly: true, + }); + }; + + const handleExitEditMode = () => { + if (!isEdit) { + exitEditModeProcess(); + return; + } + + setExitType(ExitType.EXIT_EDIT); + setConfirm(true); + }; + + const handleSave = async () => { + + setSaving(true); + const blob = new Blob([fileContent], { type: "text/plain" }); + + const formData = new FormData(); + formData.append("file", blob); + + await fetch(urlToUpload(clusterId, filename), { + method: "POST", + body: formData, + }).then((response) => { + if (!response.ok) { + return Promise.reject(response.statusText); + } + message.success(t(p("saveFileSuccess"))); + setIsEdit(false); + }).catch((error) => { + message.error(t(p("saveFileFail"), [error])); + }).finally(() => { + setSaving(false); + }); + + }; + + const downloadFile = () => { + if (!open) { + return; + } + + setLoading(true); + setDownloading(true); + api.downloadFile({ + query: { download: false, path: filePath, cluster: clusterId }, + }).then((json) => { + setFileContent(JSON.stringify(json, null, 2)); + }).catch(async (res: Response) => { + + if (!res.ok) { + message.error(t(p("failedGetFile"), [filename])); + return; + } + + const reader = res.body?.getReader(); + if (reader) { + let accumulatedChunks = ""; + let accumulatedSize = 0; + const CHUNK_SIZE = 3 * 1024 * 1024; + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + setFileContent(() => { + return accumulatedChunks; + }); + break; + } + const chunk = decoder.decode(value, { stream: true }); + accumulatedChunks += chunk; + accumulatedSize += chunk.length; + + if (accumulatedSize > CHUNK_SIZE) { + setFileContent(() => { + return accumulatedChunks; + }); + accumulatedSize = 0; + setLoading(false); + } + } + } else { + message.error(t(p("cantReadFile"), [filename])); + } + + }).finally(() => { + setLoading(false); + setDownloading(false); + }); + + }; + + const modalTitle = ( +
+ {mode === "PREVIEW" ? t(p("filePreview")) : t(p("fileEdit"))} +
+
+ +
+ ); + + const modalFooterRender = () => { + const fileEditLimitSize = publicConfig.FILE_EDIT_SIZE || DEFAULT_FILE_EDIT_LIMIT_SIZE; + return ( + mode === Mode.PREVIEW ? ( + fileSize <= convertToBytes(fileEditLimitSize) + ? ( + + ) + : ( + + + + ) + ) : ( + + + + + + ) + ); + }; + + return ( + + + , + key: `${filename}`, + children: ( + + + + ), + }]} + /> + + exitType === ExitType.CLOSE ? closeProcess() : exitEditModeProcess()} + /> + + ); +}; diff --git a/apps/portal-web/src/pageComponents/filemanager/FileManager.tsx b/apps/portal-web/src/pageComponents/filemanager/FileManager.tsx index 86cf0b9ed8..06712bff97 100644 --- a/apps/portal-web/src/pageComponents/filemanager/FileManager.tsx +++ b/apps/portal-web/src/pageComponents/filemanager/FileManager.tsx @@ -34,7 +34,9 @@ import { TableTitle } from "src/components/TableTitle"; import { prefix, useI18n, useI18nTranslateToString } from "src/i18n"; import { urlToDownload } from "src/pageComponents/filemanager/api"; import { CreateFileModal } from "src/pageComponents/filemanager/CreateFileModal"; +import { FileEditModal } from "src/pageComponents/filemanager/FileEditModal"; import { FileTable } from "src/pageComponents/filemanager/FileTable"; +import { ImagePreviewer } from "src/pageComponents/filemanager/ImagePreviewer"; import { MkdirModal } from "src/pageComponents/filemanager/MkdirModal"; import { PathBar } from "src/pageComponents/filemanager/PathBar"; import { RenameModal } from "src/pageComponents/filemanager/RenameModal"; @@ -42,6 +44,8 @@ import { UploadModal } from "src/pageComponents/filemanager/UploadModal"; import { FileInfo } from "src/pages/api/file/list"; import { LoginNodeStore } from "src/stores/LoginNodeStore"; import { Cluster, publicConfig } from "src/utils/config"; +import { convertToBytes } from "src/utils/format"; +import { canPreviewWithEditor, isImage } from "src/utils/staticFiles"; import { styled } from "styled-components"; interface Props { @@ -71,6 +75,8 @@ const OperationBar = styled(TableTitle)` gap: 4px; `; +const DEFAULT_FILE_PREVIEW_LIMIT_SIZE = "50m"; + type FileInfoKey = React.Key; const fileInfoKey = (f: FileInfo, path: string): FileInfoKey => join(path, f.name); @@ -87,7 +93,6 @@ const p = prefix("pageComp.fileManagerComp.fileManager."); export const FileManager: React.FC = ({ cluster, path, urlPrefix }) => { - const t = useI18nTranslateToString(); const operationTexts = { @@ -105,6 +110,19 @@ export const FileManager: React.FC = ({ cluster, path, urlPrefix }) => { const [loading, setLoading] = useState(false); const [selectedKeys, setSelectedKeys] = useState([]); + const [previewFile, setPreviewFile] = useState({ + open: false, + filename: "", + fileSize: 0, + filePath: "", + clusterId: "", + }); + const [previewImage, setPreviewImage] = useState({ + visible: false, + src: "", + scaleStep: 0.5, + }); + const [operation, setOperation] = useState(undefined); const [showHiddenFile, setShowHiddenFile] = useState(false); @@ -273,7 +291,6 @@ export const FileManager: React.FC = ({ cluster, path, urlPrefix }) => { message.success(t(p("delete.successMessage"), [allCount])); resetSelectedAndOperation(); } else { - // message.error(`删除成功${allCount - failedCount}项,失败${failedCount}项`); message.error(t(p("delete.errorMessage"), [(allCount - failedCount), failedCount])), setOperation((o) => o && ({ ...o, started: false })); } @@ -299,6 +316,36 @@ export const FileManager: React.FC = ({ cluster, path, urlPrefix }) => { setShowHiddenFile(!showHiddenFile); }; + const handlePreview = (filename: string, fileSize: number) => { + + const filePreviewLimitSize = publicConfig.FILE_PREVIEW_SIZE || DEFAULT_FILE_PREVIEW_LIMIT_SIZE; + if (fileSize > convertToBytes(filePreviewLimitSize)) { + message.info(t(p("preview.cantPreview"), [filePreviewLimitSize])); + return; + } + + if (isImage(filename)) { + setPreviewImage({ + ...previewImage, + visible: true, + src: urlToDownload(cluster.id, join(path, filename), false), + }); + return; + } else if (canPreviewWithEditor(filename)) { + setPreviewFile({ + open: true, + filename, + fileSize: fileSize, + filePath: join(path, filename), + clusterId: cluster.id, + }); + return; + } else { + message.info(t(p("preview.cantPreview"), [filePreviewLimitSize])); + return; + } + }; + return (
@@ -450,8 +497,7 @@ export const FileManager: React.FC = ({ cluster, path, urlPrefix }) => { if (r.type === "DIR") { Router.push(fullUrl(join(path, r.name))); } else if (r.type === "FILE") { - const href = urlToDownload(cluster.id, join(path, r.name), false); - openPreviewLink(href); + handlePreview(r.name, r.size); } }, })} @@ -462,8 +508,7 @@ export const FileManager: React.FC = ({ cluster, path, urlPrefix }) => { ) : ( { - const href = urlToDownload(cluster.id, join(path, r.name), false); - openPreviewLink(href); + handlePreview(r.name, r.size); }} > {r.name} @@ -473,11 +518,11 @@ export const FileManager: React.FC = ({ cluster, path, urlPrefix }) => { actionRender={(_, i: FileInfo) => ( { - i.type === "FILE" ? ( + i.type === "FILE" && ( {t(p("tableInfo.download"))} - ) : undefined + ) } = ({ cluster, path, urlPrefix }) => { )} /> + +
); }; + const RenameLink = ModalLink(RenameModal); const CreateFileButton = ModalButton(CreateFileModal, { icon: }); const MkdirButton = ModalButton(MkdirModal, { icon: }); const UploadButton = ModalButton(UploadModal, { icon: }); - -function openPreviewLink(href: string) { - window.open(href, "ViewFile", "location=yes,resizable=yes,scrollbars=yes,status=yes"); -} +// function openPreviewLink(href: string) { +// window.open(href, "ViewFile", "location=yes,resizable=yes,scrollbars=yes,status=yes"); +// } diff --git a/apps/portal-web/src/pageComponents/filemanager/ImagePreviewer.tsx b/apps/portal-web/src/pageComponents/filemanager/ImagePreviewer.tsx new file mode 100644 index 0000000000..2015b06f5d --- /dev/null +++ b/apps/portal-web/src/pageComponents/filemanager/ImagePreviewer.tsx @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Image, Spin } from "antd"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { styled } from "styled-components"; + +const FullScreenCentered = styled.div` + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(255, 255, 255, 0.3); + z-index: 1000; +`; + +interface PreviewImageProps { + visible: boolean; + src: string; + scaleStep: number; +} + +interface Props { + previewImage: PreviewImageProps; + setPreviewImage: Dispatch> +} + +export const ImagePreviewer: React.FC = ({ previewImage, setPreviewImage }) => { + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (previewImage.visible) { + setLoading(true); + } + }, [previewImage.visible]); + + const handleImageLoad = () => { + setLoading(false); + }; + + return ( + <> + {loading && previewImage.visible && ( + + + + )} + { + setPreviewImage({ + ...previewImage, + visible: false, + }); + + if (!vis) { + setLoading(false); + } + }, + }} + onLoad={handleImageLoad} + onError={() => setLoading(false) } + /> + + ); +}; diff --git a/apps/portal-web/src/utils/config.ts b/apps/portal-web/src/utils/config.ts index 098fa32857..f480cc3a4d 100644 --- a/apps/portal-web/src/utils/config.ts +++ b/apps/portal-web/src/utils/config.ts @@ -81,6 +81,10 @@ export interface PublicRuntimeConfig { // 上传(请求)文件的大小限制 CLIENT_MAX_BODY_SIZE: string; + FILE_EDIT_SIZE: string | undefined; + + FILE_PREVIEW_SIZE: string | undefined; + PUBLIC_PATH: string; NAV_LINKS?: NavLink[]; diff --git a/apps/portal-web/src/utils/languageMap.ts b/apps/portal-web/src/utils/languageMap.ts new file mode 100644 index 0000000000..92eda8183e --- /dev/null +++ b/apps/portal-web/src/utils/languageMap.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export const languageMap = { + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + html: "html", + css: "css", + json: "json", + md: "markdown", + py: "python", + java: "java", + c: "c", + cpp: "cpp", + cxx: "cpp", + cc: "cpp", + h: "cpp", + hpp: "cpp", + hxx: "cpp", + rs: "rust", + go: "go", + sh: "shell", + bash: "shell", + php: "php", + xml: "xml", + yml: "yaml", + yaml: "yaml", + sql: "sql", + pl: "perl", + rb: "ruby", + swift: "swift", + m: "objective-c", +}; diff --git a/apps/portal-web/src/utils/nonEditableExtensions.ts b/apps/portal-web/src/utils/nonEditableExtensions.ts new file mode 100644 index 0000000000..c979f46970 --- /dev/null +++ b/apps/portal-web/src/utils/nonEditableExtensions.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export const nonEditableExtensions = new Set([ + ".7z", + ".aiff", + ".apk", + ".app", + ".avi", + ".bat", + ".bin", + ".bmp", + ".bz2", + ".cmd", + ".com", + ".dat", + ".dll", + ".dmg", + ".doc", + ".docx", + ".exe", + ".flac", + ".flv", + ".gif", + ".gz", + ".img", + ".iso", + ".jpeg", + ".jpg", + ".mkv", + ".mov", + ".mp3", + ".mp4", + ".msi", + ".odt", + ".ott", + ".pdf", + ".png", + ".ppt", + ".pptx", + ".psd", + ".rar", + ".tar", + ".tgz", + ".tiff", + ".vcd", + ".wav", + ".wmv", + ".xcf", + ".xls", + ".xlsx", + ".zip", +]); diff --git a/apps/portal-web/src/utils/staticFiles.ts b/apps/portal-web/src/utils/staticFiles.ts index e5fba2286b..ae46d1bb9e 100644 --- a/apps/portal-web/src/utils/staticFiles.ts +++ b/apps/portal-web/src/utils/staticFiles.ts @@ -10,7 +10,35 @@ * See the Mulan PSL v2 for more details. */ +import { languageMap } from "src/utils/languageMap"; +import { nonEditableExtensions } from "src/utils/nonEditableExtensions"; + + export function basename(path: string) { const parts = path.split(/[\/\\]/); return parts[parts.length - 1]; } + +export function getExtension(filename: string) { + const parts = filename.split("."); + const extension = parts.pop(); + return extension ? extension.toLowerCase() : ""; +} + +export function isImage(filename: string): boolean { + const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "svg", "webp"]; + const extension = getExtension(filename); + return imageExtensions.includes(extension); +} + +export function getLanguage(filename) { + const ext = filename.split(".").pop().toLowerCase(); + return languageMap[ext] || "plaintext"; +} + +export function canPreviewWithEditor(filename: string): boolean { + const extension = `.${filename.split(".").pop()}`; + return !nonEditableExtensions.has(extension.toLowerCase()); +} + + diff --git a/dev/vagrant/config/portal.yaml b/dev/vagrant/config/portal.yaml index e70d0b2161..3c3947296a 100644 --- a/dev/vagrant/config/portal.yaml +++ b/dev/vagrant/config/portal.yaml @@ -60,6 +60,19 @@ homeText: # 是否启用终端功能 shell: true +# # 文件管理 +# file: +# # 文件预览功能 +# preview: +# # 大小限制 +# # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 50m +# limitSize: "50m" +# # 文件编辑功能 +# edit: +# # 文件编辑大小限制 +# # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 1m +# limitSize: "1m" + # 提交作业的默认工作目录。使用{{ name }}代替作业名称。相对于用户的家目录 # submitJobDefaultPwd: scow/jobs/{{ name }} diff --git a/docs/docs/deploy/config/portal/intro.md b/docs/docs/deploy/config/portal/intro.md index d96a312d88..eabc0605c7 100644 --- a/docs/docs/deploy/config/portal/intro.md +++ b/docs/docs/deploy/config/portal/intro.md @@ -76,6 +76,20 @@ submitJobPromptText: "#此处参数设置的优先级高于页面其它地方, # 是否启用终端功能 shell: true +# # 文件管理 +# file: +# # 文件预览功能 +# preview: +# # 大小限制 +# # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 50m +# limitSize: "50m" +# # 文件编辑功能 +# edit: +# # 文件编辑大小限制 +# # 可接受的格式为nginx的client_max_body_size可接受的值,默认为 1m +# # 建议设置为较大值 +# limitSize: "1m" + # 提交作业的默认工作目录。使用{{ name }}代替作业名称。相对于用户的家目录 # submitJobDefaultPwd: scow/jobs/{{ name }} diff --git a/libs/config/src/portal.ts b/libs/config/src/portal.ts index 55fde7ee36..a20fb07af0 100644 --- a/libs/config/src/portal.ts +++ b/libs/config/src/portal.ts @@ -56,6 +56,16 @@ export const PortalConfigSchema = Type.Object({ shell: Type.Boolean({ description: "是否启用终端功能", default: true }), + file: Type.Optional(Type.Object({ + preview: Type.Object({ + limitSize: Type.String({ description: "文件预览大小限制", default: "50m" }), + }, { description: "文件预览功能", default: {} }), + edit: Type.Object({ + limitSize: Type.String({ description: "文件编辑大小限制", default: "1m" }), + }, { description: "文件编辑功能", default: {} }), + }, { description: "文件管理" })), + + submitJobDefaultPwd: Type.String({ description: "提交作业的默认工作目录。使用{{ name }}代替作业名称。相对于用户的家目录", default: "scow/jobs/{{ name }}" }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d3b38de4a..6eb485fb69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -703,6 +703,9 @@ importers: '@grpc/grpc-js': specifier: 1.9.5 version: 1.9.5 + '@monaco-editor/react': + specifier: ^4.6.0 + version: 4.6.0(monaco-editor@0.44.0)(react-dom@18.2.0)(react@18.2.0) '@scow/config': specifier: workspace:* version: link:../../libs/config @@ -763,6 +766,9 @@ importers: mime-types: specifier: 2.1.35 version: 2.1.35 + monaco-editor: + specifier: 0.44.0 + version: 0.44.0 next: specifier: 13.4.10 version: 13.4.10(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0) @@ -6023,6 +6029,28 @@ packages: globby: 11.1.0 dev: false + /@monaco-editor/loader@1.4.0(monaco-editor@0.44.0): + resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==} + peerDependencies: + monaco-editor: '>= 0.21.0 < 1' + dependencies: + monaco-editor: 0.44.0 + state-local: 1.0.7 + dev: false + + /@monaco-editor/react@4.6.0(monaco-editor@0.44.0)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@monaco-editor/loader': 1.4.0(monaco-editor@0.44.0) + monaco-editor: 0.44.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@next/bundle-analyzer@13.4.13: resolution: {integrity: sha512-XygyFn3V61vF9LkU1zM6GlAMp8h7FbApaLA40anMGhZtQt/0S1tGSNImv9T/Z3ZTbWIQTcbYxyHIM6Fv/uSGrA==} dependencies: @@ -14661,6 +14689,10 @@ packages: resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} dev: false + /monaco-editor@0.44.0: + resolution: {integrity: sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==} + dev: false + /moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} dev: false @@ -18414,6 +18446,10 @@ packages: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} dev: false + /state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + dev: false + /state-toggle@1.0.3: resolution: {integrity: sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==} dev: false