commit 20c1fa08dc78024a19242ca67431aae61cb4eebd Author: Zichao Lin Date: Sun Feb 25 08:27:01 2024 +0800 init diff --git a/.eslintrc.yaml b/.eslintrc.yaml new file mode 100644 index 0000000..640b4d3 --- /dev/null +++ b/.eslintrc.yaml @@ -0,0 +1,43 @@ +extends: + - eslint:recommended + - plugin:react/recommended + - plugin:@typescript-eslint/recommended +parser: '@typescript-eslint/parser' +plugins: + - react + - react-hooks + - '@typescript-eslint' +parserOptions: + sourceType: module + ecmaVersion: 2020 + ecmaFeatures: + jsx: true +env: + es6: true + browser: true + node: true + jest: true + +settings: + react: + version: detect +ignorePatterns: + - node_modules +rules: + react/prop-types: 0 + react-hooks/rules-of-hooks: "error" + # TODO: 修改添加deps后出现的死循环 + react-hooks/exhaustive-deps: 0 + '@typescript-eslint/explicit-function-return-type': 0 + '@typescript-eslint/no-explicit-any': 0 + '@typescript-eslint/camelcase': 0 + '@typescript-eslint/no-non-null-assertion': 0 + '@typescript-eslint/no-unused-vars': 0 + +overrides: + - files: ['*.js', '*.jsx'] + rules: + '@typescript-eslint/camelcase': 0 + - files: ['config/*.js', 'scripts/*.js'] + rules: + '@typescript-eslint/no-var-requires': 0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..911e1ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +dist/ \ No newline at end of file diff --git a/.huskyrc b/.huskyrc new file mode 100644 index 0000000..ecd1535 --- /dev/null +++ b/.huskyrc @@ -0,0 +1 @@ +export PATH="/usr/local/bin:$PATH" diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5fcd8a7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1519c10 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: node_js +node_js: + - 12.16.3 +before_script: + - yarn install +script: + - CI=false yarn run build + - yarn run test \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..89b278a --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `yarn start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.
+See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `yarn build` + +Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `yarn eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). + +### Code Splitting + +This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting + +### Analyzing the Bundle Size + +This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size + +### Making a Progressive Web App + +This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app + +### Advanced Configuration + +This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration + +### Deployment + +This section has moved here: https://facebook.github.io/create-react-app/docs/deployment + +### `yarn build` fails to minify + +This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify diff --git a/config/env.js b/config/env.js new file mode 100644 index 0000000..211711b --- /dev/null +++ b/config/env.js @@ -0,0 +1,93 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve('./paths')]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error( + 'The NODE_ENV environment variable is required but was not specified.' + ); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +const dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== 'test' && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require('dotenv-expand')( + require('dotenv').config({ + path: dotenvFile, + }) + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || '') + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in Webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || 'development', + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + } + ); + // Stringify all values so we can feed into Webpack DefinePlugin + const stringified = { + 'process.env': Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/config/jest/cssTransform.js b/config/jest/cssTransform.js new file mode 100644 index 0000000..8f65114 --- /dev/null +++ b/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return 'module.exports = {};'; + }, + getCacheKey() { + // The output is always the same. + return 'cssTransform'; + }, +}; diff --git a/config/jest/fileTransform.js b/config/jest/fileTransform.js new file mode 100644 index 0000000..aab6761 --- /dev/null +++ b/config/jest/fileTransform.js @@ -0,0 +1,40 @@ +'use strict'; + +const path = require('path'); +const camelcase = require('camelcase'); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.svg$/)) { + // Based on how SVGR generates a component name: + // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 + const pascalCaseFilename = camelcase(path.parse(filename).name, { + pascalCase: true, + }); + const componentName = `Svg${pascalCaseFilename}`; + return `const React = require('react'); + module.exports = { + __esModule: true, + default: ${assetFilename}, + ReactComponent: React.forwardRef(function ${componentName}(props, ref) { + return { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }; + }), + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/config/modules.js b/config/modules.js new file mode 100644 index 0000000..c84210a --- /dev/null +++ b/config/modules.js @@ -0,0 +1,141 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); +const chalk = require('react-dev-utils/chalk'); +const resolve = require('resolve'); + +/** + * Get additional module paths based on the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + // If there's no baseUrl set we respect NODE_PATH + // Note that NODE_PATH is deprecated and will be removed + // in the next major release of create-react-app. + + const nodePath = process.env.NODE_PATH || ''; + return nodePath.split(path.delimiter).filter(Boolean); + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === '') { + return [paths.appSrc]; + } + + // If the path is equal to the root directory we ignore it here. + // We don't want to allow importing from the root directly as source files are + // not transpiled outside of `src`. We do allow importing them with the + // absolute path (e.g. `src/Components/Button.js`) but we set that up with + // an alias. + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return null; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + ' Create React App does not support other values at this time.' + ) + ); +} + +/** + * Get webpack aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getWebpackAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + src: paths.appSrc, + }; + } +} + +/** + * Get jest aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getJestAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + 'src/(.*)$': '/src/$1', + }; + } +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error( + 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + const ts = require(resolve.sync('typescript', { + basedir: paths.appNodeModules, + })); + config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + webpackAliases: getWebpackAliases(options), + jestAliases: getJestAliases(options), + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/config/paths.js b/config/paths.js new file mode 100644 index 0000000..61b9240 --- /dev/null +++ b/config/paths.js @@ -0,0 +1,89 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const url = require("url"); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); + +const envPublicUrl = process.env.PUBLIC_URL; + +function ensureSlash(inputPath, needsSlash) { + const hasSlash = inputPath.endsWith("/"); + if (hasSlash && !needsSlash) { + return inputPath.substr(0, inputPath.length - 1); + } else if (!hasSlash && needsSlash) { + return `${inputPath}/`; + } else { + return inputPath; + } +} + +const getPublicUrl = (appPackageJson) => + envPublicUrl || require(appPackageJson).homepage; + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// Webpack needs to know it to put the right + + + +
+ {siteScript} + + diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json new file mode 100644 index 0000000..3efbd70 --- /dev/null +++ b/public/locales/en-US/application.json @@ -0,0 +1,591 @@ +{ + "login": { + "email": "Email", + "password": "Password", + "captcha": "CAPTCHA", + "captchaError": "Cannot load CAPTCHA: {{message}}", + "signIn": "Sign in", + "signUp": "Sign up", + "signUpAccount": "Sign up", + "useFIDO2": "Use Hardware Authenticator", + "usePassword": "Use Password", + "forgetPassword": "Forgot password?", + "2FA": "2FA Verification", + "input2FACode": "Please enter the six-digit 2FA verification code", + "passwordNotMatch": "Those passwords didn’t match.", + "findMyPassword": "Find my password", + "passwordReset": "Password has been reset.", + "newPassword": "New password", + "repeatNewPassword": "Repeat the new password", + "repeatPassword": "Repeat the password", + "resetPassword": "Reset my password", + "backToSingIn": "Back to sign in", + "sendMeAnEmail": "Send me an email", + "resetEmailSent": "An email has been sent, please pay attention to check.", + "browserNotSupport": "Not supported by current browser or environment.", + "success": "Sign in successful", + "signUpSuccess": "Sign up successful", + "activateSuccess": "Sign up completed", + "accountActivated": "Your account has been successfully activated.", + "title": "Sign in to {{title}}", + "sinUpTitle": "Sign up to {{title}}", + "activateTitle": "Activate your account", + "activateDescription": "An activation email has been sent to your email address, please visit the link in the email to complete your sign-up.", + "continue": "Next", + "logout": "Sign out", + "loggedOut": "You are signed out now.", + "clickToRefresh": "Click to refresh" + }, + "navbar": { + "myFiles": "My Files", + "myShare": "Shared", + "remoteDownload": "Remote Download", + "connect": "Connect & Mount", + "taskQueue": "Task Queue", + "setting": "Settings", + "videos": "Videos", + "photos": "Photos", + "music": "Music", + "documents": "Documents", + "addATag": "Add a tag...", + "addTagDialog": { + "selectFolder": "Select a Folder", + "fileSelector": "File Selector", + "folderLink": "Folder Shortcut", + "tagName": "Tag name", + "matchPattern": "Match pattern(s) of file name", + "matchPatternDescription": "You can use <0>* as a wildcard. For example, <1>*.png means match png format images. Multi-line rules will operate in an \"or\" relationship with each other.", + "icon": "Icon:", + "color": "Color:", + "folderPath": "Path to the folder" + }, + "storage": "Storage", + "storageDetail": "{{used}} of {{total}} used", + "notLoginIn": "Not sign in", + "visitor": "Anonymous", + "objectsSelected": "{{num}} objects selected", + "searchPlaceholder": "Search...", + "searchInFiles": "Search <0>{{name}} in my files", + "searchInFolders": "Search <0>{{name}} under current folder", + "searchInShares": "Search <0>{{name}} in other users’ shares", + "backToHomepage": "Back to homepage", + "toDarkMode": "Switch to dark theme", + "toLightMode": "Switch to light theme", + "myProfile": "My profile", + "dashboard": "Dashboard", + "exceedQuota": "Your used capacity has exceeded the quota, please delete the extra files." + }, + "fileManager": { + "open": "Open", + "openParentFolder": "Open parent folder", + "download": "Download", + "batchDownload": "Download in batch", + "share": "Share", + "rename": "Rename", + "move": "Move", + "delete": "Remove", + "moreActions": "More actions...", + "refresh": "Refresh", + "compress": "Compress", + "newFolder": "New folder", + "newFile": "New file", + "showFullPath": "Show full path", + "listView": "List view", + "gridViewSmall": "Grid view (no preview)", + "gridViewLarge": "Grid view", + "paginationSize": "Pagination", + "paginationOption": "{{option}} / page", + "noPagination": "No pagination", + "sortMethod": "Sort by", + "sortMethods": { + "A-Z": "A-Z", + "Z-A": "Z-A", + "oldestUploaded": "Oldest uploaded", + "newestUploaded": "Newest uploaded", + "oldestModified": "Oldest modified", + "newestModified": "Newest modified", + "smallest": "Smallest", + "largest": "Largest" + }, + "shareCreateBy": "Created by {{nick}}", + "name": "Name", + "size": "Size", + "lastModified": "Last modified", + "currentFolder": "Current Folder", + "backToParentFolder": "Back to the parent", + "folders": "Folders", + "files": "Files", + "listError": ":( Failed to lis files", + "dropFileHere": "Drag and drop the file here", + "orClickUploadButton": "Or click the \"Upload File\" button at the bottom right to add a file", + "nothingFound": "Nothing was found", + "uploadFiles": "Upload files", + "uploadFolder": "Upload folder", + "newRemoteDownloads": "New remote download", + "enter": "Enter", + "getSourceLink": "Get source link", + "getSourceLinkInBatch": "Get source links", + "createRemoteDownloadForTorrent": "New remote download", + "decompress": "Decompress", + "createShareLink": "Share", + "viewDetails": "View details", + "copy": "Copy", + "bytes": " ({{bytes}} Bytes)", + "storagePolicy": "Storage policy", + "inheritedFromParent": "Inherited from parent", + "childFolders": "Child folders", + "childFiles": "Child files", + "childCount": "{{num}}", + "parentFolder": "Parent folder", + "rootFolder": "Root folder", + "modifiedAt": "Modified at", + "createdAt": "Created at", + "statisticAt": "Statistic at <1>", + "musicPlayer": "Music player", + "closeAndStop": "Close and stop", + "playInBackground": "Play in background", + "copyTo": "Copy to", + "copyToDst": "Copy to <0>{{dst}}", + "errorReadFileContent": "Failed to read file content: {{msg}}", + "wordWrap": "Word wrap", + "pdfLoadingError": "Failed to load PDF: {{msg}}", + "subtitleSwitchTo": "Subtitle switched to: {{subtitle}}", + "noSubtitleAvailable": "No available subtitle files in the video folder (supported: ASS/SRT/VTT)", + "subtitle": "Subtitles", + "playlist": "Playlist", + "openInExternalPlayer": "Open in external player", + "searchResult": "Search Results", + "preparingBathDownload": "Preparing batch download...", + "preparingDownload": "Preparing to download...", + "browserBatchDownload": "Browser-side archiving", + "browserBatchDownloadDescription": "Downloaded and packaged by the browser in real time, not all environments are supported.", + "serverBatchDownload": "Server-side transit archiving", + "serverBatchDownloadDescription": "Archive by the server and sent to the client for download on-the-fly.", + "selectArchiveMethod": "Select archive method", + "batchDownloadStarted": "Batch download has started, please do not close this tab", + "batchDownloadError": "Failed to archive: {{msg}}", + "userDenied": "User denied.", + "directoryDownloadReplace": "Overwrite", + "directoryDownloadReplaceDescription": "{{num}} objects including {{duplicates}} will be overwritten.", + "directoryDownloadSkip": "Skip", + "directoryDownloadSkipDescription": "{{num}} objects including {{duplicates}} will be skipped.", + "selectDirectoryDuplicationMethod": "How to handle duplicate files?", + "directoryDownloadStarted": "Download started, please do not close this tab.", + "directoryDownloadFinished": "Download finished, no failed objects.", + "directoryDownloadFinishedWithError": "Download finished, {{failed}} object failed.", + "directoryDownloadPermissionError": "Permission denied, please allow read and write local files." + }, + "modals": { + "processing": "Processing...", + "duplicatedObjectName": "Duplicated object name.", + "duplicatedFolderName": "Duplicated folder name.", + "taskCreated": "Task created.", + "taskCreateFailed": "{{failed}} task(s) failed to be created: {{details}}.", + "linkCopied": "Link copied.", + "getSourceLinkTitle": "Get source link", + "sourceLink": "Source link", + "folderName": "Folder name", + "create": "Create", + "fileName": "File name", + "renameDescription": "Enter the new name for <0>{{name}} :", + "newName": "New name", + "moveToTitle": "Move to", + "moveToDescription": "Move to <0>{{name}}", + "saveToTitle": "Save to", + "saveToTitleDescription": "Save to <0>{{name}}", + "deleteTitle": "Delete objects", + "deleteOneDescription": "Are you sure to delete <0>{{name}} ?", + "deleteMultipleDescription": "Are you sure to remove those {{num}} objects?", + "newRemoteDownloadTitle": "New remote download task", + "remoteDownloadURL": "Download target URL", + "remoteDownloadURLDescription": "Paste the download URL, one URL per line, support HTTP(s) / FTP / Magnet link", + "remoteDownloadDst": "Download to", + "remoteDownloadNode": "Download node", + "remoteDownloadNodeAuto": "Auto dispatch", + "createTask": "Creat task", + "downloadTo": "Download to <0>{{name}}", + "decompressTo": "Decompress to", + "decompressToDst": "Decompress to <0>{{name}}", + "defaultEncoding": "Default", + "chineseMajorEncoding": "", + "selectEncoding": "Select the encoding for non-UTF8 characters", + "noEncodingSelected": "No encoding method selected", + "listingFiles": "Listing files...", + "listingFileError": "Failed to list files: {{message}}", + "generatingSourceLinks": "Generating source links...", + "noFileCanGenerateSourceLink": "There is no file that can be used to generate source link", + "sourceBatchSizeExceeded": "The current user group can generate source links for a maximum of {{limit}} files at the same time.", + "zipFileName": "ZIP file name", + "shareLinkShareContent": "I shared with you: {{name}} Link: {{link}}", + "shareLinkPasswordInfo": "Password: {{password}}", + "createShareLink": "Create share link", + "usePasswordProtection": "Use password protection", + "sharePassword": "Share password", + "randomlyGenerate": "Random", + "expireAutomatically": "Automatic expiration", + "downloadLimitOptions": "{{num}} downloads", + "or": "Or after", + "5minutes": "5 minutes", + "1hour": "1 hour", + "1day": "1 day", + "7days": "7 days", + "30days": "30 days", + "custom": "Custom", + "seconds": "seconds", + "downloads": "downloads", + "downloadSuffix": "", + "allowPreview": "Enable preview", + "allowPreviewDescription": "Whether to allow preview of file content from the share link", + "shareLink": "Share link", + "sendLink": "Send the link", + "directoryDownloadReplaceNotifiction": "Overwrite {{name}}", + "directoryDownloadSkipNotifiction": "Skipped {{name}}", + "directoryDownloadTitle": "Download", + "directoryDownloadStarted": "Start downloading {{name}}", + "directoryDownloadFinished": "Download finished", + "directoryDownloadError": "Error: {{msg}}", + "directoryDownloadErrorNotification": "Error occurs while download {{name}}: {{msg}}", + "directoryDownloadAutoscroll": "Auto scroll", + "directoryDownloadCancelled": "Download cancelled", + "advanceOptions": "Advanced options", + "forceDelete": "Force delete ", + "forceDeleteDes": "Force delete file records, regardless of whether the physical file was successfully deleted.", + "unlinkOnly": "Unlink only", + "unlinkOnlyDes": "Delete file records only, physical files will not be deleted." + }, + "uploader": { + "fileNotMatchError": "The selected file does not match the original file.", + "unknownError": "Unknown error occurs: {{msg}}", + "taskListEmpty": "No upload task.", + "hideTaskList": "Hide the list", + "uploadTasks": "Upload tasks", + "moreActions": "More actions", + "addNewFiles": "Add new files", + "toggleTaskList": "Expand/Collapse the list", + "pendingInQueue": "Pending in queue...", + "preparing": "Preparing...", + "processing": "Processing...", + "progressDescription": "{{uploaded}} uploaded, {{total}} total - {{percentage}}%", + "progressDescriptionFull": "{{uploaded}} uploaded, {{total}} total - {{percentage}}% ({{speed}})", + "progressDescriptionPlaceHolder": " - uploaded", + "uploadedTo": "Uploaded to ", + "rootFolder": "Root folder", + "unknownStatus": "Unknown", + "resumed": "Resumed", + "resumable": "Resumable", + "retry": "Retry", + "deleteTask": "Delete task", + "cancelAndDelete": "Cancel and delete", + "selectAndResume": "Select the same file and resume uploading", + "fileName": "Name: ", + "fileSize": "Size: ", + "sessionExpiredIn": "Expires <0>", + "chunkDescription": "({{total}} chunks, {{size}} each)", + "noChunks": "(No chunks)", + "destination": "Destination: ", + "uploadSession": "Upload session: ", + "errorDetails": "Error details: ", + "uploadSessionCleaned": "All upload sessions cleared.", + "hideCompletedTooltip": "Hide completed, failed and cancelled tasks.", + "hideCompleted": "Hide completed tasks", + "addTimeAscTooltip": "Tasks added first are ranked first.", + "addTimeAsc":"Oldest to newest", + "addTimeDescTooltip": "Latest added first are ranked first.", + "addTimeDesc": "Newest to oldest", + "showInstantSpeedTooltip": "Task upload speeds are shown as instantaneous speed.", + "showInstantSpeed": "Instantaneous speed", + "showAvgSpeedTooltip": "Task upload speeds are shown as average speeds.", + "showAvgSpeed": "Average speed", + "cleanAllSessionTooltip": "Clear all outstanding upload sessions on the server side.", + "cleanAllSession": "Clear all upload sessions", + "cleanCompletedTooltip": "Clear completed, failed, and cancelled tasks", + "cleanCompleted": "Clear completed tasks", + "retryFailedTasks": "Retry all failed tasks", + "retryFailedTasksTooltip": "Retry all failed tasks in current queue", + "setConcurrentTooltip": "Set the max number of tasks that can be uploaded simultaneously.", + "setConcurrent": "Set concurrent task limit", + "sizeExceedLimitError": "File size exceeds storage policy limits. (Maximum: {{max}})", + "suffixNotAllowedError": "The storage policy does not support uploading files with this extension. (Supported:{{supported}})", + "createUploadSessionError": "Unable to create upload session", + "deleteUploadSessionError": "Unable to delete upload session", + "requestError": "Request failed: {{msg}} ({{url}}).", + "chunkUploadError": "Failed to upload chunk [{{index}}].", + "conflictError": "The upload task for files with the same name is already being processed.", + "chunkUploadErrorWithMsg": "Chunk upload failed: {{msg}}", + "chunkUploadErrorWithRetryAfter": "(Please retry after {{retryAfter}}s)", + "emptyFileError": "Uploading empty files to OneDrive is not supported, please create empty files via the Create File button.", + "finishUploadError": "Unable to complete file upload.", + "finishUploadErrorWithMsg": "Unable to complete file upload: {{msg}}", + "ossFinishUploadError": "Unable to complete file upload: {{msg}} ({{code}})", + "cosUploadFailed": "Upload failed: {{msg}} ({{code}})", + "upyunUploadFailed": "Upload failed: {{msg}}", + "parseResponseError": "Unable to parse response: {{msg}} ({{content}})", + "concurrentTaskNumber": "Concurrent task limit", + "dropFileHere": "Drop file to upload" + }, + "share": { + "expireInXDays": "Expire in $t(share.days, {\"count\": {{num}} })", + "days":"{{count}} day", + "days_other":"{{count}} days", + "expireInXHours":"Expire in $t(share.hours, {\"count\": {{num}} })", + "hours":"an hour", + "hours_other":"{{count}} hours", + "createdBy": "Created by <0>{{nick}}", + "sharedBy": "<0>{{nick}} shared $t(share.files, {\"count\": {{num}} }) to you.", + "files":"1 file", + "files_other":"{{count}} files", + "statistics": "$t(share.views, {\"count\": {{views}} }) • $t(share.downloads, {\"count\": {{downloads}} }) • {{time}}", + "views":"{{count}} view", + "views_other":"{{count}} views", + "downloads":"{{count}} download", + "downloads_other":"{{count}} downloads", + "privateShareTitle": "Private share from {{nick}}", + "enterPassword": "Enter share password", + "continue": "Continue", + "shareCanceled": "Share is canceled.", + "listLoadingError": "Failed to load.", + "sharedFiles": "Shared files", + "createdAtDesc": "Date (Descending)", + "createdAtAsc": "Date (Ascending)", + "downloadsDesc": "Number of downloads (Descending)", + "downloadsAsc":"Number of downloads (Ascending)", + "viewsDesc":"Number of views (Descending)", + "viewsAsc":"Number of views (Ascending)", + "noRecords": "No shared files.", + "sourceNotFound": "[Source not exist]", + "expired": "Expired", + "changeToPublic": "Make it public", + "changeToPrivate": "Make it private", + "viewPassword": "View password", + "disablePreview": "Disable preview", + "enablePreview": "Enable preview", + "cancelShare": "Cancel share", + "sharePassword": "Share password", + "readmeError": "Cannot load README: {{msg}}", + "enterKeywords": "Please enter search keywords.", + "searchResult": "Search results", + "sharedAt": "Shared at <0>", + "pleaseLogin": "Please sign in first.", + "cannotShare": "This file cannot be previewed.", + "preview": "Preview", + "incorrectPassword": "Password incorrect.", + "shareNotExist": "Invalid or expired share link." + }, + "download": { + "failedToLoad": "Failed to load.", + "active": "Active", + "finished": "Finished", + "activeEmpty": "No ongoing download task.", + "finishedEmpty": "No finished download task.", + "loadMore": "Load more", + "taskFileDeleted": "File deleted.", + "unknownTaskName": "[Unknown]", + "taskCanceled": "Download task cancelled, status will be updated later", + "operationSubmitted": "Operation submitted, status will be updated later", + "deleteThisFile": "Delete this file", + "openDstFolder": "Open target folder", + "selectDownloadingFile": "Select files to download", + "cancelTask": "Cancel", + "updatedAt": "Updated at: ", + "uploaded": "Uploaded: ", + "uploadSpeed": "Upload speed: ", + "InfoHash": "InfoHash: ", + "seederCount": "Seeders:", + "seeding": "Seeding: ", + "downloadNode": "Node: ", + "isSeeding": "Yes", + "notSeeding": "No", + "chunkSize": "Chunk size:", + "chunkNumbers": "Chunks:", + "taskDeleted": "Task deleted.", + "transferFailed": "Failed to transfer files.", + "downloadFailed": "Download failed: {{msg}}", + "canceledStatus": "Canceled", + "finishedStatus": "Finished", + "pending": "Finished, transfer pending in queue", + "transferring": "Finished, transferring", + "deleteRecord": "Delete record", + "createdAt": "Created at: " + }, + "setting": { + "avatarUpdated": "The avatar has been updated and will take effect after refreshing.", + "nickChanged": "Nickname changed and will take effect after refreshing.", + "settingSaved": "Setting saved.", + "themeColorChanged": "Theme color changed.", + "profile": "Profile", + "avatar": "Avatar", + "uid": "UID", + "nickname": "Nickname", + "group": "Group", + "regTime": "Sign up date", + "privacyAndSecurity": "Privacy and security", + "profilePage": "Public profile", + "accountPassword": "Password", + "2fa": "2FA authentication", + "enabled": "Enabled", + "disabled": "Disabled", + "appearance": "Appearance", + "themeColor": "Theme color", + "darkMode": "Dark mode", + "syncWithSystem": "Sync with system", + "fileList": "File list", + "timeZone": "Timezone", + "webdavServer": "Server", + "userName": "Username", + "manageAccount": "Manage accounts", + "uploadImage": "Upload from file", + "useGravatar": "Use Gravatar ", + "changeNick": "Change nickname", + "originalPassword": "Original password", + "enable2FA": "Enable 2FA authentication", + "disable2FA": "Disable 2FA authentication", + "2faDescription": "Please use any 2FA mobile app or password management software that supports 2FA to scan the QR code on the left to add this site. After scanning, please fill in the 6-digit verification code given by the 2FA app to enable 2FA.", + "inputCurrent2FACode": "Enter current 2FA verification code.", + "timeZoneCode": "IANA timezone code", + "authenticatorRemoved": "Authenticator removed.", + "authenticatorAdded": "Authenticator added.", + "browserNotSupported": "Not supported by current browser or environment.", + "removedAuthenticator": "Remove authenticator", + "removedAuthenticatorConfirm": "Are you sure to remove this authenticator?", + "addNewAuthenticator": "Add a authenticator", + "hardwareAuthenticator": "Hardware authenticator", + "copied": "Copied to clipboard.", + "pleaseManuallyCopy": "Current browser does not support, please copy manually.", + "webdavAccounts": "WebDAV Accounts", + "webdavHint": "WebDAV server: {{url}}; Username: {{name}} ; The password is the password of the created account below.", + "annotation": "Annotation", + "rootFolder": "Relative root folder", + "createdAt": "Created at", + "action": "Action", + "readonlyOn": "Turn on readonly", + "readonlyOff": "Turn off readonly", + "useProxyOn": "Turn on reverse proxy", + "useProxyOff": "Turn off reverse proxy", + "delete": "Delete", + "listEmpty": "No records.", + "createNewAccount": "Create new account", + "taskType": "Task type", + "taskStatus": "Status", + "lastProgress": "Last progress", + "errorDetails": "Error details", + "queueing": "Queueing", + "processing": "Processing", + "failed": "Failed", + "canceled": "Canceled", + "finished": "Finished", + "fileTransfer": "File transfer", + "fileRecycle": "File recycle", + "importFiles": "Import external files", + "transferProgress": "{{num}} files done", + "waiting": "Pending", + "compressing": "Compressing", + "decompressing": "Decompressing", + "downloading": "Downloading", + "transferring": "Transferring", + "indexing": "Indexing", + "listing": "Inserting", + "allShares": "Shared", + "trendingShares": "Trending", + "totalShares": "Created shares", + "fileName": "File name", + "shareDate": "Shared at", + "downloadNumber": "Downloads", + "viewNumber": "Views", + "language": "Language", + "iOSApp": "iOS App", + "connectByiOS": "Connect to <0>{{title}} through iOS devices.", + "downloadOurApp": "Download our iOS APP:", + "fillInEndpoint": "Scan below QR Code with our App (DO NOT use other app to scan):", + "loginApp": "You can start using the iOS App now. If you encounter problems with the QR Code, you can also try to manually enter your username and password to log in.", + "aboutCloudreve": "About Cloudreve", + "githubRepo": "GitHub Repository", + "homepage": "Homepage" + }, + "vas": { + "loginWithQQ": "Sign in with QQ", + "quota": "Quota", + "exceedQuota": "Your used capacity has exceeded the quota, please delete the extra files or buy more storage as soon as possible.", + "extendStorage": "Buy storage", + "folderPolicySwitched": "Folder storage policy is switched.", + "switchFolderPolicy": "Switching folder storage policies", + "setPolicyForFolder": "Set the storage policy for the current folder: ", + "manageMount": "Manage mounts", + "saveToMyFiles": "Save to my files", + "report": "Report abuse", + "migrateStoragePolicy": "Migrate storage policy", + "fileSaved": "File(s) saved.", + "sharePurchaseTitle": "Sure you want to pay {{score}} credits for this share?", + "sharePurchaseDescription": "After purchase, you are free to preview and download all the contents of this share without repeated deduction for a certain period of time. If you have already purchased, please ignore this message.", + "payToDownload": "Paid credits to download", + "creditToBePaid": "Credits to be paid per person per download", + "creditGainPredict": "Expected {{num}} points to you per download", + "creditPrice": " (Cost {{num}} credits)", + "creditFree": " (Credits free)", + "cancelSubscription": "The cancellation is successful and the change will take effect in a few minutes.", + "qqUnlinked": "Unlinked from QQ account.", + "groupExpire": " Expires <0>", + "manuallyCancelSubscription": "Unsubscribe current user group", + "qqAccount": "QQ account", + "connect": "Connect", + "unlink": "Unlink", + "credits": "Credits", + "cancelSubscriptionTitle": "Unsubscribe", + "cancelSubscriptionWarning": "You will return to the initial user group and the credits paid is not refundable, are you sure you want to continue?", + "mountPolicy": "Mount storage policy", + "mountDescription": "After mounting a storage policy to a folder, new files uploaded to this folder or sub-folders will be stored using the mounted storage policy. Copying and moving to this folder will not apply the mounted storage policy; when multiple parent folders are specified, the storage policy of the closest parent folder will be selected.", + "mountNewFolder": "Mount new folder", + "nsfw": "NSFW", + "malware": "Malware", + "copyright": "Copyright", + "inappropriateStatements": "Inappropriate statements", + "other": "Other", + "groupBaseQuota": "Group base quota", + "validPackQuota": "Storage packs", + "used": "Used", + "total": "Total", + "validStoragePack": "Valid storage packs", + "buyStoragePack": "Buy storage packs", + "useGiftCode": "Redeem with gift code", + "packName": "Pack name", + "activationDate": "Activation date", + "validDuration": "Duration", + "expiredAt": "Expire at", + "days": "$t(share.days, {\"count\": {{num}} })", + "pleaseInputGiftCode": "Please enter gift code.", + "pleaseSelectAStoragePack": "Please select a storage pack.", + "selectPaymentMethod": "Payment: ", + "noAvailableMethod": "No available payment method", + "alipay": "Alipay", + "wechatPay": "Wechat Pay", + "payByCredits": "Credits", + "purchaseDuration": "Duration unit: ", + "creditsNum": "Credits qyt:", + "store": "Store", + "storagePacks": "Storage packs", + "membership": "Memberships", + "buyCredits": "Credits", + "subtotal": "Subtotal: ", + "creditsTotalNum": "{{num}} credits", + "checkoutNow": "Buy now", + "recommended": "Recommended", + "enterGiftCode": "Enter gift code", + "qrcodeAlipay": "Please use Alipay to scan the QR code below to complete the payment, this page will be automatically refreshed after the payment is completed.", + "qrcodeWechat": "Please use Wechat to scan the QR code below to complete the payment, this page will be automatically refreshed after the payment is completed.", + "qrcodeCustom": "Please scan the QR code below to complete the payment, this page will be automatically refreshed after the payment is completed.", + "paymentCompleted": "Payment completed", + "productDelivered": "Your purchase are processed.", + "confirmRedeem": "Redeem", + "productName": "Product: ", + "qyt": "Qyt: ", + "duration": "Duration: ", + "subscribe": "Subscribe", + "selected": "Selected: ", + "paymentQrcode": "Payment QRCode", + "validDurationDays": "Duration: $t(share.days, {\"count\": {{num}} })", + "reportSuccessful": "Report submitted.", + "additionalDescription": "Additional description", + "announcement": "Announcement", + "dontShowAgain": "Don't show again", + "openPaymentLink": "Open payment link" + } +} diff --git a/public/locales/en-US/common.json b/public/locales/en-US/common.json new file mode 100644 index 0000000..26f9dbc --- /dev/null +++ b/public/locales/en-US/common.json @@ -0,0 +1,90 @@ +{ + "pageNotFound": "Page not found", + "unknownError": "Unknown error", + "errLoadingSiteConfig": "Unable to load site configuration: ", + "newVersionRefresh": "A new version of the current page is available and ready to be refreshed.", + "errorDetails": "Error details", + "renderError": "There is an error in the page rendering, please try refreshing this page.", + "ok": "OK", + "cancel": "Cancel", + "select": "Select", + "copyToClipboard": "Copy", + "close": "Close", + "intlDateTime": "{{val, datetime}}", + "timeAgoLocaleCode": "en_US", + "forEditorLocaleCode": "en", + "artPlayerLocaleCode": "en", + "errors": { + "401": "Please login.", + "403": "You are not allowed to perform this action.", + "404": "Resource not found.", + "409": "Conflict. ({{message}})", + "40001": "Invalid input parameters ({{message}}).", + "40002": "Upload failed.", + "40003": "Failed to create folder.", + "40004": "Object with the same name already exist.", + "40005": "Signature expired.", + "40006": "Not supported policy type.", + "40007": "Current group have no permission to perform such action.", + "40011": "Upload session not exist or expired.", + "40012": "Invalid chunk index. ({{message}})", + "40013": "Invalid content length. ({{message}})", + "40014": "Exceed batch size limit of getting source link.", + "40015": "Exceed aria2 batch size limit.", + "40016": "Path not found.", + "40017": "This account has been blocked.", + "40018": "This account is not activated.", + "40019": "This feature is not enabled.", + "40020": "Wrong password or email address.", + "40021": "User not found.", + "40022": "Verification code not correct.", + "40023": "Login session not exist.", + "40024": "Cannot initialize WebAuthn.", + "40025": "Authentication failed.", + "40026": "CAPTCHA code is not correct.", + "40027": "Verification failed, please refresh the page and retry.", + "40028": "Email delivery failed.", + "40029": "This link is invalid.", + "40030": "This link is expired.", + "40032": "This email is already in use.", + "40033": "This account is not activated, activation email has been resent.", + "40034": "This user cannot be activated.", + "40035": "Storage policy not found.", + "40039": "Group not found.", + "40044": "File not found.", + "40045": "Failed to list objects under given folder.", + "40047": "Failed to initialize filesystem.", + "40048": "Failed to create task", + "40049": "File size exceed limit.", + "40050": "File type not allowed.", + "40051": "Insufficient storage quota.", + "40052": "Invalid object name, please remove special characters.", + "40053": "Cannot perform such action on root folder", + "40054": "File with the same name is already being uploaded under this folder, please cleanup upload sessions.", + "40055": "File metadata mismatch.", + "40056": "Unsupported compressed file type.", + "40057": "Available storage policy has changed, please refresh the file list and add this task again.", + "40058": "This share does not exist or already expired.", + "40069": "Incorrect password.", + "40070": "This share doesn't support preview.", + "40071": "Invalid signature.", + "50001": "Database operation failed. ({{message}})", + "50002": "Failed to sign the URL or request. ({{message}})", + "50004": "I/O operation failed. ({{message}})", + "50005": "Internal error.", + "50010": "Desired node is unavailable.", + "50011": "Failed to query file metadata." + }, + "vasErrors": { + "40031": "This email provider is forbidden, please change to another one.", + "40059": "You cannot save your own share.", + "40062": "Insufficient credits.", + "40063": "Your current membership has not yet expired, please go to settings page to manually unsubscribe the membership. first.", + "40064": "You are already in this membership.", + "40065": "Invalid gift code.", + "40066": "You already have a QQ account linked, please unlink it first.", + "40067": "This QQ account is already linked to other account.", + "40068": "This QQ account is not linked to any account.", + "40072": "You are administrator, you cannot purchase other group." + } +} \ No newline at end of file diff --git a/public/locales/en-US/dashboard.json b/public/locales/en-US/dashboard.json new file mode 100644 index 0000000..0c15107 --- /dev/null +++ b/public/locales/en-US/dashboard.json @@ -0,0 +1,986 @@ +{ + "errors":{ + "40036": "Default storage policy cannot be deleted.", + "40037": "{{message}} file(s) are using this policy, please delete those files first.", + "40038": "{{message}} group(s) are using this policy, please unlink those groups first.", + "40040": "Cannot perform such action on system group.", + "40041": "{{message}} users are still in this group, please delete or unlink those users first.", + "40042": "Cannot change the group of system user group.", + "40043": "Cannot perform such action on default user.", + "40046": "Cannot perform such action on master node.", + "40060": "Slave node cannot send callback request to master, please check master node setting: Basic - Site Information - Site URL, please make sure slave node can access this url. ({{message}})", + "40061": "Mismatched Cloudreve version. ({{message}})", + "50008": "Failed to update setting. ({{message}})", + "50009": "Failed to add CORS policy." + }, + "nav": { + "summary": "Summary", + "settings": "Settings", + "basicSetting": "Basic", + "publicAccess": "Public Access", + "email": "Email", + "transportation": "Transmission", + "appearance": "Appearance", + "image": "Images", + "captcha": "Captcha", + "storagePolicy": "Storage Policy", + "nodes": "Nodes", + "groups": "Groups", + "users": "Users", + "files": "Files", + "shares": "Shares", + "tasks": "Tasks", + "remoteDownload": "Remote Download", + "generalTasks": "General", + "title": "Dashboard", + "dashboard": "Cloudreve Dashboard" + }, + "summary": { + "newsletterError": "Failed to load newsletter.", + "confirmSiteURLTitle": "Confirm site URL", + "siteURLNotSet": "You have not set the site URL yet, do you want to set it to the current {{current}} ?", + "siteURLNotMatch": "The site URL you set does not match the current one, do you want to set it to the {{current}} ?", + "siteURLDescription": "This setting is very important, make sure it matches the actual URL of your site. You can change this setting in Settings - Basic.", + "ignore": "Ignore", + "changeIt": "Change it", + "trend": "Trend", + "summary": "Summary", + "totalUsers": "Users", + "totalFiles": "Files", + "publicShares": "Public shares", + "privateShares": "Private shares", + "homepage": "Homepage", + "documents": "Documents", + "github": "Github repository", + "forum": "Forum", + "forumLink": "https://github.com/cloudreve/Cloudreve/discussions", + "telegramGroup": "Telegram group", + "telegramGroupLink": "https://t.me/cloudreve_global", + "buyPro": "Donate to developers", + "publishedAt": "published at <0>", + "newsTag": "announcements" + }, + "settings": { + "saved": "Settings saved.", + "save": "Save", + "basicInformation": "Basic Information", + "mainTitle": "Main title", + "mainTitleDes": "Main title of the website.", + "subTitle": "Subtitle", + "subTitleDes": "Subtitle of the website.", + "siteKeywords": "Site keywords", + "siteKeywordsDes": "Keywords of the website, separated by commas.", + "siteDescription": "Site description", + "siteDescriptionDes": "Description of the website, which may be displayed in the shared page summary.", + "siteURL": "Site URL", + "siteURLDes": "Very important, please make sure it is consistent with the actual situation. When using cloud storage policy and payment platform, please fill in the address that can be accessed by WAN.", + "customFooterHTML": "Custom footer HTML", + "customFooterHTMLDes": "Custom HTML code inserted at the bottom of the page.", + "announcement": "Announcement", + "announcementDes": "Announcements displayed to logged-in users. Blank value will not be displayed. After this content is changed, all users will see the announcement again.", + "supportHTML": "Enter HTML or plain text.", + "pwa": "Progressive Web Application (PWA)", + "smallIcon": "Small icon", + "smallIconDes": "URL of the small icon with the ico as extension", + "mediumIcon": "Medium icon", + "mediumIconDes": "URL of the medium icon, prefer size at 192x192, png format.", + "largeIcon": "Large icon", + "largeIconDes": "URL of the medium icon, prefer size at 512x512, png format. This icon will also be shown while switching account in iOS app.", + "displayMode": "Display mode", + "displayModeDes": "The display mode of a PWA application after it's installed.", + "themeColor": "Theme color", + "themeColorDes": "CSS color value that affect the color of the status bar on the PWA launch screen, the status bar in the content page, and the address bar.", + "backgroundColor": "Background color", + "backgroundColorDes": "CSS color value.", + "hint": "Hint", + "webauthnNoHttps": "Web Authn requires your website to be HTTPS enabled, and please confirm that in Settings - Basic - Site URL also uses HTTPS.", + "accountManagement": "Accounts", + "allowNewRegistrations": "Accept new signups", + "allowNewRegistrationsDes": "After disabled, no new users can be registered, unless manually added by admins.", + "emailActivation": "Email activation", + "emailActivationDes": "After enabled, new users need to click the activation link in the email to complete signups. Please make sure the email delivery settings are correct, otherwise the activation email will not be delivered.", + "captchaForSignup": "Captcha for signups", + "captchaForSignupDes": "Whether to enable the captcha for signups.", + "captchaForLogin": "Captcha for logins", + "captchaForLoginDes": "Whether to enable the captcha for logins.", + "captchaForReset": "Captcha for resetting password", + "captchaForResetDes": "Whether to enable the captcha for resetting password.", + "webauthnDes": "Whether to allow users to log in using a hardware authenticator, the website must enable HTTPS for this to work.", + "webauthn": "Hardware authenticator", + "defaultGroup": "Default group", + "defaultGroupDes": "The initial user group after user registration.", + "testMailSent": "Test email is sent.", + "testSMTPSettings": "Test SMTP settings", + "testSMTPTooltip": "Before sending a test email, please save the changed SMTP settings; the email delivery results will not be fed back immediately, if you do not receive a test email for a long time, please check the error log output by Cloudreve in the terminal.", + "recipient": "Recipient", + "send": "Send", + "smtp": "SMTP", + "senderName": "Sender name", + "senderNameDes": "The sender's name displayed in the email.", + "senderAddress": "Sender address", + "senderAddressDes": "Email address of the sender.", + "smtpServer": "SMTP server", + "smtpServerDes": "SMTP server address, without port number.", + "smtpPort": "SMTP Port", + "smtpPortDes": "Port of SMTP server.", + "smtpUsername": "SMTP Username", + "smtpUsernameDes": "SMTP username, generally the same as the sender address.", + "smtpPassword": "SMTP Password", + "smtpPasswordDes": "Password of the sender mailbox.", + "replyToAddress": "Reply to address", + "replyToAddressDes": "The mailbox used to receive reply emails when users reply to emails sent by the system.", + "enforceSSL": "Enforce SSL connection", + "enforceSSLDes": "Whether to enforce an SSL encrypted connection. If you cannot send emails, you can turn this off and Cloudreve will try to use STARTTLS and decide whether to use encrypted connections.", + "smtpTTL": "SMTP connection TTL (seconds)", + "smtpTTLDes": "SMTP connections established during the TTL period will be reused by new mail delivery requests.", + "emailTemplates": "Email Templates", + "activateNewUser": "Activate new user", + "activateNewUserDes": "Template for activation email after new user registration.", + "resetPassword": "Reset password", + "resetPasswordDes": "Template reset password.", + "sendTestEmail": "Send test email", + "transportation": "Transmission", + "workerNum": "Number of worker", + "workerNumDes": "The maximum number of tasks to be executed in parallel by the master node task queue, restarting Cloudreve is needed to take effect.", + "transitParallelNum": "Number of transfer in parallel", + "transitParallelNumDes": "Maximum number of parallel co-processes for transfer tasks.", + "tempFolder": "Temp folder", + "tempFolderDes": "Used to store temporary files generated by tasks such as decompression, compression, etc.", + "textEditMaxSize": "Max size of editable document files", + "textEditMaxSizeDes": "The maximum size of a document file that can be edited online, files beyond this size cannot be edited online. This setting applies to plain text, code and Office documents (WOPI).", + "failedChunkRetry": "Max chunk error retries", + "failedChunkRetryDes": "Maximum number of retries after a failed chunk, only for server-side uploads or transferring.", + "cacheChunks": "Cache chunk for retries", + "cacheChunksDes": "If enabled, streaming chunk uploads will cache chunk data in a temporary directory for retrying after failed uploads.\n If disabled, streaming chunk uploads do not take up additional hard disk space, but the entire upload will fail immediately after a single chunk failure.", + "resetConnection": "Reset connection after failed upload", + "resetConnectionDes": "If enabled, the server will force to reset the connection if upload verification fails.", + "expirationDuration": "Expire Durations (seconds)", + "batchDownload": "Batch download", + "downloadSession": "Download session", + "previewURL": "Preview URL", + "docPreviewURL": "Doc preview URL", + "uploadSession": "Upload session", + "uploadSessionDes": "For supported storage policy, user can resume uploads within upload session expiration. Max value various from third-party storage providers.", + "downloadSessionForShared": "Download session in shares", + "downloadSessionForSharedDes": "Repeated downloads of shared files within this set period of time will not be counted in the total number of downloads.", + "onedriveMonitorInterval": "OneDrive upload monitor interval", + "onedriveMonitorIntervalDes": "At set intervals, Cloudreve will request OneDrive to check client uploads to ensure they're under control.", + "onedriveCallbackTolerance": "OneDrive callback timeout", + "onedriveCallbackToleranceDes": "Maximum time to wait for the callback after the OneDrive client has finished uploading, if it exceeds it, the upload will be considered failed.", + "onedriveDownloadURLCache": "OneDrive download cache", + "onedriveDownloadURLCacheDes": "Cloudreve can cache the result after getting the file download URL to reduce the frequency of hot API requests.", + "slaveAPIExpiration": "Slave API timeout (seconds)", + "slaveAPIExpirationDes": "Timeout time for master to wait for slave API request responses.", + "heartbeatInterval": "Node heartbeat interval (seconds)", + "heartbeatIntervalDes": "The interval at which the master node sends heartbeats to slave nodes.", + "heartbeatFailThreshold": "Heartbeat failure retry threshold", + "heartbeatFailThresholdDes": "The maximum number of retries the master can make after sending a heartbeat to a slave that fails. After all failed retries, the node will enter recovery mode.", + "heartbeatRecoverModeInterval": "Recover mode heartbeat interval (seconds)", + "heartbeatRecoverModeIntervalDes": "Interval between master attempts to reconnect to a node after the node has been marked as recovery mode.", + "slaveTransitExpiration": "Slave transfer timeout (seconds)", + "slaveTransitExpirationDes": "Maximum time that can be consumed by a slave to execute a file transfer task.", + "nodesCommunication": "Node Communication", + "cannotDeleteDefaultTheme": "Cannot delete default theme.", + "keepAtLeastOneTheme": "Please reserve at least one theme.", + "duplicatedThemePrimaryColor": "Duplicated primart color.", + "themes": "Themes", + "colors": "Colors", + "themeConfig": "Configs", + "actions": "Actions", + "wrongFormat": "Incorrect format.", + "createNewTheme": "Create new theme", + "themeConfigDoc": "https://v4.mui.com/customization/default-theme/", + "themeConfigDes": "Full available configurations can be referred at <0>Default Theme - Material-UI.", + "defaultTheme": "Default theme", + "defaultThemeDes": "The default them to use when the user does not specify a preferred one.", + "appearance": "Appearance", + "personalFileListView": "Default view for personal file list", + "personalFileListViewDes": "The default display view to use when the user does not specify a preferred one.", + "sharedFileListView": "Default view for shared file list", + "sharedFileListViewDes": "The default display view to use when the user does not specify a preferred one.", + "primaryColor": "Primary color", + "primaryColorText": "Text on primary color", + "secondaryColor": "Secondary color", + "secondaryColorText": "Text on secondary color", + "avatar": "Avatar", + "gravatarServer": "Gravatar server", + "gravatarServerDes": "URL of Gravatar mirror server.", + "avatarFilePath": "Avatar file path", + "avatarFilePathDes": "Path to save user's avatar files.", + "avatarSize": "Max avatar file size", + "avatarSizeDes": "Maximum size of avatar files that users can upload.", + "smallAvatarSize": "Small avatar width", + "mediumAvatarSize": "Medium avatar width", + "largeAvatarSize": "Large avatar width", + "filePreview": "File Preview", + "officePreviewService": "Office preview service", + "officePreviewServiceDes": "You can use following magic variables:", + "officePreviewServiceSrcDes": "File URL", + "officePreviewServiceSrcB64Des": " Base64 encoded file URL", + "officePreviewServiceName": "File name", + "thumbnails": "Thumbnails", + "localOnlyInfo": "The following settings only apply to local storage policies.", + "thumbnailDoc": "For more information about thumbnail, see the <0>document.", + "thumbnailDocLink":"https://docs.cloudreve.org/v/en/use/thumbnails", + "thumbnailBasic": "Basic", + "generators": "Generators", + "thumbMaxSize": "Maximum original file size", + "thumbMaxSizeDes": "The maximum original file size for which thumbnails can be generated, thumbnails will not be generated if files exceed this size.", + "generatorProxyWarning": "By default, non-local storage policies will only use the \"Native in storage policy\" generator. You can extend the thumbnail capability of third-party storage policies by enabling the \"Generator proxy\" feature.", + "policyBuiltin": "Native in storage policy", + "policyBuiltinDes": "Use the native API from storage provider to process thumbnails. For local and S3 policy, this generator is not available and will automatically fallback to other generators. For other storage policies, please refer to the Cloudreve documentation for supported image formats.", + "cloudreveBuiltin":"Cloudreve built-in", + "cloudreveBuiltinDes": "Only images in PNG, JPEG, GIF formats are supported using Cloudreve's built-in image processing capabilities.", + "libreOffice": "LibreOffice", + "libreOfficeDes": "Use LibreOffice to generate thumbnails for Office documents. This generator depends on any other image thumbnail generator (Cloudreve built-in or VIPS).", + "vips": "VIPS", + "vipsDes": "Use libvips to process thumbnail images, support more image formats, and consume less resources.", + "thumbDependencyWarning": "LibreOffice generators depend on Cloudreve built-in or VIPS generators, please enable either one.", + "ffmpeg": "FFmpeg", + "ffmpegDes": "Use FFmpeg to generate video thumbnails.", + "executable": "Executable", + "executableDes": "The address or command of the third-party generator executable.", + "executableTest": "Test", + "executableTestSuccess": "Generator works, version: {{version}}", + "generatorExts": "Available extensions", + "generatorExtsDes": "List of available file extensions for this generator, please use comma , to separate multiple ones.", + "ffmpegSeek": "Thumbnail capture location", + "ffmpegSeekDes": "Define the thumbnail interception time, it is recommended to choose a smaller value to speed up the generation process. If the actual length of the video is exceeded, the thumbnail generation will fail.", + "generatorProxy": "Generator proxy", + "enableThumbProxy": "Use generator proxy", + "proxyPolicyList": "Enabled storage policy", + "proxyPolicyListDes": "Multi-selectable. If enabled, files whose storage policy does not support native generation, its thumbnails will be proxy generated by the Cloudreve.", + "thumbWidth": "Width", + "thumbHeight": "Height", + "thumbSuffix": "File suffix", + "thumbConcurrent": "Concurrent count", + "thumbConcurrentDes": "-1 means auto.", + "thumbFormat": "Image format", + "thumbFormatDes": "Available: png/jpg", + "thumbQuality": "Quality", + "thumbQualityDes": "Compression quality percentage, valid only for jpg encoding.", + "thumbGC": "Run GC after thumb generated", + "captcha": "Captcha", + "captchaType": "Captcha type", + "plainCaptcha": "Plain", + "reCaptchaV2": "reCAPTCHA V2", + "tencentCloudCaptcha": "Tencent Cloud Captcha", + "captchaProvider": "Provider of the captcha service.", + "plainCaptchaTitle": "Plain Captcha", + "captchaWidth": "Width", + "captchaHeight": "Height", + "captchaLength": "Length", + "captchaMode": "Mode", + "captchaModeNumber": "Numbers", + "captchaModeLetter": "Letters", + "captchaModeMath": "Math", + "captchaModeNumberLetter": "Numbers + Letters", + "captchaElement": "Elements inside of the captcha image.", + "complexOfNoiseText": "Complex of noise text", + "complexOfNoiseDot": "Complex of noise dots", + "showHollowLine": "Show hollow lines", + "showNoiseDot": "Show noise dots", + "showNoiseText": "Show noise text", + "showSlimeLine": "Show slime lines", + "showSineLine": "Show sine lines", + "siteKey": "Site KEY", + "siteKeyDes": "You can find it at <0>App Management Page.", + "siteSecret": "Secret", + "siteSecretDes": "You can find it at <0>App Management Page.", + "secretID": "SecretId", + "secretIDDes": "You can find it at <0>Access Management Page.", + "secretKey": "SecretKey", + "secretKeyDes": "You can find it at <0>Access Management Page.", + "tCaptchaAppID": "APPID", + "tCaptchaAppIDDes": "You can find it at <0>Captcha Management Page.", + "tCaptchaSecretKey": "App Secret Key", + "tCaptchaSecretKeyDes": "You can find it at <0>Captcha Management Page.", + "staticResourceCache": "Public static resources cache", + "staticResourceCacheDes": "Max age of cache for public accessible static resources (e.g. local policy source link, download link).", + "wopiClient": "WOPI Client", + "wopiClientDes": "Extend Cloudreve's document online preview and editing capabilities by interfacing with online document processing systems that support the WOPI protocol. For more information, please refer to <0>Official Documentation.", + "wopiDocLink": "https://docs.cloudreve.org/v/en/use/wopi", + "enableWopi": "Enable WOPI", + "wopiEndpoint": "WOPI Discovery Endpoint", + "wopiEndpointDes": "Endpoint URL of WOPI Discovery API.", + "wopiSessionTtl": "Edit session TTL (seconds)", + "wopiSessionTtlDes": "The user opens an online editing document session with an expiration date, beyond which the session cannot continue to save new changes." + }, + "policy": { + "sharp": "#", + "name": "Name", + "type": "Type", + "childFiles": "Chile files", + "totalSize": "Total size", + "actions": "Actions", + "authSuccess": "Authorization granted.", + "policyDeleted": "Policy deleted.", + "newStoragePolicy": "New storage policy", + "all": "All", + "local": "Local", + "remote": "Remote Node", + "qiniu": "Qiniu", + "upyun": "Upyun", + "oss": "Alibaba Cloud OSS", + "cos": "Tencent Cloud COS", + "onedrive": "OneDrive", + "s3": "AWS S3", + "refresh": "Refresh", + "delete": "Delete", + "edit": "Edit", + "editInProMode": "Edit in pro mode", + "editInWizardMode": "Edit in wizard mode", + "selectAStorageProvider": "Select a storage provider", + "comparesStoragePolicies": "Compare storage policies", + "comparesStoragePoliciesLink": "https://docs.cloudreve.org/v/en/use/policy/compare", + "storagePathStep": "Storage path", + "sourceLinkStep": "Source links", + "uploadSettingStep": "Uploading", + "finishStep": "Finish", + "policyAdded": "Storage policy added.", + "policySaved": "Storage policy saved.", + "editLocalStoragePolicy": "Edit local storage policy", + "addLocalStoragePolicy": "Add local storage policy", + "optional": "Optional", + "pathMagicVarDes": "Enter the physical path to the folder you want to store files. Either absolute or relative (relative to Cloudreve executable) path is supported. You can use magic variables in the path, which will be automatically replaced with the corresponding values when the file is uploaded; see <0>List of path magic variables for available magic variables.", + "pathOfFolderToStoreFiles": "Path of the folder", + "filePathMagicVarDes": "Do you want to rename the physical files that are uploaded? The renaming here will not affect the final file name presented to the user. Magic variables can also be used for file names, see <0>List of Magic Variables for file names for available magic variables.", + "autoRenameStoredFile": "Enable auto-renaming", + "keepOriginalFileName": "Use original file name", + "renameRule": "Rename rule", + "next": "Next", + "enableGettingPermanentSourceLink": "Allow user to get permanent file source link?", + "enableGettingPermanentSourceLinkDes": "When enabled, users can obtain a direct link to the contents of the file, for use in the Image Bed application or for your own use. You may also need to enable this feature in the user group settings to make it available for users.", + "allowed": "Enable", + "forbidden": "Disable", + "useCDN": "Do you want to use CDN for download and source links?", + "useCDNDes": "When enabled, the domain part of the URL which user uses to access files will be replaced with the CDN domain.", + "use": "Enable", + "notUse": "Disable", + "cdnDomain": "Select a protocol and enter the CDN domain:", + "cdnPrefix": "CDN domain", + "back": "Back", + "limitFileSize": "Do you want to limit the max size of one single file that can be uploaded?", + "limit": "Yes", + "notLimit": "No", + "enterSizeLimit": "Enter the max file size:", + "maxSizeOfSingleFile": "Max single file size", + "limitFileExt": "Do you want to limit file extensions?", + "enterFileExt": "Enter the file extensions allowed to be uploaded, separated by semi-colon commas:", + "extList": "File extension list", + "chunkSizeLabel": "Specify the chunk size for resumable uploads. A value of 0 means no resumable uploads are used.", + "chunkSizeDes": "After enabling resumable upload, the files uploaded by users will be sliced into chunks and uploaded to the storage side one by one. After the upload is interrupted, users can choose to continue uploading from the last uploaded chunk.", + "chunkSize": "Chunk size", + "nameThePolicy": "Last step, name the storage policy:", + "policyName": "Storage policy name", + "finish": "Finish", + "furtherActions": "To use this storage policy, go to the user group setting page and bind this storage policy for the appropriate user group.", + "backToList": "Back to storage policy list", + "magicVar": { + "fileNameMagicVar": "File name magic variables", + "pathMagicVar": "Path magic variables", + "variable": "Variable", + "description": "Description", + "example": "Example", + "16digitsRandomString": "16 digits random string", + "8digitsRandomString": "8 digits random string", + "secondTimestamp": "Timestamp", + "nanoTimestamp": "Nano timestamp", + "uid": "User ID", + "originalFileName": "Original file name", + "originFileNameNoext": "Original file name without ext", + "extension": "File extension name", + "uuidV4": "UUID V4", + "date": "Date", + "dateAndTime": "Date and time", + "year": "Year", + "month": "Month", + "day": "Day", + "hour": "Hour", + "minute": "Minute", + "second": "Second", + "userUploadPath": "Upload path" + }, + "storageNode": "Storage node", + "communicationOK": "Communication successful.", + "editRemoteStoragePolicy": "Edit remote storage policy", + "addRemoteStoragePolicy": "Add remote storage policy", + "remoteDescription": "The remote storage policy allows you to use a server that is also running Cloudreve as the slave storage node, and users' upload and download traffic are directly transmitted over HTTP.", + "remoteCopyBinaryDescription": "Copy the Cloudreve executable with the same version as master to the server you want to use as a slave storage node.", + "remoteSecretDescription": "The following is the randomly generated slave secret, usually no need to change. If you have customization requirement, you can fill in your own secret into the following field.", + "remoteSecret": "Slave node secret", + "modifyRemoteConfig": "Modify the Cloudreve config file on slave node.", + "addRemoteConfigDes": " Create a new <0>conf.ini file in the same directory as the slave Cloudreve, fill in the slave configuration, and start/restart the slave Cloudreve. The following is an example configuration for your slave Cloudreve, where the secret section is pre-filled in for you as generated in the previous step.", + "remoteConfigDifference": "The configuration file format on the slave side is roughly the same as the master side, with the following differences:", + "remoteConfigDifference1": "The <1>mode field under the <0>System section must be changed to <2>slave.", + "remoteConfigDifference2": "You must specify the <1>Secret field under the <0>Slave section, whose value is the secret filled in or generated in step 2.", + "remoteConfigDifference3": "The cross-origin configuration, i.e. the contents of the <0>CORS field, must be enabled, as described in the example above or in the official documentation. If the configuration is not correct, users will not be able to upload files to the slave node via the web browser.", + "inputRemoteAddress": "Enter slave node address.", + "inputRemoteAddressDes": "If HTTPS is enabled on the master, the slave also needs to enable it and fill in the address with HTTPS protocol below.", + "remoteAddress": "Slave node address", + "testCommunicationDes": "After completing the above steps, you can test if the communication is working by clicking the test button below.", + "testCommunication": "Test slave communication", + "pathMagicVarDesRemote": "Enter the physical path to the folder you want to store files. Either absolute or relative (relative to slave Cloudreve executable) path is supported. You can use magic variables in the path, which will be automatically replaced with the corresponding values when the file is uploaded; see <0>List of path magic variables for available magic variables.", + "storageBucket": "Storage bucket", + "editQiniuStoragePolicy": "Edit Qiniu storage policy", + "addQiniuStoragePolicy": "Add Qiniu storage policy", + "wanSiteURLDes": "Before using this policy, please make sure that the address you entered in Basic Settings - Site Information - Site URL matches the actual address and <0>can be accessed properly by WAN.", + "createQiniuBucket": "Go to <0>Qiniu dashboard to create a storage bucket.。", + "enterQiniuBucket": "Enter the \"bucket name\" you just created:", + "qiniuBucketName": "Bucket name", + "bucketTypeDes": "Select the type of bucket you just created. We recommend selecting \"Private bucket\" for higher security.", + "privateBucket": "Private bucket", + "publicBucket": "Public bucket", + "bucketCDNDes": "Fill in the CDN-accelerated domain name you have bound for the storage bucket.", + "bucketCDNDomain": "CDN domain", + "qiniuCredentialDes": "Go to Personal Center - Credential Management in the Qiniu dashboard and fill in the obtained AK, SK.", + "ak": "AK", + "sk": "SK", + "cannotEnableForPrivateBucket": "If this feature is enabled for private bucket, you need to enable \"Use redirected source link\" for user groups.", + "limitMimeType": "Do you want to limit MimeTypes of file that can be uploaded?", + "mimeTypeDes": "Enter the MimeType of the allowed files, and separate multiple MimeTypes with a comma. Qiniu will detect the file content to determine the MimeType, and allow the upload if the MimeType is presented in your list.", + "mimeTypeList": "MimeTypes list", + "chunkSizeLabelQiniu": "Specify the chunk size for resumable uploads. Allowed range is 1 MB - 1 GB.", + "createPlaceholderDes": "Do you want to create a placeholder file and deduct user capacity when users start uploading? If enabled, Cloudreve will prevent users from maliciously initiating multiple upload requests but not completing the upload.", + "createPlaceholder": "Create placeholder files", + "notCreatePlaceholder": "Don't create", + "corsSettingStep": "CORS policy", + "corsPolicyAdded": "CORS policy is added successfully.", + "editOSSStoragePolicy": "Edit Alibaba Cloud OSS storage policy", + "addOSSStoragePolicy": "Add Alibaba Cloud OSS storage policy", + "createOSSBucketDes": "Go to <0>OSS Dashboard to create a Bucket。Attention: You can only use bucket with SKU of <1>Standard storage or <2>Low frequency storage, <3>Archive storage is not supported.", + "ossBucketNameDes": "Enter the your specified <0>Bucket name:", + "bucketName": "Bucket name", + "publicReadBucket": "Public read", + "ossEndpointDes": "Go to Bucket summary page, enter the <2>Endpoint under <1>External access section, in <0>Access domain page.", + "endpoint": "EndPoint", + "endpointDomainOnly": "Wrong format, just enter the hostname of the domain.", + "ossLANEndpointDes": "If your Cloudreve is deployed in Alibaba Cloud compute related services which are under the same availability zone as the OSS bucket, you can additionally specify a intranet endpoint, Cloudreve will try to use this endpoint on server side to reduce traffic cost. Do you want to use the OSS intranet endpoint?", + "intranetEndPoint": "Intranet endpoint", + "ossCDNDes": "Do you want to use Alibaba Cloud CDN to speed up file access?", + "createOSSCDNDes": "Go to <0>Alibaba Cloud CDN Dashboard to create a CDN domain, the source of the CDN should be your OSS bucket. Enter the CDN domain and select if you want to use HTTPS:", + "ossAKDes": "Obtain your AccessKey in <0>Security Information Management page, fill in the AccessKey below:", + "shouldNotContainSpace": "This cannot contain spaces.", + "nameThePolicyFirst": "Name the storage policy:", + "chunkSizeLabelOSS": "Specify the chunk size for resumable uploads. Allowed range is 100 KB - 5 GB.", + "ossCORSDes": "This storage policy requires a CORS policy to enable uploading from browser. Cloudreve can set it up automatically for you, or you can set it up manually by following the steps in the documentation. If you have already set the CORS policy for this Bucket, this step can be skipped.", + "letCloudreveHelpMe": "Let Cloudreve set it for me", + "skip": "Skip", + "editUpyunStoragePolicy": "Edit Upyun storage policy", + "addUpyunStoragePolicy": "Add Upyun storage policy", + "createUpyunBucketDes": "Go to <0>Upyun Dashboard to create a storage service.", + "storageServiceNameDes": "Enter the name of your storage service:", + "storageServiceName": "Service name", + "operatorNameDes": "Create an operators for this service, authorize the operators with read, write, and delete permissions, fill in the operator information below.", + "operatorName": "Operator name", + "operatorPassword": "Operator password", + "upyunCDNDes": "Fill in the domain bound for the storage service, and choose whether to use HTTPS.", + "upyunOptionalDes": "You can skip this step and keep it as default, but we strongly suggest you follow below instructions.", + "upyunTokenDes": "Go to the Feature Configuration panel of the created storage service, go to the Access Configuration tab, enable Token Anti-Hotlinking and set a secret.", + "tokenEnabled": "Enable Token Anti-Hotlinking", + "tokenDisabled": "Not use Token Anti-Hotlinking", + "upyunTokenSecretDes": "Enter the secret of the Token Anti-Hotlinking.", + "upyunTokenSecret": "Token Anti-Hotlinking secret", + "cannotEnableForTokenProtectedBucket": "This feature is not supported if you enable Token Anti-Hotlinking.", + "callbackFunctionStep": "Serverless callback", + "callbackFunctionAdded": "Callback function is added.", + "editCOSStoragePolicy": "Edit COS storage policy", + "addCOSStoragePolicy": "Add COS storage policy", + "createCOSBucketDes": "Go to <0>COS Dashboard to create a storage bucket.", + "cosBucketNameDes": "Go to basic setting page of created bucket, enter <0>Bucket name below:", + "cosBucketFormatError": "Bucket name format is not correct, an example: ccc-1252109809", + "cosBucketTypeDes": "Below you can select the type of access control for the bucket you created. We recommend selecting <0>Private Read/Write for higher security, private bucket do not have the \"Get Source Link\" feature.", + "cosPrivateRW": "Private Read/Write", + "cosPublicRW": "Public Read and Private Write", + "cosAccessDomainDes": "Go to the base configuration of the created Bucket and fill in the <1>Access Domain given under the <0>Basic Information section.", + "accessDomain": "Access domain", + "cosCDNDes": "Do you want to use Tencent Cloud CDN to speed up file access?", + "cosCDNDomainDes": "Go to <0>Tencent Cloud CDN Management Console to create a CDN acceleration domain and set the source site to the COS bucket you just created. Fill in the CDN domain name below and select whether to use HTTPS.", + "cosCredentialDes": "Get a pair of access keys from the Tencent Cloud <0>Access Keys page and fill them in below. Please make sure the pair of keys has access permission to COS and SCF services.", + "secretId": "SecretId", + "secretKey": "SecretKey", + "cosCallbackDes": "COS Storage Bucket Client-side direct transfer requires the use of Tencent Cloud's <0>Cloud Functions product to ensure controlled upload callbacks. This step can be skipped if you intend to use this storage policy on your own, or assign it to a trusted user group. If it is for public use, please make sure to create callback cloud functions.", + "cosCallbackCreate": "Cloudreve can try to automatically create the callback cloud function for you, please select the region of the COS bucket and continue. It may take a few seconds to create, so please be patient. Please make sure your Tencent Cloud account has cloud function service enabled before creating.", + "cosBucketRegion": "Bucket region", + "ap-beijing": "ap-beijing", + "ap-chengdu": "ap-chengdu", + "ap-guangzhou": "ap-guangzhou", + "ap-guangzhou-open": "ap-guangzhou-open", + "ap-hongkong": "ap-hongkong", + "ap-mumbai": "ap-mumbai", + "ap-shanghai": "ap-shanghai", + "na-siliconvalley": "na-siliconvalley", + "na-toronto": "na-toronto", + "applicationRegistration": "Application registration", + "grantAccess": "Grant access", + "warning": "Warning", + "odHttpsWarning": "You must enable HTTPS to use OneDrive/SharePoint storage policies; after enabled, make sure to change Settings - Basic - Site Information - Site URL.", + "editOdStoragePolicy": "Edit OneDrive/SharePoint storage policy", + "addOdStoragePolicy": "Add OneDrive/SharePoint storage policy", + "creatAadAppDes": "Go to <0>Azure Active Directory Dashboard (Worldwide cloud) or <1>Azure Active Directory Dashboard (21V Chinese cloud), after logging in, go to the <2>Azure Active Directory admin panel, you can optionally use an account different from the one used to store files to login.", + "createAadAppDes2": "Go to the <0>App Registrations menu on the left and click the <1>New registration button.", + "createAadAppDes3": "Fill out the application registration form. Makse sure <0>Supported account types is selected as <1>\tAccounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox); <2>Redirect URI (optional) is selected as <3>Web and fill in <4>{{url}}; For other fields, just leave it as default.", + "aadAppIDDes": "Once created, go to the <0>Overview page in Application Management, copy the <1>Application (Client) ID and fill in the following fields:", + "aadAppID": "Application (Client) ID", + "addAppSecretDes": "Go to the <0>Certificates & secrets menu on the left side, click the <1>New client secret button, and select <3>Never for the <2>Expires. After you are done creating the client secret, fill in the value of the client secret below:", + "aadAppSecret": "Client secret", + "aadAccountCloudDes": "Select your Microsoft 365 account type:", + "multiTenant": "Worldwide public cloud", + "gallatin": "21V Chinese cloud", + "sharePointDes": "Do you want to store files in SharePoint?", + "saveToSharePoint": "Store files to SharePoint", + "saveToOneDrive": "Store files to default OneDrive", + "spSiteURL": "SharePoint Site URL", + "odReverseProxyURLDes": "Do you want to use custom reverse proxy server for file downloading?", + "odReverseProxyURL": "URL of reverse proxy server", + "chunkSizeLabelOd": "Specify the chunk size for resumable uploads. OneDrive requires it must be an integer multiple of 320 KiB (327,680 bytes).", + "limitOdTPSDes": "Do you want to add a limit to the frequency of server-side OneDrive API requests?", + "tps": "TPS limit", + "tpsDes": "Limit this storage policy the maximum number of API requests sent to OneDrive per second. Requests that exceed this frequency will be rate-limited. When multiple Cloudreve nodes transferring files, they each use their own token bucket, so please scale this number down as appropriate in this condition.", + "tpsBurst": "TPS burst", + "tpsBurstDes": "When requested is idle, Cloudreve can reserve a specified number of slots for future bursts of traffic.", + "odOauthDes": "However, you will need to click the button below and authorize with Microsoft account login to complete the initialization before you can use it. You can re-authorize later on the Storage Policy List page.", + "gotoAuthPage": "Go to authorization page", + "s3SelfHostWarning": "AWS S3 storage policies are currently only safe for your own use, or for trusted user groups.", + "editS3StoragePolicy": "Edit AWS S3 storage policy", + "addS3StoragePolicy": "Add AWS S3 storage policy", + "s3BucketDes": "Go to AWS S3 dashboard to create a bucket, enter the <0>Bucket name you just created:", + "publicAccessDisabled": "Public read disabled", + "publicAccessEnabled": "Public read enabled", + "s3EndpointDes": "(Optional) Specify the EndPoint (geographical node) of the storage bucket in full URL format, e.g. <0>https://bucket.region.example.com. Leaving it blank will use the system-generated default endpoint.", + "selectRegionDes": "Select the region where the storage bucket is located or enter the region code manually.", + "enterAccessCredentials": "Obtain a pair of access credential and fill in below:", + "accessKey": "AccessKey", + "chunkSizeLabelS3": "Specify the chunk size for resumable uploads. Allowed range is 5 MB - 5 GB.", + "editPolicy": "Edit storage policy", + "setting":"Setting name", + "value": "Value", + "description": "Description", + "id": "ID", + "policyID": "ID of storage policy.", + "policyType": "Type of storage policy.", + "server": "Server", + "policyEndpoint": "Storage node endpoint.", + "bucketID": "Identifier of storage bucket.", + "yes": "Yes", + "no": "No", + "privateBucketDes": "Whether the storage bucket is private.", + "resourceRootURL": "File resource root URL", + "resourceRootURLDes": "Prefix of URL generated for previewing and downloading.", + "akDes": "AccessKey / RefreshToken", + "maxSizeBytes": "Max single file size (Bytes)", + "maxSizeBytesDes": "Max file size can be uploaded, 0 means no limit.", + "autoRename": "Auto rename", + "autoRenameDes": "Whether to automatically rename files.", + "storagePath": "Storage path", + "storagePathDes": "Physical path of uploaded files.", + "fileName": "File name", + "fileNameDes": "Physical name of uploaded files.", + "allowGetSourceLink": "Allow getting source link", + "allowGetSourceLinkDes": "Whether to allow getting source links. Note that some storage policy types are not supported, and even if they are turned on here, the obtained source links will be invalid.", + "upyunToken": "Upyun anti-hotlinking token", + "upyunOnly": "Available only for Upyun policy.", + "allowedFileExtension": "Allowed file extensions", + "emptyIsNoLimit": "Blank means not limit.", + "allowedMimetype": "Allowed MimeType", + "qiniuOnly": "Available only for Qiniu policy.", + "odRedirectURL": "OneDrive redirect URL", + "noModificationNeeded": "Generally no modification is needed.", + "odReverseProxy": "OneDrive reverse proxy server", + "odOnly": "Available only for OneDrive policy.", + "odDriverID": "OneDrive/SharePoint driver ID", + "odDriverIDDes": "Available only for OneDrive policy, blank means use default OneDrive driver.", + "s3Region": "Amazon S3 Region", + "s3Only": "Available only for AWS S3 policy.", + "lanEndpoint": "Intranet EndPoint.", + "ossOnly": "Available only for OSS policy.", + "chunkSizeBytes": "Chunk size (Bytes)", + "chunkSizeBytesDes": "Size of chunk for resumable uploads. Only supported in partial storage policy.", + "placeHolderWithSize": "Use placeholder before uploading", + "placeHolderWithSizeDes": "Whether to create a placeholder file before uploading .Only supported in partial storage policy.", + "saveChanges": "Save changes", + "s3EndpointPathStyle": "Select the format of the S3 Endpoint address, or if you don't know what to select, just leave to the default. Some third-party S3-compatible storage policies may require this option to work. When turned on, we will force to use of path-like format addresses, such as <0>http://s3.amazonaws.com/BUCKET/KEY.", + "usePathEndpoint": "Force path style", + "useHostnameEndpoint": "Use host name if possible", + "thumbExt": "Extensions that supports thumbnails", + "thumbExtDes": "Leave blank to indicate that the storage policy predefined set is used. Not valid for local, S3 storage policies." + }, + "node": { + "#": "#", + "name": "Name", + "status": "Status", + "features": "Enabled features", + "action": "Actions", + "remoteDownload": "Remote download", + "nodeDisabled": "Node is disabled.", + "nodeEnabled": "Node is enabled.", + "nodeDeleted": "Node is deleted.", + "disabled": "Disabled", + "online": "Online", + "offline": "Offline", + "addNewNode": "New node", + "refresh": "Refresh", + "enableNode": "Enable node", + "disableNode": "Disable node", + "edit": "Edit", + "delete": "Delete", + "slaveNodeDes": "You can add a server that is also running Cloudreve as a slave node. A slave node can share the load of certain asynchronous tasks (such as remote downloads) for the master. Please refer to the following wizard to deploy and configure a slave node. <0> If you have already deployed a remote node storage policy on the target server, you can skip some steps on this page and just fill in the slave secret and server address here, keep them as the same in the remote storage policy. In subsequent releases, the configuration related to the remote storage policy will be merged into here.", + "overwriteDes": "; The following settings are optional and correspond to the relevant parameters of the master node, <0>; which can be applied to the slave node via the configuration file, please adjust them according to <0>; the actual situation. Changing the following settings requires a restart of the slave node to take effect.", + "workerNumDes": "Maximum number of tasks to be executed in parallel in the task queue.", + "parallelTransferDes": "Maximum number of parallel goroutine when transferring files in the task queue", + "chunkRetriesDes": "Maximum number of retries after a failed upload of a chunk.", + "multipleMasterDes": "A slave Cloudreve instance can interface to multiple Cloudreve master nodes; simply add this slave node to all master nodes and keep the secret consistent.", + "ariaSuccess": "Successfully connected, Aria2 version: {{version}}", + "slave": "slave", + "master": "master", + "aria2Des": "Cloudreve's remote download functionality is powered by <0>Aria2. To use it, start Aria2 as the same user running Cloudreve on the target node server, and enable the RPC service in the Aria2 config file, <1>Aria2 needs to share the same file system as the {{mode}} Cloudreve process. For more information and guidelines, refer the <2>Offline Downloads section of the documentation.", + "slaveTakeOverRemoteDownload": "Do you need this node to take over remote download tasks?", + "masterTakeOverRemoteDownload": "Do you need the master to take over the remote download task?", + "routeTaskSlave": "After enabled, users' remote download requests can be scheduled to this node for processing.", + "routeTaskMaster": "After enabled, users' remote download requests can be scheduled to master node for processing.", + "enable": "Enable", + "disable": "Disable", + "slaveNodeTarget": "on slave node which shares the same file system with slave Cloudreve", + "masterNodeTarget": "which shares the same file system with Cloudreve", + "aria2ConfigDes": "Boot Aria2 {{target}} which shares the same file system with Cloudreve. When you start Aria2, you need to enable the RPC service in its config file and set the RPC Secret for future use. The following is a config example for reference.", + "enableRPCComment": "Enable RPC service", + "rpcPortComment": "RPC port to listen on", + "rpcSecretComment": "RPC secret, you can change it on your own.", + "rpcConfigDes": "It is recommended to start Aria2 before the node Cloudreve in the routine startup process, so that node Cloudreve can subscribe to event notifications to Aria2 and download status changes are handled in a more timely manner. Of course, if this process is not available, node Cloudreve will also track the task status via polling.", + "rpcServerDes": "Fill in the address of the RPC service that {{mode}} Cloudreve uses to communicate with Aria2. This can be filled in as <0>http://127.0.0.1:6800/, where the port number <1>6800 is consistent with <2>rpc-listen-port in the config file above.", + "rpcServer": "RPC Server", + "rpcServerHelpDes": "RPC server address contain full port number, e.g. http://127.0.0.1:6800/, Leave blank to indicate that the Aria2 service is not enabled.", + "rpcTokenDes": "RPC secret, consistent with <0>rpc-secret in the Aria2 configuration file; leave blank if not set.", + "aria2PathDes": "Fill in the <0>absolute path on the node that Aria2 uses as a temporary download directory. The Cloudreve process on the node needs read, write, and execute permissions on this directory.", + "aria2SettingDes": "Fill in some additional Aria2 parameter information below, as your need.", + "refreshInterval": "Status refresh interval (seconds)", + "refreshIntervalDes": "The interval at which Cloudreve requests a refresh of the task state from Aria2.", + "rpcTimeout": "RPC timeouts (seconds)", + "rpcTimeoutDes": "Maximum wait time when calling RPC services.", + "globalOptions": "Global job options", + "globalOptionsDes": "Additional settings carried when creating a download job, written in JSON encoded format, you can also write these settings in the Aria2 config file, see the Aria2 official documentation for available settings.", + "testAria2Des": "Once you have completed these steps, you can click the Test button below to test that if {{mode}} Cloudreve is communicating properly to Aria2.", + "testAria2DesSlaveAddition": "Please make sure you have performed and passed the \"Slave Communication Test\" on the previous page before performing the test.", + "testAria2": "Testing Aria2 Communication", + "aria2DocURL": "https://docs.cloudreve.org/v/en/use/aria2", + "nameNode": "Enter the name of this node:", + "loadBalancerRankDes": "Specify a load balancing weight for this node, the value is an integer. Some load balancing policies will weight the nodes based on this value.", + "loadBalancerRank": "Load balancing weight", + "nodeSaved": "Node saved successfully.", + "nodeSavedFutureAction": "If you add a new node, you will also need to manually enable the node in the node list for it to work properly.", + "backToNodeList": "Back to node list", + "communication": "Communication", + "otherSettings": "Other settings", + "finish": "Finish", + "nodeAdded": "Node added successfully.", + "nodeSavedNow": "Node saved successfully.", + "editNode": "Edit node", + "addNode": "Add node" + }, + "group": { + "#": "#", + "name": "Name", + "type": "Storage policy", + "count": "Child users", + "size": "Storage quota", + "action": "Actions", + "deleted": "Group deleted.", + "new": "New group", + "aria2FormatError": "Aria2 options format error.", + "atLeastOnePolicy": "At least one storage policy is required.", + "added": "Group added successfully.", + "saved": "Group saved successfully.", + "editGroup": "Edit {{group}}", + "nameOfGroup": "Name", + "nameOfGroupDes": "Name of the group.", + "storagePolicy": "Storage policy", + "storageDes": "Select the storage policy that this group use.", + "availablePolicies": "Available storage policies", + "availablePoliciesDes": "Select the storage policies that this group can use.", + "initialStorageQuota": "Initial storage quota", + "initialStorageQuotaDes": "Max storage can used by single user under this group.", + "downloadSpeedLimit": "Max download speed", + "downloadSpeedLimitDes": "Fill in 0 to indicate no limit. When the restriction is turned on, the maximum download speed will be limited when users under this user group download all files under the storage policy that supports the speed limit.", + "bathSourceLinkLimit": "Max size of batch source links", + "bathSourceLinkLimitDes": "For the files under the supported storage policy, the maximum number of files allowed for users to obtain source links in a single batch, fill in 0 means no batch generation of source links is allowed.", + "allowCreateShareLink": "Share files", + "allowCreateShareLinkDes": "If disabled, users cannot create sharing links.", + "allowDownloadShare": "Download shared files", + "allowDownloadShareDes": "If disabled, user cannot download shared files.", + "allowWabDAV": "WebDAV", + "allowWabDAVDes": "If disabled, users cannot connect to the storage via the WebDAV protocol", + "allowWabDAVProxy": "WebDAV Proxy", + "allowWabDAVProxyDes": "If enabled, users can configure the WebDAV to proxy traffic when downloading files", + "disableMultipleDownload": "Disable multiple download requests", + "disableMultipleDownloadDes": "Valid only for local storage policies. When disabled, users cannot use the multi-threaded download tool.", + "allowRemoteDownload": "Remote download", + "allowRemoteDownloadDes": "Whether to allow users to create remote download tasks", + "aria2Options": "Aria2 job options", + "aria2OptionsDes": "The additional parameters that this user group carries when creating remote download tasks. Options are written in JSON encoded format, you can also write these settings in the Aria2 configuration file, see the official documentation for available parameters.", + "aria2BatchSize": "Max size of batch Aria2 tasks", + "aria2BatchSizeDes": "The number of simultaneous remote download tasks allowed for the user, fill in 0 or leave blank to indicate no limit.", + "serverSideBatchDownload": "Serverside batch download", + "serverSideBatchDownloadDes": "Whether to allow users to select multiple files to use the server-side relay batch download, after disabled, users can still use the pure browser based batch download feature.", + "compressTask": "Compression/Decompression tasks", + "compressTaskDes": "Whether allow the user to create the compression/decompression task", + "compressSize": "Maximum file size to be compressed", + "compressSizeDes": "The maximum total file size of compression jobs that can be created by the user, fill in 0 to indicate no limit.", + "decompressSize": "Maximum file size to be decompressed", + "decompressSizeDes": "The maximum total file size of decompression jobs that can be created by the user, fill in 0 to indicate no limit.", + "migratePolicy": "Migrate storage policy", + "migratePolicyDes": "Whether the user creates a storage policy migration task.", + "allowSelectNode": "Allow select node", + "allowSelectNodeDes": "When enabled, user can select preferred slave node before creating remote download task. When disabled, the node will be load-balanced by system.", + "redirectedSource": "Use redirected source link", + "redirectedSourceDes": "When enabled, the source link to the file obtained by the user will be redirected by Cloudreve with a shorter link. When disabled, the source link to the file obtained by the user becomes the original URL to the file. Some policies produce non-redirected source links that do not remain persistent, see <0>Comparing Storage Policies.", + "availableNodes": "Available nodes", + "availableNodesDes": "Select the slave nodes that this group can use to create remote download tasks. Empty list means all nodes are available. Users can only select or be assigned nodes within this list by load balancer.", + "advanceDelete": "Allow advanced file deletion options", + "advanceDeleteDes": "Once enabled, users can choose whether to force deletion and whether to unlink only physical links when deleting files. These options are similar to the administration dashboard when deleting files." + }, + "user": { + "deleted": "User deleted.", + "new": "New user", + "filter": "Filter", + "selectedObjects": "{{num}} objects selected.", + "nick": "Nickname", + "email": "Email", + "group": "Group", + "status": "Status", + "usedStorage": "Used storage", + "active": "Active", + "notActivated": "Inactive", + "banned": "Blocked", + "bannedBySys": "Banned by system", + "toggleBan": "Block/Unblock", + "filterCondition": "Filter conditions", + "all": "All", + "userStatus": "User status", + "searchNickUserName": "Search nickname / username", + "apply": "Apply", + "added": "User added.", + "saved": "User saved.", + "editUser": "Edit {{nick}}", + "password": "Password", + "passwordDes": "Leave blank means no modification.", + "groupDes": "Group that the user belongs to.", + "2FASecret": "2FA Secret", + "2FASecretDes": "Secret of the 2FA authenticator, leave blank to disable 2FA." + }, + "file": { + "name": "File name", + "deleteAsync": "Delete task will be executed in background.", + "import": "Import external files", + "forceDelete": "Force delete", + "size": "Size", + "uploader": "Uploader", + "createdAt": "Created at", + "uploading": "Uploading", + "unknownUploader": "Unknown", + "uploaderID": "Uploader ID", + "searchFileName": "Search file name", + "storagePolicy": "Storage policy", + "selectTargetUser": "Select target user", + "importTaskCreated": "Import task created, you can view its status in the Tasks - General.", + "manuallyPathOnly": "The selected storage policy only supports manually inputting path.", + "selectFolder": "Select folder", + "importExternalFolder": "Import external folders", + "importExternalFolderDes": "You can import existing files and directory structures from your storage policy into Cloudreve. The import operation will not take up additional physical storage, but will still deduct the user's used storage quota as normal. The import will be halted when there is not enough quota.", + "storagePolicyDes": "Select the storage policy where the files to be imported are currently stored.", + "targetUser": "Target user", + "targetUserDes": "Select which user's file system you want to import the files to, you can search users by nickname or email.", + "srcFolderPath": "Source folder path", + "select": "Select", + "selectSrcDes": "The path of the directory to be imported on the storage side.", + "dstFolderPath": "Destination folder path", + "dstFolderPathDes": "Path in the user's file system to hold all imported files.", + "recursivelyImport": "Recursively import", + "recursivelyImportDes": "Whether to import all subdirectories under the directory recursively.", + "createImportTask": "Create import task", + "unlink": "Unlink (Keep physical file)" + }, + "share": { + "deleted": "Share deleted.", + "objectName": "Object name", + "views": "Views", + "downloads": "Downloads", + "price": "Price", + "autoExpire": "Auto expire", + "owner": "Owner", + "createdAt": "Created at", + "public": "Public", + "private": "Private", + "afterNDownloads":"After {{num}} download(s).", + "none": "None", + "srcType": "Source object type", + "folder": "Folder", + "file": "File" + }, + "task": { + "taskDeleted": "Task deleted.", + "howToConfigAria2": "How to configure remote download?", + "srcURL": "Source URL", + "node": "Distributed node", + "createdBy": "Created by", + "ready": "Ready", + "downloading": "Downloading", + "paused": "Paused", + "seeding": "Seeding", + "error": "Error", + "finished": "Finished", + "canceled": "Canceled/Stopped", + "unknown": "Unknown", + "aria2Des": "Cloudreve's remote download support a master-slave decentralized mode. You can configure multiple Cloudreve slave nodes that can be used to handle remote download tasks, spreading the pressure on the master node. Of course, you can also configure to handle remote downloads only on the master node, which is the easiest way.", + "masterAria2Des": "If you only need to enable remote downloads on the master node, <0>click here to edit the master node.", + "slaveAria2Des": "If you want to distribute remote download tasks on slave nodes, <0>click here to add and configure a new node.", + "editGroupDes": "When you add multiple nodes that can be used for remote downloads, the master node will send remote download requests to these nodes in turn for processing. You may also need to <0>go here to enable remote download permissions for the corresponding groups.", + "lastProgress": "Last progress", + "errorMsg": "Error message" + }, + "vas": { + "vas": "VAS", + "reports": "Reports", + "orders": "Orders", + "initialFiles": "Initial files", + "filterEmailProvider": "Filter email provider", + "filterEmailProviderWhitelist": "Whitelist", + "initialFilesDes": "Specify the files that the user initially owns after signups. Enter a file ID to search existing files.", + "filterEmailProviderDisabled": "Disabled", + "filterEmailProviderDes": "Filter signup email domains.", + "appLinkDes": "Will be displayed in mobile client, leave empty to hide menu item, This setting will take effect only if VOL license is valid.", + "filterEmailProviderBlacklist": "Blacklist", + "filterEmailProviderRuleDes": "Separate multiple fields with a semi-colon comma.", + "filterEmailProviderRule": "Email domain filter rules", + "qqConnectHint": "When creating the application, please fill in the callback URL: {{url}}", + "enableQQConnectDes": "Whether to allow binding QQ, use QQ to login website.", + "loginWithoutBindingDes": "After enabled, if a user logs in using QQ but does not have a registered user that is bound to it, the system will create a user for them and log them in. Users created in this way will only be able to log in using QQ in the future.", + "qqConnect": "QQ Connect", + "enableQQConnect": "Enable QQ Connect", + "appidDes": "The APP ID obtained from the application management page.", + "appForum": "User forum URL", + "loginWithoutBinding": "Login without registration", + "appKeyDes": "The APP KEY obtained from the application management page.", + "appid": "APP ID", + "overuseReminderDes": "Reminder email template sent to users after their capacity exceeds the limit due to expired VAS.", + "storagePack": "Storage packs", + "giftCodes": "Gift codes", + "appKey": "APP KEY", + "overuseReminder": "Overuse reminder", + "enable": "Enable", + "appFeedback": "Feedback URL", + "vasSetting": "VAS settings", + "appIDDes": "APPID of payment application.", + "purchasableGroups": "Memberships", + "rsaPrivateDes": "The RSA2 (SHA256) private key for the payment application, typically generated by you. For details, refer to <0>Generating RSA Keys.", + "alipayPublicKeyDes": "Provided by Alipay, available in Application Management - Application Information - API Signing Method.", + "applicationID": "Application ID", + "alipay": "Alipay", + "appID": "App- ID", + "merchantID": "Merchant number", + "customPaymentEndpointDes":"URL to be requested when creating a payment order.", + "rsaPrivate": "RSA application private key", + "apiV3Secret": "API v3 secret", + "alipayPublicKey": "Alipay public key", + "mcCertificateSerial": "Merchant certificate serial number", + "mcAPISecret": "Merchant API Secrey", + "payjs": "PAYJS", + "wechatPay": "WeChat Pay", + "applicationIDDes": "Public number or mobile application appid applied by merchants.", + "mcNumber": "Merchant number", + "customPaymentEndpoint":"Payment API URL", + "merchantIDDes": "The merchant number generated and issued by WeChat Pay.", + "communicationSecret": "Communication key", + "apiV3SecretDes": "The merchant needs to set the secret in [Merchant Platform] - [API Security] before the request WeChat Pay. The length of the key is 32 bytes.", + "banBufferPeriod": "Suspend buffer period (seconds)", + "allowSellShares": "Allow pricing for shares", + "creditPriceRatio": "Credit arrival rate (%)", + "mcCertificateSerialDes": "Navigate to [API Security] - [API Certificate] - [View Certificate] to view the merchant API certificate serial number.", + "mcAPISecretDes": "Content of the secret file apiclient_key.pem.", + "creditPrice": "Credit price (penny)", + "customPaymentSecretDes": "Secret key for signing payment requests.", + "payjsWarning": "This service is provided by <0>PAYJS, a third-party platform, and any disputes arising from it are not the responsibility of Cloudreve developers.", + "add": "Add", + "mcNumberDes": "Available in the PAYJS admin panel home page.", + "price": "Price", + "size": "Size", + "orCredits": " Or {{num}} credits", + "otherSettings": "Other Settings", + "banBufferPeriodDes": "The maximum length of time that a user can maintain the capacity overage status, beyond which the user will be suspend by the system.", + "yes": "Yes", + "customPaymentNameDes": "Name of the payment method used to display to the user.", + "allowSellSharesDes": "Once enabled users can set a credit price for sharing and credit will be deducted for downloading.", + "productName": "Product name", + "creditPriceRatioDes": "The rate of credits actually arriving to the sharer for the purchase of a share with a set price for download.", + "code": "Code", + "invalidProduct": "Invalid product", + "notUsed": "Not used", + "creditPriceDes": "Price when recharging credits", + "allowReportShare": "Allow report sharing", + "allowReportShareDes": "When enabled, any user can report sharing and there is a risk of the database filling up.", + "name": "Name", + "addStoragePack": "Add storage pack", + "customPaymentName": "Payment method name", + "duration": "Duration", + "productNameDes": "Product display name", + "actions": "Actions", + "durationDay": "Duration (day)", + "priceYuan": "Price (Yuan)", + "priceCredits": "Price (Credits)", + "highlight": "Highlight", + "no": "No", + "editMembership": "Edit membership", + "customPaymentDocumentLink":"https://docs.cloudreve.org/v/en/use/pro/pay", + "qyt": "Qyt.", + "group": "Group", + "status": "Status", + "durationGroupDes": "The validity of the purchase time of the user group unit upgraded after the purchase.", + "productDescription": "Product description (Once per line)", + "highlightDes": "After enabled, it will be highlighted on the product selection page.", + "used": "Used", + "generatingResult": "Result", + "numberOfCodes": "Number of codes", + "customPaymentDes":"Bridge to other third party payment platforms by implementing Cloudreve compatible payment interfaces, please refer to <0>Official Documentation for details.", + "editStoragePack": "Edit storage pack", + "linkedProduct": "Linked product", + "packSizeDes": "Size of storage pack", + "productQytDes": "For credit products, this is the number of points and other products are multiples of durations.", + "freeDownloadDes": "After enabled, user can download paid shares for free.", + "markSuccessful": "Marked successfully.", + "durationDayDes": "Valid duration of each storage pack.", + "packPriceDes": "Price of storage pack.", + "reportedContent": "Reported content", + "customPayment": "Custom payment provider", + "priceCreditsDes": "The price when using credits to buy, fill in 0 means you can't use credits to buy.", + "description": "Description", + "addMembership": "Add membership", + "invalid": "[Invalid]", + "orderDeleted": "Order deleted.", + "product": "Product", + "groupDes": "User groups upgraded after purchase.", + "groupPriceDes": "Membership price", + "paidBy": "Paid with", + "showAppPromotion": "Show app promotion page", + "showAppPromotionDes": "After enabled, user can see the guidance page for mobile application in \"Connect & Mount\" page.", + "productDescriptionDes": "Description of the product displayed on the purchase page.", + "unpaid": "Unpaid", + "generateGiftCode": "Generate gift codes", + "shareLink": "Shared link", + "iosVol": "iOS client volume license (VOL)", + "syncLicense": "Sync License", + "numberOfCodesDes": "Number of gift codes to generate.", + "productQyt": "Product qyt.", + "freeDownload": "Download shared files for free", + "credits": "Credits", + "markAsResolved": "Mark as resolved", + "reason": "Reason", + "reportTime": "Reported at", + "deleteShare": "Delete share link", + "orderName": "Name", + "orderNumber": "Order No.", + "orderOwner": "Created by", + "paid": "Paid", + "volPurchase": "The client VOL license needs to be purchased separately from the <0>License Management Dashboard. The VOL license allows your users to connect to your site using the <1>Cloudreve iOS for free, without the need for users to pay for a subscription for the iOS app itself. After purchasing a license, please click \"Sync License\" below.", + "mobileApp": "Mobile application", + "volSynced": "VOL synced." + } +} diff --git a/public/locales/zh-CN/application.json b/public/locales/zh-CN/application.json new file mode 100644 index 0000000..37448eb --- /dev/null +++ b/public/locales/zh-CN/application.json @@ -0,0 +1,591 @@ +{ + "login": { + "email": "电子邮箱", + "password": "密码", + "captcha": "验证码", + "captchaError": "验证码加载失败: {{message}}", + "signIn": "登录", + "signUp": "注册", + "signUpAccount": "注册账号", + "useFIDO2": "使用外部验证器登录", + "usePassword": "使用密码登录", + "forgetPassword": "忘记密码", + "2FA": "二步验证", + "input2FACode": "请输入 6 位二步验证代码", + "passwordNotMatch": "两次密码输入不一致", + "findMyPassword": "找回密码", + "passwordReset": "密码已重设", + "newPassword": "新密码", + "repeatNewPassword": "重复新密码", + "repeatPassword": "重复密码", + "resetPassword": "重设密码", + "backToSingIn": "返回登录", + "sendMeAnEmail": "发送密码重置邮件", + "resetEmailSent": "密码重置邮件已发送,请注意查收", + "browserNotSupport": "当前浏览器或环境不支持", + "success": "登录成功", + "signUpSuccess": "注册成功", + "activateSuccess": "激活成功", + "accountActivated": "您的账号已被成功激活。", + "title": "登录 {{title}}", + "sinUpTitle": "注册 {{title}}", + "activateTitle": "邮件激活", + "activateDescription": "一封激活邮件已经发送至您的邮箱,请访问邮件中的链接以继续完成注册。", + "continue": "下一步", + "logout": "退出登录", + "loggedOut": "您已退出登录", + "clickToRefresh": "点击刷新验证码" + }, + "navbar": { + "myFiles": "我的文件", + "myShare": "我的分享", + "remoteDownload": "离线下载", + "connect": "连接与挂载", + "taskQueue": "任务队列", + "setting": "个人设置", + "videos": "视频", + "photos": "图片", + "music": "音乐", + "documents": "文档", + "addATag": "添加标签...", + "addTagDialog": { + "selectFolder": "选择目录", + "fileSelector": "文件分类", + "folderLink": "目录快捷方式", + "tagName": "标签名", + "matchPattern": "文件名匹配规则", + "matchPatternDescription": "你可以使用 <0>* 作为通配符。比如 <1>*.png 表示匹配 png 格式图像。多行规则间会以 “或” 的关系进行运算。", + "icon": "图标:", + "color": "颜色:", + "folderPath": "目录路径" + }, + "storage": "存储空间", + "storageDetail": "已使用 {{used}}, 共 {{total}}", + "notLoginIn": "未登录", + "visitor": "游客", + "objectsSelected": "{{num}} 个对象", + "searchPlaceholder": "搜索...", + "searchInFiles": "在我的文件中搜索 <0>{{name}}", + "searchInFolders": "在当前目录中搜索 <0>{{name}}", + "searchInShares": "在全站分享中搜索 <0>{{name}}", + "backToHomepage": "返回主页", + "toDarkMode": "切换到深色模式", + "toLightMode": "切换到浅色模式", + "myProfile": "个人主页", + "dashboard": "管理面板", + "exceedQuota": "您的已用容量已超过容量配额,请尽快删除多余文件" + }, + "fileManager": { + "open": "打开", + "openParentFolder": "打开所在目录", + "download": "下载", + "batchDownload": "打包下载", + "share": "分享", + "rename": "重命名", + "move": "移动", + "delete": "删除", + "moreActions": "更多操作", + "refresh": "刷新", + "compress": "压缩", + "newFolder": "创建文件夹", + "newFile": "创建文件", + "showFullPath": "显示路径", + "listView": "列表", + "gridViewSmall": "小图标", + "gridViewLarge": "大图标", + "paginationSize": "分页大小", + "paginationOption": "{{option}} / 页", + "noPagination": "不分页", + "sortMethod": "排序方式", + "sortMethods": { + "A-Z": "A-Z", + "Z-A": "Z-A", + "oldestUploaded": "最早上传", + "newestUploaded": "最新上传", + "oldestModified": "最早修改", + "newestModified": "最新修改", + "smallest": "最小", + "largest": "最大" + }, + "shareCreateBy": "由 {{nick}} 创建", + "name": "名称", + "size": "大小", + "lastModified": "修改日期", + "currentFolder": "当前目录", + "backToParentFolder": "上级目录", + "folders": "文件夹", + "files": "文件", + "listError": ":( 请求时出现错误", + "dropFileHere": "拖拽文件至此", + "orClickUploadButton": "或点击右下方“上传文件”按钮添加文件", + "nothingFound": "什么都没有找到", + "uploadFiles": "上传文件", + "uploadFolder": "上传目录", + "newRemoteDownloads": "离线下载", + "enter": "进入", + "getSourceLink": "获取外链", + "getSourceLinkInBatch": "批量获取外链", + "createRemoteDownloadForTorrent": "创建离线下载任务", + "decompress": "解压缩", + "createShareLink": "创建分享链接", + "viewDetails": "详细信息", + "copy": "复制", + "bytes": " ({{bytes}} 字节)", + "storagePolicy": "存储策略", + "inheritedFromParent": "跟随父目录", + "childFolders": "包含目录", + "childFiles": "包含文件", + "childCount": "{{num}} 个", + "parentFolder": "所在目录", + "rootFolder": "根目录", + "modifiedAt": "修改于", + "createdAt": "创建于", + "statisticAt": "统计于 <1>", + "musicPlayer": "音频播放", + "closeAndStop": "退出播放", + "playInBackground": "后台播放", + "copyTo": "复制到", + "copyToDst": "复制到 <0>{{dst}}", + "errorReadFileContent": "无法读取文件内容:{{msg}}", + "wordWrap": "自动换行", + "pdfLoadingError": "PDF 加载失败:{{msg}}", + "subtitleSwitchTo": "字幕切换到:{{subtitle}}", + "noSubtitleAvailable": "视频目录下没有可用字幕文件 (支持:ASS/SRT/VTT)", + "subtitle": "选择字幕", + "playlist": "播放列表", + "openInExternalPlayer": "用外部播放器打开", + "searchResult": "搜索结果", + "preparingBathDownload": "正在准备打包下载...", + "preparingDownload": "获取下载地址...", + "browserBatchDownload": "浏览器端打包", + "browserBatchDownloadDescription": "由浏览器实时下载并打包,并非所有环境都支持。", + "serverBatchDownload": "服务端中转打包", + "serverBatchDownloadDescription": "由服务端中转打包并实时发送到客户端下载。", + "selectArchiveMethod": "选择打包下载方式", + "batchDownloadStarted": "打包下载已开始,请不要关闭此标签页", + "batchDownloadError": "打包遇到错误:{{msg}}", + "userDenied": "用户拒绝", + "directoryDownloadReplace": "替换对象", + "directoryDownloadReplaceDescription": "将会替换 {{duplicates}} 等共 {{num}} 个对象。", + "directoryDownloadSkip": "跳过对象", + "directoryDownloadSkipDescription": "将会跳过 {{duplicates}} 等共 {{num}} 个对象。", + "selectDirectoryDuplicationMethod": "重复对象处理方式", + "directoryDownloadStarted": "下载已开始,请不要关闭此标签页", + "directoryDownloadFinished": "下载完成,无失败对象", + "directoryDownloadFinishedWithError": "下载完成, 失败 {{failed}} 个对象", + "directoryDownloadPermissionError": "无权限操作,请允许读写本地文件" + }, + "modals": { + "processing": "处理中...", + "duplicatedObjectName": "新名称与已有文件重复", + "duplicatedFolderName": "文件夹名称重复", + "taskCreated": "任务已创建", + "taskCreateFailed": "{{failed}} 个任务创建失败:{{details}}", + "linkCopied": "链接已复制", + "getSourceLinkTitle": "获取文件外链", + "sourceLink": "文件外链", + "folderName": "文件夹名称", + "create": "创建", + "fileName": "文件名称", + "renameDescription": "输入 <0>{{name}} 的新名称:", + "newName": "新名称", + "moveToTitle": "移动至", + "moveToDescription": "移动至 <0>{{name}}", + "saveToTitle": "保存至", + "saveToTitleDescription": "保存至 <0>{{name}}", + "deleteTitle": "删除对象", + "deleteOneDescription": "确定要删除 <0>{{name}} 吗?", + "deleteMultipleDescription": "确定要删除这 {{num}} 个对象吗?", + "newRemoteDownloadTitle": "新建离线下载任务", + "remoteDownloadURL": "下载链接", + "remoteDownloadURLDescription": "输入文件下载地址,一行一个,支持 HTTP(s) / FTP / 磁力链", + "remoteDownloadDst": "下载至", + "remoteDownloadNode": "下载节点", + "remoteDownloadNodeAuto": "自动分配", + "createTask": "创建任务", + "downloadTo": "下载至 <0>{{name}}", + "decompressTo": "解压缩至", + "decompressToDst": "解压缩至 <0>{{name}}", + "defaultEncoding": "缺省", + "chineseMajorEncoding": "简体中文常见编码", + "selectEncoding": "选择 ZIP 文件特殊字符编码", + "noEncodingSelected": "未选择编码方式", + "listingFiles": "列取文件中...", + "listingFileError": "列取文件时出错:{{message}}", + "generatingSourceLinks": "生成外链中...", + "noFileCanGenerateSourceLink": "没有可以生成外链的文件", + "sourceBatchSizeExceeded": "当前用户组最大可同时为 {{limit}} 个文件生成外链", + "zipFileName": "ZIP 文件名", + "shareLinkShareContent": "我向你分享了:{{name}} 链接:{{link}}", + "shareLinkPasswordInfo": " 密码: {{password}}", + "createShareLink": "创建分享链接", + "usePasswordProtection": "使用密码保护", + "sharePassword": "分享密码", + "randomlyGenerate": "随机生成", + "expireAutomatically": "自动过期", + "downloadLimitOptions": "{{num}} 次下载", + "or": "或者", + "5minutes": "5 分钟", + "1hour": "1 小时", + "1day": "1 天", + "7days": "7 天", + "30days": "30 天", + "custom": "自定义", + "seconds": "秒", + "downloads": "次下载", + "downloadSuffix": "后过期", + "allowPreview": "允许预览", + "allowPreviewDescription": "是否允许在分享页面预览文件内容", + "shareLink": "分享链接", + "sendLink": "发送链接", + "directoryDownloadReplaceNotifiction": "已覆盖 {{name}}", + "directoryDownloadSkipNotifiction": "已跳过 {{name}}", + "directoryDownloadTitle": "下载", + "directoryDownloadStarted": "开始下载 {{name}}", + "directoryDownloadFinished": "下载完成", + "directoryDownloadError": "遇到错误:{{msg}}", + "directoryDownloadErrorNotification": "下载 {{name}} 遇到错误:{{msg}}", + "directoryDownloadAutoscroll": "自动滚动", + "directoryDownloadCancelled": "已取消下载", + "advanceOptions": "高级选项", + "forceDelete": "强制删除文件", + "forceDeleteDes": "强制删除文件记录,无论物理文件是否被成功删除", + "unlinkOnly": "仅解除链接", + "unlinkOnlyDes": "仅删除文件记录,物理文件不会被删除" + }, + "uploader": { + "fileNotMatchError": "所选择文件与原始文件不符", + "unknownError": "出现未知错误:{{msg}}", + "taskListEmpty": "没有上传任务", + "hideTaskList": "隐藏列表", + "uploadTasks": "上传队列", + "moreActions": "更多操作", + "addNewFiles": "添加新文件", + "toggleTaskList": "展开/折叠队列", + "pendingInQueue": "排队中...", + "preparing": "准备中...", + "processing": "处理中...", + "progressDescription": "已上传 {{uploaded}} , 共 {{total}} - {{percentage}}%", + "progressDescriptionFull": "{{speed}} 已上传 {{uploaded}} , 共 {{total}} - {{percentage}}%", + "progressDescriptionPlaceHolder": "已上传 - ", + "uploadedTo": "已上传至 ", + "rootFolder": "根目录", + "unknownStatus": "未知", + "resumed": "断点续传", + "resumable": "可恢复进度", + "retry": "重试", + "deleteTask": "删除任务记录", + "cancelAndDelete": "取消并删除", + "selectAndResume": "选取同样文件并恢复上传", + "fileName": "文件名:", + "fileSize": "文件大小:", + "sessionExpiredIn": "<0> 过期", + "chunkDescription": "({{total}} 个分片, 每个分片 {{size}})", + "noChunks": "(无分片)", + "destination": "存储路径:", + "uploadSession": "上传会话:", + "errorDetails": "错误信息:", + "uploadSessionCleaned": "上传会话已清除", + "hideCompletedTooltip": "列表中不显示已完成、失败、被取消的任务", + "hideCompleted": "隐藏已完成任务", + "addTimeAscTooltip": "最先添加的任务排在最前", + "addTimeAsc": "最先添加靠前", + "addTimeDescTooltip": "最后添加的任务排在最前", + "addTimeDesc": "最后添加靠前", + "showInstantSpeedTooltip": "单个任务上传速度展示为瞬时速度", + "showInstantSpeed": "瞬时速度", + "showAvgSpeedTooltip": "单个任务上传速度展示为平均速度", + "showAvgSpeed": "平均速度", + "cleanAllSessionTooltip": "清空服务端所有未完成的上传会话", + "cleanAllSession": "清空所有上传会话", + "cleanCompletedTooltip": "清除列表中已完成、失败、被取消的任务", + "cleanCompleted": "清除已完成任务", + "retryFailedTasks": "重试所有失败任务", + "retryFailedTasksTooltip": "重试队列中所有已失败的任务", + "setConcurrentTooltip": "设定同时进行的任务数量", + "setConcurrent": "设置并行数量", + "sizeExceedLimitError": "文件大小超出存储策略限制(最大:{{max}})", + "suffixNotAllowedError": "存储策略不支持上传此扩展名的文件(当前支持:{{supported}})", + "createUploadSessionError": "无法创建上传会话", + "deleteUploadSessionError": "无法删除上传会话", + "requestError": "请求失败: {{msg}} ({{url}})", + "chunkUploadError": "分片 [{{index}}] 上传失败", + "conflictError": "同名文件的上传任务已经在处理中", + "chunkUploadErrorWithMsg": "分片上传失败: {{msg}}", + "chunkUploadErrorWithRetryAfter": "(请在 {{retryAfter}} 秒后重试)", + "emptyFileError": "暂不支持上传空文件至 OneDrive,请通过创建文件按钮创建空文件", + "finishUploadError": "无法完成文件上传", + "finishUploadErrorWithMsg": "无法完成文件上传: {{msg}}", + "ossFinishUploadError": "无法完成文件上传: {{msg}} ({{code}})", + "cosUploadFailed": "上传失败: {{msg}} ({{code}})", + "upyunUploadFailed": "上传失败: {{msg}}", + "parseResponseError": "无法解析响应: {{msg}} ({{content}})", + "concurrentTaskNumber": "同时上传的任务数量", + "dropFileHere": "松开鼠标开始上传" + }, + "share": { + "expireInXDays": "{{num}} 天后到期", + "days":"{{count}} day", + "days_other":"{{count}} days", + "expireInXHours": "{{num}} 小时后到期", + "hours":"an hour", + "hours_other":"{{count}} hours", + "createdBy": "此分享由 <0>{{nick}} 创建", + "sharedBy": "<0>{{nick}} 向您分享了 {{num}} 个文件", + "files":"1 file", + "files_other":"{{count}} files", + "statistics": "{{views}} 次浏览 • {{downloads}} 次下载 • {{time}}", + "views":"{{count}} view", + "views_other":"{{count}} views", + "downloads":"{{count}} download", + "downloads_other":"{{count}} downloads", + "privateShareTitle": "{{nick}} 的加密分享", + "enterPassword": "输入分享密码", + "continue": "继续", + "shareCanceled": "分享已取消", + "listLoadingError": "加载失败", + "sharedFiles": "我的分享", + "createdAtDesc": "创建日期由晚到早", + "createdAtAsc": "创建日期由早到晚", + "downloadsDesc": "下载次数由大到小", + "downloadsAsc": "下载次数由小到大", + "viewsDesc": "浏览次数由大到小", + "viewsAsc": "浏览次数由小到大", + "noRecords": "没有分享记录.", + "sourceNotFound": "[原始对象不存在]", + "expired": "已失效", + "changeToPublic": "变更为公开分享", + "changeToPrivate": "变更为私密分享", + "viewPassword": "查看密码", + "disablePreview": "禁止预览", + "enablePreview": "允许预览", + "cancelShare": "取消分享", + "sharePassword": "分享密码", + "readmeError": "无法读取 README 内容:{{msg}}", + "enterKeywords": "请输入搜索关键词", + "searchResult": "搜索结果", + "sharedAt": "分享于 <0>", + "pleaseLogin": "请先登录", + "cannotShare": "此文件无法预览", + "preview": "预览", + "incorrectPassword": "密码不正确", + "shareNotExist": "分享不存在或已过期" + }, + "download": { + "failedToLoad": "加载失败", + "active": "进行中", + "finished": "已完成", + "activeEmpty": "没有下载中的任务", + "finishedEmpty": "没有已完成的任务", + "loadMore": "加载更多", + "taskFileDeleted": "文件已删除", + "unknownTaskName": "[未知]", + "taskCanceled": "任务已取消,状态会在稍后更新", + "operationSubmitted": "操作成功,状态会在稍后更新", + "deleteThisFile": "删除此文件", + "openDstFolder": "打开存放目录", + "selectDownloadingFile": "选择要下载的文件", + "cancelTask": "取消任务", + "updatedAt": "更新于:", + "uploaded": "上传大小:", + "uploadSpeed": "上传速度:", + "InfoHash": "InfoHash:", + "seederCount": "做种者:", + "seeding": "做种中:", + "downloadNode": "节点:", + "isSeeding": "是", + "notSeeding": "否", + "chunkSize": "分片大小:", + "chunkNumbers": "分片数量:", + "taskDeleted": "删除成功", + "transferFailed": "文件转存失败", + "downloadFailed": "下载出错:{{msg}}", + "canceledStatus": "已取消", + "finishedStatus": "已完成", + "pending": "已完成,转存排队中", + "transferring": "已完成,转存中", + "deleteRecord": "删除记录", + "createdAt": "创建日期:" + }, + "setting": { + "avatarUpdated": "头像已更新,刷新后生效", + "nickChanged": "昵称已更改,刷新后生效", + "settingSaved": "设置已保存", + "themeColorChanged": "主题配色已更换", + "profile": "个人资料", + "avatar": "头像", + "uid": "UID", + "nickname": "昵称", + "group": "用户组", + "regTime": "注册时间", + "privacyAndSecurity": "安全隐私", + "profilePage": "个人主页", + "accountPassword": "登录密码", + "2fa": "二步验证", + "enabled": "已开启", + "disabled": "未开启", + "appearance": "个性化", + "themeColor": "主题配色", + "darkMode": "黑暗模式", + "syncWithSystem": "跟随系统", + "fileList": "文件列表", + "timeZone": "时区", + "webdavServer": "连接地址", + "userName": "用户名", + "manageAccount": "账号管理", + "uploadImage": "从文件上传", + "useGravatar": "使用 Gravatar 头像 ", + "changeNick": "修改昵称", + "originalPassword": "原密码", + "enable2FA": "启用二步验证", + "disable2FA": "关闭二步验证", + "2faDescription": "请使用任意二步验证APP或者支持二步验证的密码管理软件扫描左侧二维码添加本站。扫描完成后请填写二步验证APP给出的6位验证码以开启二步验证。", + "inputCurrent2FACode": "请验证当前二步验证代码。", + "timeZoneCode": "IANA 时区名称标识", + "authenticatorRemoved": "凭证已删除", + "authenticatorAdded": "验证器已添加", + "browserNotSupported": "当前浏览器或环境不支持", + "removedAuthenticator": "删除凭证", + "removedAuthenticatorConfirm": "确定要吊销这个凭证吗?", + "addNewAuthenticator": "添加新验证器", + "hardwareAuthenticator": "外部认证器", + "copied": "已复制到剪切板", + "pleaseManuallyCopy": "当前浏览器不支持,请手动复制", + "webdavAccounts": "WebDAV 账号管理", + "webdavHint": "WebDAV的地址为:{{url}};登录用户名统一为:{{name}} ;密码为所创建账号的密码。", + "annotation": "备注名", + "rootFolder": "相对根目录", + "createdAt": "创建日期", + "action": "操作", + "readonlyOn": "开启只读", + "readonlyOff": "关闭只读", + "useProxyOn": "开启反代", + "useProxyOff": "关闭反代", + "delete": "删除", + "listEmpty": "没有记录", + "createNewAccount": "创建新账号", + "taskType": "任务类型", + "taskStatus": "状态", + "lastProgress": "最后进度", + "errorDetails": "错误信息", + "queueing": "排队中", + "processing": "处理中", + "failed": "失败", + "canceled": "取消", + "finished": "已完成", + "fileTransfer": "文件中转", + "fileRecycle": "文件回收", + "importFiles": "导入外部目录", + "transferProgress": "已完成 {{num}} 个文件", + "waiting": "等待中", + "compressing": "压缩中", + "decompressing": "解压缩中", + "downloading": "下载中", + "transferring": "转存中", + "indexing": "索引中", + "listing": "插入中", + "allShares": "全部分享", + "trendingShares": "热门分享", + "totalShares": "分享总数", + "fileName": "文件名", + "shareDate": "分享日期", + "downloadNumber": "下载次数", + "viewNumber": "浏览次数", + "language": "语言", + "iOSApp": "iOS 客户端", + "connectByiOS": "通过 iOS 设备连接到 <0>{{title}}", + "downloadOurApp": "下载并安装我们的 iOS 应用:", + "fillInEndpoint": "使用我们的 iOS 应用扫描下方二维码(其他扫码应用无效):", + "loginApp": "完成绑定,你可以开始使用 iOS 客户端了。如果扫码绑定遇到问题,你也可以尝试手动输入用户名和密码登录。", + "aboutCloudreve": "关于 Cloudreve", + "githubRepo": "GitHub 仓库", + "homepage": "主页" + }, + "vas": { + "loginWithQQ": "使用 QQ 登录", + "quota": "容量配额", + "exceedQuota": "您的已用容量已超过容量配额,请尽快删除多余文件或购买容量", + "extendStorage": "扩容", + "folderPolicySwitched": "目录存储策略已切换", + "switchFolderPolicy": "切换目录存储策略", + "setPolicyForFolder": "为当前目录设置存储策略: ", + "manageMount": "管理绑定", + "saveToMyFiles": "保存到我的文件", + "report": "举报", + "migrateStoragePolicy": "转移存储策略", + "fileSaved": "文件已保存", + "sharePurchaseTitle": "确定要支付 {{score}} 积分 购买此分享?", + "sharePurchaseDescription": "购买后,您可以自由预览、下载此分享的所有内容,一定期限内不会重复扣费。如果您已购买,请忽略此提示。", + "payToDownload": "付积分下载", + "creditToBePaid": "每人次下载需支付的积分", + "creditGainPredict": "预计每人次下载可到账 {{num}} 积分", + "creditPrice": " ({{num}} 积分)", + "creditFree": " (免积分)", + "cancelSubscription": "解约成功,更改会在数分钟后生效", + "qqUnlinked": "已解除与QQ账户的关联", + "groupExpire": " <0> 过期", + "manuallyCancelSubscription": "手动解约当前用户组", + "qqAccount": "QQ账号", + "connect": "绑定", + "unlink": "解除绑定", + "credits": "积分", + "cancelSubscriptionTitle": "解约用户组", + "cancelSubscriptionWarning": "将要退回到初始用户组,且所支付金额无法退还,确定要继续吗?", + "mountPolicy": "存储策略绑定", + "mountDescription": "为目录绑定存储策略后,上传至此目录或此目录下的子目录的新文件将会使用绑定的存储策略存储。复制、移动到此目录不会应用绑定的存储策略;多个父目录指定存储策略时将会选择最接近的父目录的存储策略。", + "mountNewFolder": "绑定新目录", + "nsfw": "色情信息", + "malware": "包含病毒", + "copyright": "侵权", + "inappropriateStatements": "不恰当的言论", + "other": "其他", + "groupBaseQuota": "用户组基础容量", + "validPackQuota": "有效容量包附加容量", + "used": "已使用容量", + "total": "总容量", + "validStoragePack": "可用容量包", + "buyStoragePack": "购买容量包", + "useGiftCode": "使用激活码兑换", + "packName": "容量包名称", + "activationDate": "激活日期", + "validDuration": "有效期", + "expiredAt": "过期日期", + "days": "{{num}} 天", + "pleaseInputGiftCode": "请输入激活码", + "pleaseSelectAStoragePack": "请先选择一个容量包", + "selectPaymentMethod": "选择支付方式:", + "noAvailableMethod": "无可用支付方式", + "alipay": "支付宝扫码", + "wechatPay": "微信扫码", + "payByCredits": "积分支付", + "purchaseDuration": "购买时长倍数:", + "creditsNum": "充值积分数量:", + "store": "商店", + "storagePacks": "容量包", + "membership": "会员", + "buyCredits": "积分充值", + "subtotal": "当前费用:", + "creditsTotalNum": "{{num}} 积分", + "checkoutNow": "立即购买", + "recommended": "推荐", + "enterGiftCode": "输入激活码", + "qrcodeAlipay": "请使用 支付宝 扫描下方二维码完成付款,付款完成后本页面会自动刷新。", + "qrcodeWechat": "请使用 微信 扫描下方二维码完成付款,付款完成后本页面会自动刷新。", + "qrcodeCustom": "请扫描下方二维码完成付款,付款完成后本页面会自动刷新。", + "paymentCompleted": "支付完成", + "productDelivered": "您所购买的商品已到账。", + "confirmRedeem": "确认兑换", + "productName": "商品名称:", + "qyt": "数量:", + "duration": "时长:", + "subscribe": "购买用户组", + "selected": "已选:", + "paymentQrcode": "付款二维码", + "validDurationDays": "有效期:{{num}} 天", + "reportSuccessful": "举报成功", + "additionalDescription": "补充描述", + "announcement": "公告", + "dontShowAgain": "不再显示", + "openPaymentLink": "直接打开支付链接" + } +} diff --git a/public/locales/zh-CN/common.json b/public/locales/zh-CN/common.json new file mode 100644 index 0000000..354733f --- /dev/null +++ b/public/locales/zh-CN/common.json @@ -0,0 +1,90 @@ +{ + "pageNotFound": "页面不存在", + "unknownError": "未知错误", + "errLoadingSiteConfig": "无法加载站点配置:", + "newVersionRefresh": "当前页面有新版本可用,准备刷新。", + "errorDetails": "错误详情", + "renderError": "页面渲染出现错误,请尝试刷新此页面。", + "ok": "确定", + "cancel": "取消", + "select": "选择", + "copyToClipboard": "复制", + "close": "关闭", + "intlDateTime": "{{val, datetime}}", + "timeAgoLocaleCode": "zh_CN", + "forEditorLocaleCode": "zh-CN", + "artPlayerLocaleCode": "zh-cn", + "errors": { + "401": "请先登录", + "403": "此操作被禁止", + "404": "资源不存在", + "409": "发生冲突 ({{message}})", + "40001": "输入参数有误 ({{message}})", + "40002": "上传失败", + "40003": "目录创建失败", + "40004": "同名对象已存在", + "40005": "签名过期", + "40006": "不支持的存储策略类型", + "40007": "当前用户组无法进行此操作", + "40011": "上传会话不存在或已过期", + "40012": "分片序号无效 ({{message}})", + "40013": "正文长度无效 ({{message}})", + "40014": "超出批量获取外链限制", + "40015": "超出最大离线下载任务数量限制", + "40016": "路径不存在", + "40017": "该账号已被封禁", + "40018": "该账号未激活", + "40019": "此功能未启用", + "40020": "用户邮箱或密码错误", + "40021": "用户不存在", + "40022": "验证代码不正确", + "40023": "登录会话不存在", + "40024": "无法初始化 WebAuthn", + "40025": "验证失败", + "40026": "验证码错误", + "40027": "验证失败,请刷新网页重试", + "40028": "邮件发送失败", + "40029": "无效的链接", + "40030": "此链接已过期", + "40032": "此邮箱已被使用", + "40033": "用户未激活,已重新发送激活邮件", + "40034": "该用户无法被激活", + "40035": "存储策略不存在", + "40039": "用户组不存在", + "40044": "文件不存在", + "40045": "无法列取目录下的对象", + "40047": "无法初始化文件系统", + "40048": "创建任务出错", + "40049": "文件大小超出限制", + "40050": "文件类型不允许", + "40051": "容量空间不足", + "40052": "对象名非法,请移除特殊字符", + "40053": "不支持对根目录执行此操作", + "40054": "话当前目录下已经有同名文件正在上传中,请尝试清空上传会话", + "40055": "文件信息不一致", + "40056": "不支持该格式的压缩文件", + "40057": "可用存储策略发生变化,请刷新文件列表并重新添加此任务", + "40058": "分享不存在或已过期", + "40069": "密码不正确", + "40070": "此分享无法预览", + "40071": "签名无效", + "50001": "数据库操作失败 ({{message}})", + "50002": "URL 或请求签名失败 ({{message}})", + "50004": "I/O 操作失败 ({{message}})", + "50005": "內部错误 ({{message}})", + "50010": "目标节点不可用", + "50011": "文件元信息查询失败" + }, + "vasErrors": { + "40031": "此 Email 服务提供商不可用,请更换其他 Email 地址", + "40059": "不能转存自己的分享", + "40062": "积分不足", + "40063": "当前用户组仍未过期,请前往个人设置手动解约后继续", + "40064": "您当前已处于此用户组中", + "40065": "兑换码无效", + "40066": "您已绑定了QQ账号,请先解除绑定", + "40067": "此QQ账号已被绑定其他账号", + "40068": "此QQ号未绑定任何账号", + "40072": "管理员无法升级至其他用户组" + } +} \ No newline at end of file diff --git a/public/locales/zh-CN/dashboard.json b/public/locales/zh-CN/dashboard.json new file mode 100644 index 0000000..63ea172 --- /dev/null +++ b/public/locales/zh-CN/dashboard.json @@ -0,0 +1,986 @@ +{ + "errors":{ + "40036": "默认存储策略无法删除", + "40037": "有 {{message}} 个文件仍在使用此存储策略,请先删除这些文件", + "40038": "有 {{message}} 个用户组绑定了此存储策略,请先解除绑定", + "40040": "无法对系统用户组执行此操作", + "40041": "有 {{message}} 位用户仍属于此用户组,请先删除这些用户或者更改用户组", + "40042": "无法更改初始用户的用户组", + "40043": "无法对初始用户执行此操作", + "40046": "无法对主机节点执行此操作", + "40060": "从机无法向主机发送回调请求,请检查主机端 参数设置 - 站点信息 - 站点URL设置,并确保从机可以连接到此地址 ({{message}})", + "40061": "Cloudreve 版本不一致 ({{message}})", + "50008": "设置项更新失败 ({{message}})", + "50009": "跨域策略添加失败" + }, + "nav": { + "summary": "面板首页", + "settings": "参数设置", + "basicSetting": "站点信息", + "publicAccess": "注册与登录", + "email": "邮件", + "transportation": "传输与通信", + "appearance": "外观", + "image": "图像与预览", + "captcha": "验证码", + "storagePolicy": "存储策略", + "nodes": "离线下载节点", + "groups": "用户组", + "users": "用户", + "files": "文件", + "shares": "分享", + "tasks": "持久任务", + "remoteDownload": "离线下载", + "generalTasks": "常规任务", + "title": "仪表盘", + "dashboard": "Cloudreve 仪表盘" + }, + "summary": { + "newsletterError": "Cloudreve 公告加载失败", + "confirmSiteURLTitle": "确定站点URL设置", + "siteURLNotSet": "您尚未设定站点URL,是否要将其设定为当前的 {{current}} ?", + "siteURLNotMatch": "您设置的站点URL与当前实际不一致,是否要将其设定为当前的 {{current}} ?", + "siteURLDescription": "此设置非常重要,请确保其与您站点的实际地址一致。你可以在 参数设置 - 站点信息 中更改此设置。", + "ignore": "忽略", + "changeIt": "更改", + "trend": "趋势", + "summary": "总计", + "totalUsers": "注册用户", + "totalFiles": "文件总数", + "publicShares": "公开分享总数", + "privateShares": "私密分享总数", + "homepage": "官方网站", + "documents": "使用文档", + "github": "Github 仓库", + "forum": "讨论社区", + "forumLink": "https://forum.cloudreve.org", + "telegramGroup": "Telegram 群组", + "telegramGroupLink": "https://t.me/cloudreve_official", + "buyPro": "捐助开发者", + "publishedAt": "发表于 <0>", + "newsTag": "notice" + }, + "settings": { + "saved": "设置已更改", + "save": "保存", + "basicInformation": "基本信息", + "mainTitle": "主标题", + "mainTitleDes": "站点的主标题", + "subTitle": "副标题", + "subTitleDes": "站点的副标题", + "siteKeywords": "关键词", + "siteKeywordsDes": "站点关键词,以英文逗号分隔", + "siteDescription": "站点描述", + "siteDescriptionDes": "站点描述信息,可能会在分享页面摘要内展示", + "siteURL": "站点 URL", + "siteURLDes": "非常重要,请确保与实际情况一致。使用云存储策略、支付平台时,请填入可以被外网访问的地址", + "customFooterHTML": "页脚代码", + "customFooterHTMLDes": "在页面底部插入的自定义 HTML 代码", + "announcement": "站点公告", + "announcementDes": "展示给已登陆用户的公告,留空不展示。当此项内容更改时,所有用户会重新看到公告", + "supportHTML": "支持 HTML 代码", + "pwa": "渐进式应用 (PWA)", + "smallIcon": "小图标", + "smallIconDes": "扩展名为 ico 的小图标地址", + "mediumIcon": "中图标", + "mediumIconDes": "192x192 的中等图标地址,png 格式", + "largeIcon": "大图标", + "largeIconDes": "512x512 的大图标地址,png 格式。此图标还会被用于在 iOS 客户端切换站点时展示", + "displayMode": "展示模式", + "displayModeDes": "PWA 应用添加后的展示模式", + "themeColor": "主题色", + "themeColorDes": "CSS 色值,影响 PWA 启动画面上状态栏、内容页中状态栏、地址栏的颜色", + "backgroundColor": "背景色", + "backgroundColorDes": "CSS 色值", + "hint": "提示", + "webauthnNoHttps": "Web Authn 需要您的站点启用 HTTPS,并确认 参数设置 - 站点信息 - 站点URL 也使用了 HTTPS 后才能开启。", + "accountManagement": "注册与登录", + "allowNewRegistrations": "允许新用户注册", + "allowNewRegistrationsDes": "关闭后,无法再通过前台注册新的用户", + "emailActivation": "邮件激活", + "emailActivationDes": "开启后,新用户注册需要点击邮件中的激活链接才能完成。请确认邮件发送设置是否正确,否则激活邮件无法送达。", + "captchaForSignup": "注册验证码", + "captchaForSignupDes": "是否启用注册表单验证码", + "captchaForLogin": "登录验证码", + "captchaForLoginDes": "是否启用登录表单验证码", + "captchaForReset": "找回密码验证码", + "captchaForResetDes": "是否启用找回密码表单验证码", + "webauthnDes": "是否允许用户使用绑定的外部验证器登录,站点必须启用 HTTPS 才能使用。", + "webauthn": "外部验证器登录", + "defaultGroup": "默认用户组", + "defaultGroupDes": "用户注册后的初始用户组", + "testMailSent": "测试邮件已发送", + "testSMTPSettings": "发件测试", + "testSMTPTooltip": "发送测试邮件前,请先保存已更改的邮件设置;邮件发送结果不会立即反馈,如果您长时间未收到测试邮件,请检查 Cloudreve 在终端输出的错误日志。", + "recipient": "收件人地址", + "send": "发送", + "smtp": "发信", + "senderName": "发件人名", + "senderNameDes": "邮件中展示的发件人姓名", + "senderAddress": "发件人邮箱", + "senderAddressDes": "发件邮箱的地址", + "smtpServer": "SMTP 服务器", + "smtpServerDes": "发件服务器地址,不含端口号", + "smtpPort": "SMTP 端口", + "smtpPortDes": "发件服务器地址端口号", + "smtpUsername": "SMTP 用户名", + "smtpUsernameDes": "发信邮箱用户名,一般与邮箱地址相同", + "smtpPassword": "SMTP 密码", + "smtpPasswordDes": "发信邮箱密码", + "replyToAddress": "回信邮箱", + "replyToAddressDes": "用户回复系统发送的邮件时,用于接收回信的邮箱", + "enforceSSL": "强制使用 SSL 连接", + "enforceSSLDes": "是否强制使用 SSL 加密连接。如果无法发送邮件,可关闭此项, Cloudreve 会尝试使用 STARTTLS 并决定是否使用加密连接", + "smtpTTL": "SMTP 连接有效期 (秒)", + "smtpTTLDes": "有效期内建立的 SMTP 连接会被新邮件发送请求复用", + "emailTemplates": "邮件模板", + "activateNewUser": "新用户激活", + "activateNewUserDes": "新用户注册后激活邮件的模板", + "resetPassword": "重置密码", + "resetPasswordDes": "密码重置邮件模板", + "sendTestEmail": "发送测试邮件", + "transportation": "传输", + "workerNum": "Worker 数量", + "workerNumDes": "主机节点任务队列最多并行执行的任务数,保存后需要重启 Cloudreve 生效", + "transitParallelNum": "中转并行传输", + "transitParallelNumDes": "任务队列中转任务传输时,最大并行协程数", + "tempFolder": "临时目录", + "tempFolderDes": "用于存放解压缩、压缩等任务产生的临时文件的目录路径", + "textEditMaxSize": "文档在线编辑最大尺寸", + "textEditMaxSizeDes": "文档文件可在线编辑的最大大小,超出此大小的文件无法在线编辑。此项设置适用于纯文本文件、代码文件、Office 文档 (WOPI)", + "failedChunkRetry": "分片错误重试", + "failedChunkRetryDes": "分片上传失败后重试的最大次数,只适用于服务端上传或中转", + "cacheChunks": "缓存流式分片文件以用于重试", + "cacheChunksDes": "开启后,流式中转分片上传时会将分片数据缓存在系统临时目录,以便用于分片上传失败后的重试;\n 关闭后,流式中转分片上传不会额外占用硬盘空间,但分片上传失败后整个上传会立即失败。", + "resetConnection": "上传校验失败时强制重置连接", + "resetConnectionDes": "开启后,如果本次策略、头像等数据上传校验失败,服务器会强制重置连接", + "expirationDuration": "有效期 (秒)", + "batchDownload": "打包下载", + "downloadSession": "下载会话", + "previewURL": "预览链接", + "docPreviewURL": "Office 文档预览链接", + "uploadSession": "上传会话", + "uploadSessionDes": "在上传会话有效期内,对于支持的存储策略,用户可以断点续传未完成的任务。最大可设定的值受限于不同存储策略服务商的规则。", + "downloadSessionForShared": "分享下载会话", + "downloadSessionForSharedDes": "设定时间内重复下载分享文件,不会被记入总下载次数", + "onedriveMonitorInterval": "OneDrive 客户端上传监控间隔", + "onedriveMonitorIntervalDes": "每间隔所设定时间,Cloudreve 会向 OneDrive 请求检查客户端上传情况已确保客户端上传可控", + "onedriveCallbackTolerance": "OneDrive 回调等待", + "onedriveCallbackToleranceDes": "OneDrive 客户端上传完成后,等待回调的最大时间,如果超出会被认为上传失败", + "onedriveDownloadURLCache": "OneDrive 下载请求缓存", + "onedriveDownloadURLCacheDes": "OneDrive 获取文件下载 URL 后可将结果缓存,减轻热门文件下载API请求频率", + "slaveAPIExpiration": "从机API请求超时(秒)", + "slaveAPIExpirationDes": "主机等待从机API请求响应的超时时间", + "heartbeatInterval": "节点心跳间隔(秒)", + "heartbeatIntervalDes": "主机节点向从机节点发送心跳的间隔", + "heartbeatFailThreshold": "心跳失败重试阈值", + "heartbeatFailThresholdDes": "主机向从机发送心跳失败后,主机可最大重试的次数。重试失败后,节点会进入恢复模式", + "heartbeatRecoverModeInterval": "恢复模式心跳间隔(秒)", + "heartbeatRecoverModeIntervalDes": "节点因异常被主机标记为恢复模式后,主机尝试重新连接节点的间隔", + "slaveTransitExpiration": "从机中转超时(秒)", + "slaveTransitExpirationDes": "从机执行文件中转任务可消耗的最长时间", + "nodesCommunication": "节点通信", + "cannotDeleteDefaultTheme": "不能删除默认配色", + "keepAtLeastOneTheme": "请至少保留一个配色方案", + "duplicatedThemePrimaryColor": "主色调不能与已有配色重复", + "themes": "主题配色", + "colors": "关键色", + "themeConfig": "色彩配置", + "actions": "操作", + "wrongFormat": "格式不正确", + "createNewTheme": "新建配色方案", + "themeConfigDoc": "https://v4.mui.com/zh/customization/default-theme/", + "themeConfigDes": "完整的配置项可在 <0>默认主题 - Material-UI 查阅。", + "defaultTheme": "默认配色", + "defaultThemeDes": "用户未指定偏好配色时,站点默认使用的配色方案", + "appearance": "界面", + "personalFileListView": "个人文件列表默认样式", + "personalFileListViewDes": "用户未指定偏好样式时,个人文件页面列表默认样式", + "sharedFileListView": "目录分享页列表默认样式", + "sharedFileListViewDes": "用户未指定偏好样式时,目录分享页面的默认样式", + "primaryColor": "主色调", + "primaryColorText": "主色调文字", + "secondaryColor": "辅色调", + "secondaryColorText": "辅色调文字", + "avatar": "头像", + "gravatarServer": "Gravatar 服务器", + "gravatarServerDes": "Gravatar 服务器地址,可选择使用国内镜像", + "avatarFilePath": "头像存储路径", + "avatarFilePathDes": "用户上传自定义头像的存储路径", + "avatarSize": "头像文件大小限制", + "avatarSizeDes": "用户可上传头像文件的最大大小", + "smallAvatarSize": "小头像尺寸", + "mediumAvatarSize": "中头像尺寸", + "largeAvatarSize": "大头像尺寸", + "filePreview": "文件预览", + "officePreviewService": "Office 文档预览服务", + "officePreviewServiceDes": "可使用以下替换变量:", + "officePreviewServiceSrcDes": "文件 URL", + "officePreviewServiceSrcB64Des": " Base64 编码后的文件 URL", + "officePreviewServiceName": "文件名", + "thumbnails": "缩略图", + "localOnlyInfo": "以下设置只针对本机存储策略有效。", + "thumbnailDoc": "有关配置缩略图的更多信息,请参阅 <0>官方文档。", + "thumbnailDocLink":"https://docs.cloudreve.org/use/thumbnails", + "thumbnailBasic": "基本设置", + "generators": "生成器", + "thumbMaxSize": "最大原始文件尺寸", + "thumbMaxSizeDes": "可生成缩略图的最大原始文件的大小,超出此大小的文件不会生成缩略图", + "generatorProxyWarning": "默认情况下,非本机存储策略只会使用“存储策略原生”生成器。你可以通过开启“生成器代理”功能扩展第三方存储策略的缩略图能力。", + "policyBuiltin": "存储策略原生", + "policyBuiltinDes": "使用存储提供方原生的图像处理接口。对于本机和 S3 策略,这一生成器不可用,将会自动顺沿其他生成器。对于其他存储策略,支持的原始图像格式和大小限制请参考 Cloudreve 文档。", + "cloudreveBuiltin":"Cloudreve 内置", + "cloudreveBuiltinDes": "使用 Cloudreve 内置的图像处理能力,仅支持 PNG、JPEG、GIF 格式的图片。", + "libreOffice": "LibreOffice", + "libreOfficeDes": "使用 LibreOffice 生成 Office 文档的缩略图。这一生成器依赖于任一其他图像生成器(Cloudreve 内置 或 VIPS)。", + "vips": "VIPS", + "vipsDes": "使用 libvips 处理缩略图图像,支持更多图像格式,资源消耗更低。", + "thumbDependencyWarning": "LibreOffice 生成器依赖于 Cloudreve 内置 或 VIPS 生成器,请开启其中任一生成器。", + "ffmpeg": "FFmpeg", + "ffmpegDes": "使用 FFmpeg 生成视频缩略图。", + "executable": "可执行文件", + "executableDes": "第三方生成器可执行文件的地址或命令", + "executableTest": "测试", + "executableTestSuccess": "生成器正常,版本:{{version}}", + "generatorExts": "可用扩展名", + "generatorExtsDes": "此生成器可用的文件扩展名列表,多个请使用半角逗号 , 隔开", + "ffmpegSeek": "缩略图截取位置", + "ffmpegSeekDes": "定义缩略图截取的时间,推荐选择较小值以加速生成过程。如果超出视频实际长度,会导致缩略图截取失败", + "generatorProxy": "生成器代理", + "enableThumbProxy": "使用生成器代理", + "proxyPolicyList": "启动代理的存储策略", + "proxyPolicyListDes": "可多选。选中后,存储策略不支持原生生成缩略图的类型会由 Cloudreve 代理生成", + "thumbWidth": "缩略图宽度", + "thumbHeight": "缩略图高度", + "thumbSuffix": "缩略图文件后缀", + "thumbConcurrent": "缩略图生成并行数量", + "thumbConcurrentDes": "-1 表示自动决定", + "thumbFormat": "缩略图格式", + "thumbFormatDes": "可选:png/jpg", + "thumbQuality": "图像质量", + "thumbQualityDes": "压缩质量百分比,只针对 jpg 编码有效", + "thumbGC": "生成完成后立即回收内存", + "captcha": "验证码", + "captchaType": "验证码类型", + "plainCaptcha": "普通", + "reCaptchaV2": "reCAPTCHA V2", + "tencentCloudCaptcha": "腾讯云验证码", + "captchaProvider": "验证码类型", + "plainCaptchaTitle": "普通验证码", + "captchaWidth": "宽度", + "captchaHeight": "高度", + "captchaLength": "长度", + "captchaMode": "模式", + "captchaModeNumber": "数字", + "captchaModeLetter": "字母", + "captchaModeMath": "算数", + "captchaModeNumberLetter": "数字+字母", + "captchaElement": "验证码的形式", + "complexOfNoiseText": "加强干扰文字", + "complexOfNoiseDot": "加强干扰点", + "showHollowLine": "使用空心线", + "showNoiseDot": "使用噪点", + "showNoiseText": "使用干扰文字", + "showSlimeLine": "使用波浪线", + "showSineLine": "使用正弦线", + "siteKey": "Site KEY", + "siteKeyDes": "<0>应用管理页面 获取到的的 网站密钥", + "siteSecret": "Secret", + "siteSecretDes": "<0>应用管理页面 获取到的的 秘钥", + "secretID": "SecretId", + "secretIDDes": "<0>访问密钥页面 获取到的的 SecretId", + "secretKey": "SecretKey", + "secretKeyDes": "<0>访问密钥页面 获取到的的 SecretKey", + "tCaptchaAppID": "APPID", + "tCaptchaAppIDDes": "<0>图形验证页面 获取到的的 APPID", + "tCaptchaSecretKey": "App Secret Key", + "tCaptchaSecretKeyDes": "<0>图形验证页面 获取到的的 App Secret Key", + "staticResourceCache": "静态公共资源缓存", + "staticResourceCacheDes": "公共可访问的静态资源(如:本机策略直链、文件下载链接)的缓存有效期", + "wopiClient": "WOPI 客户端", + "wopiClientDes": "通过对接支持 WOPI 协议的在线文档处理系统,扩展 Cloudreve 的文档在线预览和编辑能力。详情请参考 <0>官方文档。", + "wopiDocLink": "https://docs.cloudreve.org/use/wopi", + "enableWopi": "使用 WOPI", + "wopiEndpoint": "WOPI Discovery Endpoint", + "wopiEndpointDes": "WOPI 客户端发现 API 的端点地址", + "wopiSessionTtl": "编辑会话有效期(秒)", + "wopiSessionTtlDes": "用户打开在线编辑文档会话的有效期,超出此期限的会话无法继续保存新更改" + }, + "policy": { + "sharp": "#", + "name": "名称", + "type": "类型", + "childFiles": "下属文件数", + "totalSize": "数据量", + "actions": "操作", + "authSuccess": "授权成功", + "policyDeleted": "存储策略已删除", + "newStoragePolicy": "添加存储策略", + "all": "全部", + "local": "本机存储", + "remote": "从机存储", + "qiniu": "七牛", + "upyun": "又拍云", + "oss": "阿里云 OSS", + "cos": "腾讯云 COS", + "onedrive": "OneDrive", + "s3": "AWS S3", + "refresh": "刷新", + "delete": "删除", + "edit": "编辑", + "editInProMode": "专家模式编辑", + "editInWizardMode": "向导模式编辑", + "selectAStorageProvider": "选择存储方式", + "comparesStoragePolicies": "存储策略对比", + "comparesStoragePoliciesLink": "https://docs.cloudreve.org/use/policy/compare", + "storagePathStep": "上传路径", + "sourceLinkStep": "直链设置", + "uploadSettingStep": "上传设置", + "finishStep": "完成", + "policyAdded": "存储策略已添加", + "policySaved": "存储策略已保存", + "editLocalStoragePolicy": "修改本机存储策略", + "addLocalStoragePolicy": "添加本机存储策略", + "optional": "可选", + "pathMagicVarDes": "请在下方输入文件的存储目录路径,可以为绝对路径或相对路径(相对于 Cloudreve)。路径中可以使用魔法变量,文件在上传时会自动替换这些变量为相应值; 可用魔法变量可参考 <0>路径魔法变量列表。", + "pathOfFolderToStoreFiles": "存储目录", + "filePathMagicVarDes": "是否需要对存储的物理文件进行重命名?此处的重命名不会影响最终呈现给用户的 文件名。文件名也可使用魔法变量, 可用魔法变量可参考 <0>文件名魔法变量列表。", + "autoRenameStoredFile": "开启重命名", + "keepOriginalFileName": "不开启", + "renameRule": "命名规则", + "next": "下一步", + "enableGettingPermanentSourceLink": "是否允许获取文件永久直链?", + "enableGettingPermanentSourceLinkDes": "开启后,用户可以请求获得能直接访问到文件内容的直链,适用于图床应用或自用。您可能还需要在用户组设置中开启此功能,用户才可以获取直链。", + "allowed": "允许", + "forbidden": "禁止", + "useCDN": "是否要对下载/直链使用 CDN?", + "useCDNDes": "开启后,用户访问文件时的 URL 中的域名部分会被替换为 CDN 域名。", + "use": "使用", + "notUse": "不使用", + "cdnDomain": "选择协议并填写 CDN 域名", + "cdnPrefix": "CDN 前缀", + "back": "上一步", + "limitFileSize": "是否限制上传的单文件大小?", + "limit": "限制", + "notLimit": "不限制", + "enterSizeLimit": "输入限制:", + "maxSizeOfSingleFile": "单文件大小限制", + "limitFileExt": "是否限制上传文件扩展名?", + "enterFileExt": "输入允许上传的文件扩展名,多个请以半角逗号 , 隔开", + "extList": "扩展名列表", + "chunkSizeLabel": "请指定分片上传时的分片大小,填写为 0 表示不使用分片上传。", + "chunkSizeDes": "启用分片上传后,用户上传的文件将会被切分成分片逐个上传到存储端,当上传中断后,用户可以选择从上次上传的分片后继续开始上传。", + "chunkSize": "分片上传大小", + "nameThePolicy": "最后一步,为此存储策略命名:", + "policyName": "存储策略名", + "finish": "完成", + "furtherActions": "要使用此存储策略,请到用户组管理页面,为相应用户组绑定此存储策略。", + "backToList": "返回存储策略列表", + "magicVar": { + "fileNameMagicVar": "文件名魔法变量", + "pathMagicVar": "路径魔法变量", + "variable": "魔法变量", + "description": "描述", + "example": "示例", + "16digitsRandomString": "16 位随机字符", + "8digitsRandomString": "8 位随机字符", + "secondTimestamp": "秒级时间戳", + "nanoTimestamp": "纳秒级时间戳", + "uid": "用户 ID", + "originalFileName": "原始文件名", + "originFileNameNoext": "无扩展名的原始文件名", + "extension": "文件扩展名", + "uuidV4": "UUID V4", + "date": "日期", + "dateAndTime": "日期时间", + "year": "年份", + "month": "月份", + "day": "日", + "hour": "小时", + "minute": "分钟", + "second": "秒", + "userUploadPath": "用户上传路径" + }, + "storageNode": "存储端配置", + "communicationOK": "通信正常", + "editRemoteStoragePolicy": "修改从机存储策略", + "addRemoteStoragePolicy": "添加从机存储策略", + "remoteDescription": "从机存储策略允许你使用同样运行了 Cloudreve 的服务器作为存储端, 用户上传下载流量通过 HTTP 直传。", + "remoteCopyBinaryDescription": "将和主站相同版本的 Cloudreve 程序拷贝至要作为从机的服务器上。", + "remoteSecretDescription": "下方为系统为您随机生成的从机端密钥,一般无需改动,如果有自定义需求,可将您的密钥填入下方:", + "remoteSecret": "从机密钥", + "modifyRemoteConfig": "修改从机配置文件。", + "addRemoteConfigDes": " 在从机端 Cloudreve 的同级目录下新建 <0>conf.ini 文件,填入从机配置,启动/重启从机端 Cloudreve。以下为一个可供参考的配置例子,其中密钥部分已帮您填写为上一步所生成的。", + "remoteConfigDifference": "从机端配置文件格式大致与主站端相同,区别在于:", + "remoteConfigDifference1": "<0>System 分区下的 <1>mode 字段必须更改为 <2>slave。", + "remoteConfigDifference2": "必须指定 <0>Slave 分区下的 <1>Secret 字段,其值为第二步里填写或生成的密钥。", + "remoteConfigDifference3": "必须启动跨域配置,即 <0>CORS 字段的内容,具体可参考上文范例或官方文档。如果配置不正确,用户将无法通过 Web 端向从机上传文件。", + "inputRemoteAddress": "填写从机地址。", + "inputRemoteAddressDes": "如果主站启用了 HTTPS,从机也需要启用,并在下方填入 HTTPS 协议的地址。", + "remoteAddress": "从机地址", + "testCommunicationDes": "完成以上步骤后,你可以点击下方的测试按钮测试通信是否正常。", + "testCommunication": "测试从机通信", + "pathMagicVarDesRemote": "请在下方输入文件的存储目录路径,可以为绝对路径或相对路径(相对于 从机的 Cloudreve)。路径中可以使用魔法变量,文件在上传时会自动替换这些变量为相应值; 可用魔法变量可参考 <0>路径魔法变量列表。", + "storageBucket": "存储空间", + "editQiniuStoragePolicy": "修改七牛存储策略", + "addQiniuStoragePolicy": "添加七牛存储策略", + "wanSiteURLDes": "在使用此存储策略前,请确保您在 参数设置 - 站点信息 - 站点URL 中填写的 地址与实际相符,并且 <0>能够被外网正常访问。", + "createQiniuBucket": "前往 <0>七牛控制面板 创建对象存储资源。", + "enterQiniuBucket": "在下方填写您在七牛创建存储空间时指定的“存储空间名称”:", + "qiniuBucketName": "存储空间名称", + "bucketTypeDes": "在下方选择您创建的空间类型,推荐选择“私有空间”以获得更高的安全性。", + "privateBucket": "私有", + "publicBucket": "公有", + "bucketCDNDes": "填写您为存储空间绑定的 CDN 加速域名。", + "bucketCDNDomain": "CDN 加速域名", + "qiniuCredentialDes": "在七牛控制面板进入 个人中心 - 密钥管理,在下方填写获得到的 AK、SK。", + "ak": "AK", + "sk": "SK", + "cannotEnableForPrivateBucket": "私有空间开启外链功能后,还需要在用户组里设置开启“使用重定向的外链”,否则无法正常生成外链", + "limitMimeType": "是否限制上传文件 MimeType?", + "mimeTypeDes": "输入允许上传的 MimeType,多个请以半角逗号 , 隔开。七牛服务器会侦测文件内容以判断 MimeType,再用判断值跟指定值进行匹配,匹配成功则允许上传。", + "mimeTypeList": "MimeType 列表", + "chunkSizeLabelQiniu": "请指定分片上传时的分片大小,范围 1 MB - 1 GB。", + "createPlaceholderDes": "是否要再用户开始上传时就创建占位符文件并扣除用户容量?开启后,可以防止用户恶意发起多个上传请求但不完成上传。", + "createPlaceholder": "创建占位符文件", + "notCreatePlaceholder": "不创建", + "corsSettingStep": "跨域策略", + "corsPolicyAdded": "跨域策略已添加", + "editOSSStoragePolicy": "修改阿里云 OSS 存储策略", + "addOSSStoragePolicy": "添加阿里云 OSS 存储策略", + "createOSSBucketDes": "前往 <0>OSS 管理控制台 创建 Bucket。注意:创建空间类型只能选择 <1>标准存储 或 <2>低频访问,暂不支持 <3>归档存储。", + "ossBucketNameDes": "在下方填写您创建 Bucket 时指定的 <0>Bucket 名称:", + "bucketName": "Bucket 名称", + "publicReadBucket": "公共读", + "ossEndpointDes": "转到所创建 Bucket 的概览页面,填写 <0>访问域名 栏目下 <1>外网访问 一行中间的 <2>EndPoint(地域节点)。", + "endpoint": "EndPoint", + "endpointDomainOnly": "格式不合法,只需输入域名部分即可", + "ossLANEndpointDes": "如果您的 Cloudreve 部署在阿里云计算服务中,并且与 OSS 处在同一可用区下,您可以额外指定使用内网 EndPoint 以节省流量开支。是否要在服务端发送请求时使用 OSS 内网 EndPoint?", + "intranetEndPoint": "内网 EndPoint", + "ossCDNDes": "是否要使用配套的 阿里云CDN 加速 OSS 访问?", + "createOSSCDNDes": "前往 <0>阿里云 CDN 管理控制台 创建 CDN 加速域名,并设定源站为刚创建的 OSS Bucket。在下方填写 CDN 加速域名,并选择是否使用 HTTPS:", + "ossAKDes": "在阿里云 <0>安全信息管理 页面获取 用户 AccessKey,并填写在下方。", + "shouldNotContainSpace": "不能含有空格", + "nameThePolicyFirst": "为此存储策略命名:", + "chunkSizeLabelOSS": "请指定分片上传时的分片大小,范围 100 KB ~ 5 GB。", + "ossCORSDes": "此存储策略需要正确配置跨域策略后才能使用 Web 端上传文件,Cloudreve 可以帮您自动设置,您也可以参考文档步骤手动设置。如果您已设置过此 Bucket 的跨域策略,此步骤可以跳过。", + "letCloudreveHelpMe": "让 Cloudreve 帮我设置", + "skip": "跳过", + "editUpyunStoragePolicy": "修改又拍云存储策略", + "addUpyunStoragePolicy": "添加又拍云存储策略", + "createUpyunBucketDes": "前往 <0>又拍云面板 创建云存储服务。", + "storageServiceNameDes": "在下方填写所创建的服务名称:", + "storageServiceName": "服务名称", + "operatorNameDes": "为此服务创建或授权有读取、写入、删除权限的操作员,然后将操作员信息填写在下方:", + "operatorName": "操作员名", + "operatorPassword": "操作员密码", + "upyunCDNDes": "填写为云存储服务绑定的域名,并根据实际情况选择是否使用 HTTPS:", + "upyunOptionalDes": "此步骤可保持默认并跳过,但是强烈建议您跟随此步骤操作。", + "upyunTokenDes": "前往所创建云存储服务的 功能配置 面板,转到 访问配置 选项卡,开启 Token 防盗链并设定密码。", + "tokenEnabled": "已开启 Token 防盗链", + "tokenDisabled": "未开启 Token 防盗链", + "upyunTokenSecretDes": "填写您所设置的 Token 防盗链 密钥", + "upyunTokenSecret": "Token 防盗链 密钥", + "cannotEnableForTokenProtectedBucket": "开启 Token 防盗链后无法使用直链功能", + "callbackFunctionStep": "云函数回调", + "callbackFunctionAdded": "回调云函数已添加", + "editCOSStoragePolicy": "修改腾讯云 COS 存储策略", + "addCOSStoragePolicy": "添加腾讯云 COS 存储策略", + "createCOSBucketDes": "前往 <0>COS 管理控制台 创建存储桶。", + "cosBucketNameDes": "转到所创建存储桶的基础配置页面,将 <0>空间名称 填写在下方:", + "cosBucketFormatError": "空间名格式不正确, 举例:ccc-1252109809", + "cosBucketTypeDes": "在下方选择您创建的空间的访问权限类型,推荐选择 <0>私有读写 以获得更高的安全性,私有空间无法开启“获取直链”功能。", + "cosPrivateRW": "私有读写", + "cosPublicRW": "公共读私有写", + "cosAccessDomainDes": "转到所创建 Bucket 的基础配置,填写 <0>基本信息 栏目下 给出的 <1>访问域名。", + "accessDomain": "访问域名", + "cosCDNDes": "是否要使用配套的 腾讯云CDN 加速 COS 访问?", + "cosCDNDomainDes": "前往 <0>腾讯云 CDN 管理控制台 创建 CDN 加速域名,并设定源站为刚创建的 COS 存储桶。在下方填写 CDN 加速域名,并选择是否使用 HTTPS:", + "cosCredentialDes": "在腾讯云 <0>访问密钥 页面获取一对访问密钥,并填写在下方。请确保这对密钥拥有 COS 和 SCF 服务的访问权限。", + "secretId": "SecretId", + "secretKey": "SecretKey", + "cosCallbackDes": "COS 存储桶 客户端直传需要借助腾讯云的 <0>云函数 产品以确保上传回调可控。如果您打算将此存储策略自用,或者分配给可信赖用户组,此步骤可以跳过。如果是作为公有使用,请务必创建回调云函数。", + "cosCallbackCreate": "Cloudreve 可以尝试帮你自动创建回调云函数,请选择 COS 存储桶 所在地域后继续。创建可能会花费数秒钟,请耐心等待。创建前请确保您的腾讯云账号已开启云函数服务。", + "cosBucketRegion": "存储桶所在地区", + "ap-beijing": "华北地区(北京)", + "ap-chengdu": "西南地区(成都)", + "ap-guangzhou": "华南地区(广州)", + "ap-guangzhou-open": "华南地区(广州Open)", + "ap-hongkong": "港澳台地区(中国香港)", + "ap-mumbai": "亚太南部(孟买)", + "ap-shanghai": "华东地区(上海)", + "na-siliconvalley": "美国西部(硅谷)", + "na-toronto": "北美地区(多伦多)", + "applicationRegistration": "应用授权", + "grantAccess": "账号授权", + "warning": "警告", + "odHttpsWarning": "您必须启用 HTTPS 才能使用 OneDrive/SharePoint 存储策略;启用后同步更改 参数设置 - 站点信息 - 站点URL。", + "editOdStoragePolicy": "修改 OneDrive/SharePoint 存储策略", + "addOdStoragePolicy": "添加 OneDrive/SharePoint 存储策略", + "creatAadAppDes": "前往 <0>Azure Active Directory 控制台 (国际版账号) 或者 <1>Azure Active Directory 控制台 (世纪互联账号) 并登录,登录后进入<2>Azure Active Directory 管理面板,这里登录使用的账号和最终存储使用的 OneDrive 所属账号可以不同。", + "createAadAppDes2": "进入左侧 <0>应用注册 菜单,并点击 <1>新注册 按钮。", + "createAadAppDes3": "填写应用注册表单。其中,名称可任取;<0>受支持的帐户类型 选择为 <1>任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如,Skype、Xbox);<2>重定向 URI (可选) 请选择 <3>Web,并填写 <4>{{url}}; 其他保持默认即可", + "aadAppIDDes": "创建完成后进入应用管理的 <0>概览 页面,复制 <1>应用程序(客户端) ID 并填写在下方:", + "aadAppID": "应用程序(客户端) ID", + "addAppSecretDes": "进入应用管理页面左侧的 <0>证书和密码 菜单,点击 <1>新建客户端密码 按钮,<2>截止期限 选择为 <3>从不。创建完成后将客户端密码的值填写在下方:", + "aadAppSecret": "客户端密码", + "aadAccountCloudDes": "选择您的 Microsoft 365 账号类型:", + "multiTenant": "国际版", + "gallatin": "世纪互联版", + "sharePointDes": "是否将文件存放在 SharePoint 中?", + "saveToSharePoint": "存到指定 SharePoint 中", + "saveToOneDrive": "存到账号默认 OneDrive 驱动器中", + "spSiteURL": "SharePoint 站点地址", + "odReverseProxyURLDes": "是否要在文件下载时替换为使用自建的反代服务器?", + "odReverseProxyURL": "反代服务器地址", + "chunkSizeLabelOd": "请指定分片上传时的分片大小,OneDrive 要求必须为 320 KiB (327,680 bytes) 的整数倍。", + "limitOdTPSDes": "是否限制服务端 OneDrive API 请求频率?", + "tps": "TPS 限制", + "tpsDes": "限制此存储策略每秒向 OneDrive 发送 API 请求最大数量。超出此频率的请求会被限速。多个 Cloudreve 节点转存文件时,它们会各自使用自己的限流桶,请根据情况按比例调低此数值。Web 端上传请求并不受此限制。", + "tpsBurst": "TPS 突发请求", + "tpsBurstDes": "请求空闲时,Cloudreve 可将指定数量的名额预留给未来的突发流量使用。", + "odOauthDes": "但是你需要点击下方按钮,并使用 OneDrive 登录授权以完成初始化后才能使用。日后你可以在存储策略列表页面重新进行授权。", + "gotoAuthPage": "转到授权页面", + "s3SelfHostWarning": "S3 类型存储策略目前仅可用于自己使用,或者是给受信任的用户组使用。", + "editS3StoragePolicy": "修改 AWS S3 存储策略", + "addS3StoragePolicy": "添加 AWS S3 存储策略", + "s3BucketDes": "前往 AWS S3 控制台创建存储桶,在下方填写您创建存储桶时指定的 <0>Bucket 名称:", + "publicAccessDisabled": "阻止全部公共访问权限", + "publicAccessEnabled": "允许公共读取", + "s3EndpointDes": "(可选) 指定存储桶的 EndPoint(地域节点),填写为完整的 URL 格式,比如 <0>https://bucket.region.example.com。留空则将使用系统生成的默认接入点。", + "selectRegionDes": "选择存储桶所在的区域,或者手动输入区域代码", + "enterAccessCredentials": "获取访问密钥,并填写在下方。", + "accessKey": "AccessKey", + "chunkSizeLabelS3": "请指定分片上传时的分片大小,范围 5 MB ~ 5 GB。", + "editPolicy": "编辑存储策略", + "setting":"设置项", + "value": "值", + "description": "描述", + "id": "ID", + "policyID": "存储策略编号", + "policyType": "存储策略类型", + "server": "Server", + "policyEndpoint": "存储端 Endpoint", + "bucketID": "存储桶标识", + "yes": "是", + "no": "否", + "privateBucketDes": "是否为私有空间", + "resourceRootURL": "文件资源根 URL", + "resourceRootURLDes": "预览/获取文件外链时生成 URL 的前缀", + "akDes": "AccessKey / 刷新 Token", + "maxSizeBytes": "最大单文件尺寸 (Bytes)", + "maxSizeBytesDes": "最大可上传的文件尺寸,填写为 0 表示不限制", + "autoRename": "自动重命名", + "autoRenameDes": "是否根据规则对上传物理文件重命名", + "storagePath": "存储路径", + "storagePathDes": "文件物理存储路径", + "fileName": "存储文件名", + "fileNameDes": "文件物理存储文件名", + "allowGetSourceLink": "允许获取外链", + "allowGetSourceLinkDes": "是否允许获取外链。注意,某些存储策略类型不支持,即使在此开启,获取的外链也无法使用", + "upyunToken": "又拍云防盗链 Token", + "upyunOnly": "仅对又拍云存储策略有效", + "allowedFileExtension": "允许文件扩展名", + "emptyIsNoLimit": "留空表示不限制", + "allowedMimetype": "允许的 MimeType", + "qiniuOnly": "仅对七牛存储策略有效", + "odRedirectURL": "OneDrive 重定向地址", + "noModificationNeeded": "一般添加后无需修改", + "odReverseProxy": "OneDrive 反代服务器地址", + "odOnly": "仅对 OneDrive 存储策略有效", + "odDriverID": "OneDrive/SharePoint 驱动器资源标识", + "odDriverIDDes": "仅对 OneDrive 存储策略有效,留空则使用用户的默认 OneDrive 驱动器", + "s3Region": "Amazon S3 Region", + "s3Only": "仅对 Amazon S3 存储策略有效", + "lanEndpoint": "内网 EndPoint", + "ossOnly": "仅对 OSS 存储策略有效", + "chunkSizeBytes": "上传分片大小 (Bytes)", + "chunkSizeBytesDes": "分片上传时单个分片的大小,仅部分存储策略支持", + "placeHolderWithSize": "上传前预支用户存储", + "placeHolderWithSizeDes": "是否在上传会话创建时就对用户存储进行预支,仅部分存储策略支持", + "saveChanges": "保存更改", + "s3EndpointPathStyle": "选择 S3 Endpoint 地址的格式,如果您不知道该选什么,保持默认即可。某些第三方 S3 兼容存储策略可能需要更改此选项。开启后,将会强制使用路径格式地址,比如 <0>http://s3.amazonaws.com/BUCKET/KEY。", + "usePathEndpoint": "强制路径格式", + "useHostnameEndpoint": "主机名优先", + "thumbExt": "可生成缩略图的文件扩展名", + "thumbExtDes": "留空表示使用存储策略预定义集合。对本机、S3存储策略无效" + }, + "node": { + "#": "#", + "name": "名称", + "status": "当前状态", + "features": "已启用功能", + "action": "操作", + "remoteDownload": "离线下载", + "nodeDisabled": "节点已暂停使用", + "nodeEnabled": "节点已启用", + "nodeDeleted": "节点已删除", + "disabled": "未启用", + "online": "在线", + "offline": "离线", + "addNewNode": "接入新节点", + "refresh": "刷新", + "enableNode": "启用节点", + "disableNode": "暂停使用节点", + "edit": "编辑", + "delete": "删除", + "slaveNodeDes": "您可以添加同样运行了 Cloudreve 的服务器作为从机端,正常运行工作的从机端可以为主机分担某些异步任务(如离线下载)。请参考下面向导部署并配置连接 Cloudreve 从机节点。<0>如果你已经在目标服务器上部署了从机存储策略,您可以跳过本页面的某些步骤,只将从机密钥、服务器地址在这里填写并保持与从机存储策略中一致即可。 在后续版本中,从机存储策略的相关配置会合并到这里。", + "overwriteDes": "; 以下为可选的设置,对应主机节点的相关参数,可以通过配置文件应用到从机节点,请根据<0>; 实际情况调整。更改下面设置需要重启从机节点后生效。", + "workerNumDes": "任务队列最多并行执行的任务数", + "parallelTransferDes": "任务队列中转任务传输时,最大并行协程数", + "chunkRetriesDes": "中转分片上传失败后重试的最大次数", + "multipleMasterDes": "一个从机 Cloudreve 实例可以对接多个 Cloudreve 主节点,只需在所有主节点中添加此从机节点并保持密钥一致即可。", + "ariaSuccess": "连接成功,Aria2 版本为:{{version}}", + "slave": "从机", + "master": "主机", + "aria2Des": "Cloudreve 的离线下载功能由 <0>Aria2 驱动。如需使用,请在目标节点服务器上以和运行 Cloudreve 相同的用户身份启动 Aria2, 并在 Aria2 的配置文件中开启 RPC 服务,<1>Aria2 需要和{{mode}} Cloudreve 进程共用相同的文件系统。 更多信息及指引请参考文档的 <2>离线下载 章节。", + "slaveTakeOverRemoteDownload": "是否需要此节点接管离线下载任务?", + "masterTakeOverRemoteDownload": "是否需要主机接管离线下载任务?", + "routeTaskSlave": "开启后,用户的离线下载请求可以被分流到此节点处理。", + "routeTaskMaster": "开启后,用户的离线下载请求可以被分流到主机处理。", + "enable": "启用", + "disable": "关闭", + "slaveNodeTarget": "在目标节点服务器上与节点", + "masterNodeTarget": "在与", + "aria2ConfigDes": "{{target}} Cloudreve 进程相同的文件系统环境下启动 Aria2 进程。在启动 Aria2 时,需要在其配置文件中启用 RPC 服务,并设定 RPC Secret,以便后续使用。以下为一个供参考的配置:", + "enableRPCComment": "启用 RPC 服务", + "rpcPortComment": "RPC 监听端口", + "rpcSecretComment": "RPC 授权令牌,可自行设定", + "rpcConfigDes": "推荐在日常启动流程中,先启动 Aria2,再启动节点 Cloudreve,这样节点 Cloudreve 可以向 Aria2 订阅事件通知,下载状态变更处理更及时。当然,如果没有这一流程,节点 Cloudreve 也会通过轮询追踪任务状态。", + "rpcServerDes": "在下方填写{{mode}} Cloudreve 与 Aria2 通信的 RPC 服务地址。一般可填写为 <0>http://127.0.0.1:6800/,其中端口号 <1>6800 与上文配置文件中 <2>rpc-listen-port保持一致。", + "rpcServer": "RPC 服务器地址", + "rpcServerHelpDes": "包含端口的完整 RPC 服务器地址,例如:http://127.0.0.1:6800/,留空表示不启用 Aria2 服务", + "rpcTokenDes": "RPC 授权令牌,与 Aria2 配置文件中 <0>rpc-secret 保持一致,未设置请留空。", + "aria2PathDes": "在下方填写 Aria2 用作临时下载目录的 节点上的 <0>绝对路径,节点上的 Cloudreve 进程需要此目录的读、写、执行权限。", + "aria2SettingDes": "在下方按需要填写一些 Aria2 额外参数信息。", + "refreshInterval": "状态刷新间隔 (秒)", + "refreshIntervalDes": "Cloudreve 向 Aria2 请求刷新任务状态的间隔。", + "rpcTimeout": "RPC 调用超时 (秒)", + "rpcTimeoutDes": "调用 RPC 服务时最长等待时间", + "globalOptions": "全局任务参数", + "globalOptionsDes": "创建下载任务时携带的额外设置参数,以 JSON 编码后的格式书写,您可也可以将这些设置写在 Aria2 配置文件里,可用参数请查阅官方文档", + "testAria2Des": "完成以上步骤后,你可以点击下方的测试按钮测试{{mode}} Cloudreve 向 Aria2 通信是否正常。", + "testAria2DesSlaveAddition": "在进行测试前请先确保您已进行并通过上一页面中的“从机通信测试”。", + "testAria2": "测试 Aria2 通信", + "aria2DocURL": "https://docs.cloudreve.org/use/aria2", + "nameNode": "为此节点命名:", + "loadBalancerRankDes": "为此节点指定负载均衡权重,数值为整数。某些负载均衡策略会根据此数值加权选择节点", + "loadBalancerRank": "负载均衡权重", + "nodeSaved": "节点已保存!", + "nodeSavedFutureAction": "如果您添加了新节点,还需要在节点列表手动启动节点才能正常使用。", + "backToNodeList": "返回节点列表", + "communication": "通信配置", + "otherSettings": "杂项信息", + "finish": "完成", + "nodeAdded": "节点已添加", + "nodeSavedNow": "节点已保存", + "editNode": "编辑节点", + "addNode": "添加节点" + }, + "group": { + "#": "#", + "name": "名称", + "type": "存储策略", + "count": "下属用户数", + "size": "最大容量", + "action": "操作", + "deleted": "用户组已删除", + "new": "新建用户组", + "aria2FormatError": "Aria2 设置项格式错误", + "atLeastOnePolicy": "至少要为用户组选择一个存储策略", + "added": "用户组已添加", + "saved": "用户组已保存", + "editGroup": "编辑 {{group}}", + "nameOfGroup": "用户组名", + "nameOfGroupDes": "用户组的名称", + "storagePolicy": "存储策略", + "storageDes": "指定用户组的存储策略。", + "availablePolicies": "可用存储策略", + "availablePoliciesDes": "指定用户组可用的存储策略,可多选,用户可在选定范围内自由切换存储策略。", + "initialStorageQuota": "初始容量", + "initialStorageQuotaDes": "用户组下的用户初始可用最大容量", + "downloadSpeedLimit": "下载限速", + "downloadSpeedLimitDes": "填写为 0 表示不限制。开启限制后,此用户组下的用户下载所有支持限速的存储策略下的文件时,下载最大速度会被限制。", + "bathSourceLinkLimit": "批量生成外链数量限制", + "bathSourceLinkLimitDes": "对于支持的存储策略下的文件,允许用户单次批量获取外链的最大文件数量,填写为 0 表示不允许批量生成外链。", + "allowCreateShareLink": "允许创建分享", + "allowCreateShareLinkDes": "关闭后,用户无法创建分享链接", + "allowDownloadShare": "允许下载分享", + "allowDownloadShareDes": "关闭后,用户无法下载别人创建的文件分享", + "allowWabDAV": "WebDAV", + "allowWabDAVDes": "关闭后,用户无法通过 WebDAV 协议连接至网盘", + "allowWabDAVProxy": "WebDAV 代理", + "allowWabDAVProxyDes": "启用后, 用户可以配置 WebDAV 代理下载文件的流量", + "disableMultipleDownload": "禁止多次下载请求", + "disableMultipleDownloadDes": "只针对本机存储策略有效。开启后,用户无法使用多线程下载工具。", + "allowRemoteDownload": "离线下载", + "allowRemoteDownloadDes": "是否允许用户创建离线下载任务", + "aria2Options": "Aria2 任务参数", + "aria2OptionsDes": "此用户组创建离线下载任务时额外携带的参数,以 JSON 编码后的格式书写,您可也可以将这些设置写在 Aria2 配置文件里,可用参数请查阅官方文档", + "aria2BatchSize": "Aria2 批量下载最大数量", + "aria2BatchSizeDes": "允许用户同时进行的离线下载任务数量,填写为 0 或留空表示不限制。", + "serverSideBatchDownload": "服务端打包下载", + "serverSideBatchDownloadDes": "是否允许用户多选文件使用服务端中转打包下载,关闭后,用户仍然可以使用纯 Web 端打包下载功能。", + "compressTask": "压缩/解压缩 任务", + "compressTaskDes": "是否用户创建 压缩/解压缩 任务", + "compressSize": "待压缩文件最大大小", + "compressSizeDes": "用户可创建的压缩任务的文件最大总大小,填写为 0 表示不限制", + "decompressSize": "待解压文件最大大小", + "decompressSizeDes": "用户可创建的解压缩任务的文件最大总大小,填写为 0 表示不限制", + "migratePolicy": "存储策略转移", + "migratePolicyDes": "是否用户创建存储策略转移任务", + "allowSelectNode": "允许选择下载节点", + "allowSelectNodeDes": "开启后,用户可以在创建任务前选择处理下载的从机节点;关闭后,系统会自动分配节点。", + "redirectedSource": "使用重定向的外链", + "redirectedSourceDes": "开启后,用户获取的文件外链将由 Cloudreve 中转,链接较短。关闭后,用户获取的文件外链会变成文件的原始链接。部分存储策略获取的非中转外链无法保持永久有效,请参阅 <0>比较存储策略。", + "availableNodes": "可用下载节点", + "availableNodesDes": "指定用户组可用的下载节点,留空表示全部节点都可用。用户只能在此列表内选择或被负载均衡分配下载节点。", + "advanceDelete": "允许使用高级文件删除选项", + "advanceDeleteDes": "开启后,用户在前台删除文件时可以选择是否强制删除、是否仅解除物理链接。这些选项与后台管理面板删除文件时类似,请只开放给可信用户组。" + }, + "user": { + "deleted": "用户已删除", + "new": "新建用户", + "filter": "过滤", + "selectedObjects": "已选择 {{num}} 个对象", + "nick": "昵称", + "email": "Email", + "group": "用户组", + "status": "状态", + "usedStorage": "已用空间", + "active": "正常", + "notActivated": "未激活", + "banned": "被封禁", + "bannedBySys": "超额封禁", + "toggleBan": "封禁/解封", + "filterCondition": "过滤条件", + "all": "全部", + "userStatus": "用户状态", + "searchNickUserName": "搜索 昵称 / 用户名", + "apply": "应用", + "added": "用户已添加", + "saved": "用户已保存", + "editUser": "编辑 {{nick}}", + "password": "密码", + "passwordDes": "留空表示不修改", + "groupDes": "用户所属用户组", + "2FASecret": "二步验证密钥", + "2FASecretDes": "用户二步验证器的密钥,清空表示未启用。" + }, + "file": { + "name": "文件名", + "deleteAsync": "删除任务将在后台执行", + "import": "从外部导入", + "forceDelete": "强制删除", + "size": "大小", + "uploader": "上传者", + "createdAt": "创建于", + "uploading": "上传中", + "unknownUploader": "未知", + "uploaderID": "上传者 ID", + "searchFileName": "搜索文件名", + "storagePolicy": "存储策略", + "selectTargetUser": "请先选择目标用户", + "importTaskCreated": "导入任务已创建,您可以在“持久任务”中查看执行情况", + "manuallyPathOnly": "选择的存储策略只支持手动输入路径", + "selectFolder": "选择目录", + "importExternalFolder": "导入外部目录", + "importExternalFolderDes": "您可以将存储策略中已有文件、目录结构导入到 Cloudreve 中,导入操作不会额外占用物理存储空间,但仍会正常扣除用户已用容量空间,空间不足时将停止导入。", + "storagePolicyDes": "选择要导入文件目前存储所在的存储策略", + "targetUser": "目标用户", + "targetUserDes": "选择要将文件导入到哪个用户的文件系统中,可通过昵称、邮箱搜索用户", + "srcFolderPath": "原始目录路径", + "select": "选择", + "selectSrcDes": "要导入的目录在存储端的路径", + "dstFolderPath": "目的目录路径", + "dstFolderPathDes": "要将目录导入到用户文件系统中的路径", + "recursivelyImport": "递归导入子目录", + "recursivelyImportDes": "是否将目录下的所有子目录递归导入", + "createImportTask": "创建导入任务", + "unlink": "解除关联(保留物理文件)" + }, + "share": { + "deleted": "分享已删除", + "objectName": "对象名", + "views": "浏览", + "downloads": "下载", + "price": "积分", + "autoExpire": "自动过期", + "owner": "分享者", + "createdAt": "分享于", + "public": "公开", + "private": "私密", + "afterNDownloads":"{{num}} 次下载后", + "none": "无", + "srcType": "源文件类型", + "folder": "目录", + "file": "文件" + }, + "task": { + "taskDeleted": "任务已删除", + "howToConfigAria2": "如何配置离线下载?", + "srcURL": "源地址", + "node": "处理节点", + "createdBy": "创建者", + "ready": "就绪", + "downloading": "下载中", + "paused": "暂停中", + "seeding": "做种中", + "error": "出错", + "finished": "完成", + "canceled": "取消/停止", + "unknown": "未知", + "aria2Des": "Cloudreve 的离线下载支持主从分散模式。您可以配置多个 Cloudreve 从机节点,这些节点可以用来处理离线下载任务,分散主节点的压力。当然,您也可以配置只在主节点上处理离线下载任务,这是最简单的一种方式。", + "masterAria2Des": "如果您只需要为主机启用离线下载功能,请 <0>点击这里 编辑主节点;", + "slaveAria2Des": "如果您想要在从机节点上分散处理离线下载任务,请 <0>点击这里 添加并配置新节点。", + "editGroupDes": "当你添加多个可用于离线下载的节点后,主节点会将离线下载请求轮流发送到这些节点处理。节点离线下载配置完成后,您可能还需要 <0>到这里 编辑用户组,为对应用户组开启离线下载权限。", + "lastProgress": "最后进度", + "errorMsg": "错误信息" + }, + "vas": { + "vas": "增值服务", + "reports": "举报", + "orders": "订单", + "initialFiles": "初始文件", + "filterEmailProvider": "邮箱过滤", + "filterEmailProviderWhitelist": "白名单", + "initialFilesDes": "指定用户注册后初始拥有的文件。输入文件 ID 搜索并添加现有文件。", + "filterEmailProviderDisabled": "不启用", + "filterEmailProviderDes": "过滤注册邮箱域", + "appLinkDes": "用于在 App 设置页面展示,留空即不展示链接按钮,仅当 VOL 授权有效时此项设置才会生效。", + "filterEmailProviderBlacklist": "黑名单", + "filterEmailProviderRuleDes": "多个域请使用半角逗号隔开", + "filterEmailProviderRule": "邮箱域过滤规则", + "qqConnectHint": "创建应用时,回调地址请填写:{{url}}", + "enableQQConnectDes": "是否允许绑定QQ、使用QQ登录本站", + "loginWithoutBindingDes": "开启后,如果用户使用了QQ登录,但是没有已绑定的注册用户,系统会为其创建用户并登录。这种方式创建的用户日后只能使用QQ登录。", + "qqConnect": "QQ互联", + "enableQQConnect": "开启QQ互联", + "appidDes": "应用管理页面获取到的的 APP ID", + "appForum": "用户论坛 URL", + "loginWithoutBinding": "未绑定时可直接登录", + "appKeyDes": "应用管理页面获取到的的 APP KEY", + "appid": "APP ID", + "overuseReminderDes": "用户因增值服务过期,容量超出限制后发送的提醒邮件模板", + "storagePack": "容量包", + "giftCodes": "兑换码", + "appKey": "APP KEY", + "overuseReminder": "超额提醒", + "enable": "开启", + "appFeedback": "反馈页面 URL", + "vasSetting": "支付/杂项设置", + "appIDDes": "当面付应用的 APPID", + "purchasableGroups": "可购用户组", + "rsaPrivateDes": "当面付应用的 RSA2 (SHA256) 私钥,一般是由您自己生成。详情参考 <0>生成 RSA 密钥。", + "alipayPublicKeyDes": "由支付宝提供,可在 应用管理 - 应用信息 - 接口加签方式 中获取。", + "applicationID": "应用 ID", + "alipay": "支付宝当面付", + "appID": "App- ID", + "merchantID": "直连商户号", + "customPaymentEndpointDes":"创建支付订单时请求的接口 URL", + "rsaPrivate": "RSA 应用私钥", + "apiV3Secret": "API v3 密钥", + "alipayPublicKey": "支付宝公钥", + "mcCertificateSerial": "商户证书序列号", + "mcAPISecret": "商户API 私钥", + "payjs": "PAYJS 微信支付", + "wechatPay": "微信官方扫码支付", + "applicationIDDes": "直连商户申请的公众号或移动应用appid", + "mcNumber": "商户号", + "customPaymentEndpoint":"支付接口地址", + "merchantIDDes": "直连商户的商户号,由微信支付生成并下发。", + "communicationSecret": "通信密钥", + "apiV3SecretDes": "商户需先在【商户平台】-【API安全】的页面设置该密钥,请求才能通过微信支付的签名校验。密钥的长度为 32 个字节。", + "banBufferPeriod": "封禁缓冲期 (秒)", + "allowSellShares": "允许为分享定价", + "creditPriceRatio": "积分到账比率 (%)", + "mcCertificateSerialDes": "登录商户平台【API安全】-【API证书】-【查看证书】,可查看商户 API 证书序列号。", + "mcAPISecretDes": "私钥文件 apiclient_key.pem 的内容。", + "creditPrice": "积分价格 (分)", + "customPaymentSecretDes": "Cloudreve 用于签名付款请求的密钥", + "payjsWarning": "此服务由第三方平台 <0>PAYJS 提供, 产生的任何纠纷与 Cloudreve 开发者无关。", + "add": "添加", + "mcNumberDes": "可在 PAYJS 管理面板首页看到", + "price": "单价", + "size": "大小", + "orCredits": " 或 {{num}} 积分", + "otherSettings": "杂项设置", + "banBufferPeriodDes": "用户保持容量超额状态的最长时长,超出时长该用户会被系统冻结。", + "yes": "是", + "customPaymentNameDes": "用于展示给用户的付款方式名称", + "allowSellSharesDes": "开启后,用户可为分享设定积分价格,下载需要扣除积分。", + "productName": "商品名", + "creditPriceRatioDes": "购买下载设定价格的分享,分享者实际到账的积分比率。", + "code": "兑换码", + "invalidProduct": "已失效商品", + "notUsed": "未使用", + "creditPriceDes": "充值积分时的价格", + "allowReportShare": "允许举报分享", + "allowReportShareDes": "开启后,任意用户可对分享进行举报,有被刷数据库的风险", + "name": "名称", + "addStoragePack": "添加容量包", + "customPaymentName": "付款方式名称", + "duration": "时长", + "productNameDes": "商品展示名称", + "actions": "操作", + "durationDay": "有效期 (天)", + "priceYuan": "单价 (元)", + "priceCredits": "单价 (积分)", + "highlight": "突出展示", + "no": "否", + "editMembership": "编辑可购用户组", + "customPaymentDocumentLink":"https://docs.cloudreve.org/use/pro/pay", + "qyt": "数量", + "group": "用户组", + "status": "状态", + "durationGroupDes": "购买后升级的用户组单位购买时间的有效期", + "productDescription": "商品描述 (一行一个)", + "highlightDes": "开启后,在商品选择页面会被突出展示", + "used": "已使用", + "generatingResult": "生成结果", + "numberOfCodes": "生成数量", + "customPaymentDes":"通过实现 Cloudreve 兼容付款接口来对接其他第三方支付平台,详情请参考 <0>官方文档。", + "editStoragePack": "编辑容量包", + "linkedProduct": "对应商品", + "packSizeDes": "容量包的大小", + "productQytDes": "对于积分类商品,此处为积分数量,其他商品为时长倍数", + "freeDownloadDes": "开启后,用户可以免费下载需付积分的分享", + "markSuccessful": "标记成功", + "durationDayDes": "每个容量包的有效期", + "packPriceDes": "容量包的单价", + "reportedContent": "举报对象", + "customPayment": "自定义付款渠道", + "priceCreditsDes": "使用积分购买时的价格,填写为 0 表示不能使用积分购买", + "description": "补充描述", + "addMembership": "添加可购用户组", + "invalid": "[已失效]", + "orderDeleted": "订单记录已删除", + "product": "商品", + "groupDes": "购买后升级的用户组", + "groupPriceDes": "用户组的单价", + "paidBy": "支付方式", + "showAppPromotion": "展示客户端引导页面", + "showAppPromotionDes": "开启后,用户可以在 “连接与挂载” 页面中看到移动客户端的使用引导", + "productDescriptionDes": "购买页面展示的商品描述", + "unpaid": "未支付", + "generateGiftCode": "生成兑换码", + "shareLink": "分享链接", + "iosVol": "iOS 客户端批量授权 (VOL)", + "syncLicense": "同步授权", + "numberOfCodesDes": "激活码批量生成数量", + "productQyt": "商品数量", + "freeDownload": "免积分下载分享", + "credits": "积分", + "markAsResolved": "标记为已处理", + "reason": "原因", + "reportTime": "举报时间", + "deleteShare": "删除分享", + "orderName": "订单名", + "orderNumber": "订单号", + "orderOwner": "创建者", + "paid": "已支付", + "volPurchase": "客户端 VOL 授权需要单独在 <0>授权管理面板 购买。VOL 授权允许您的用户免费使用 <1>Cloudreve iOS 客户端 连接到您的站点,无需用户再付费订阅 iOS 客户端。购买授权后请点击下方同步授权。", + "mobileApp": "移动客户端", + "volSynced": "VOL 授权已同步" + } +} diff --git a/public/locales/zh-TW/application.json b/public/locales/zh-TW/application.json new file mode 100644 index 0000000..6d6c465 --- /dev/null +++ b/public/locales/zh-TW/application.json @@ -0,0 +1,589 @@ +{ + "login": { + "email": "電子信箱", + "password": "密碼", + "captcha": "驗證碼", + "captchaError": "驗證碼載入失敗: {{message}}", + "signIn": "登入", + "signUp": "註冊", + "signUpAccount": "註冊帳號", + "useFIDO2": "使用外部驗證器登入", + "usePassword": "使用密碼登入", + "forgetPassword": "忘記密碼", + "2FA": "兩步驟驗證", + "input2FACode": "請輸入 6 位兩步驟驗證碼", + "passwordNotMatch": "兩次密碼輸入不一致", + "findMyPassword": "重設密碼", + "passwordReset": "密碼已重設", + "newPassword": "新密碼", + "repeatNewPassword": "再次輸入新密碼", + "repeatPassword": "重複密碼", + "resetPassword": "重設密碼", + "backToSingIn": "返回登入", + "sendMeAnEmail": "發送密碼重設郵件", + "resetEmailSent": "密碼重設郵件已傳送,請注意查收", + "browserNotSupport": "當前瀏覽器或或環境不支援", + "success": "登入成功", + "signUpSuccess": "註冊完成", + "activateSuccess": "啟動成功", + "accountActivated": "您的帳號已成功被啟動。", + "title": "登入 {{title}}", + "sinUpTitle": "註冊 {{title}}", + "activateTitle": "信箱驗證", + "activateDescription": "一封啟動郵件已發送至您的信箱,請點擊郵件中的連結以完成註冊。", + "continue": "下一步", + "logout": "登出", + "loggedOut": "您已登出", + "clickToRefresh": "點擊重新整理驗證碼" + }, + "navbar": { + "myFiles": "我的文件", + "myShare": "我的分享", + "remoteDownload": "離線下載", + "connect": "連接", + "taskQueue": "任務列", + "setting": "個人設定", + "videos": "影片", + "photos": "圖片", + "music": "音樂", + "documents": "文件", + "addATag": "新增標籤...", + "addTagDialog": { + "selectFolder": "選擇資料夾", + "fileSelector": "文件分類", + "folderLink": "目錄捷徑", + "tagName": "標籤名", + "matchPattern": "檔案名應對規則", + "matchPatternDescription": "你可以使用 <0>* 作為通用。比如 <1>*.png 表示對應 png 格式圖像。多行規則間會以「或」的關係運算。", + "icon": "圖示:", + "color": "顏色:", + "folderPath": "資料夾路徑" + }, + "storage": "儲存空間", + "storageDetail": "已使用 {{used}}, 共 {{total}}", + "notLoginIn": "未登入", + "visitor": "遊客", + "objectsSelected": "{{num}} 個對象", + "searchPlaceholder": "搜尋...", + "searchInFiles": "在我的文件中搜尋 <0>{{name}}", + "searchInFolders": "在當前目錄中搜尋 <0>{{name}}", + "searchInShares": "在全站分享中搜尋 <0>{{name}}", + "backToHomepage": "返回首頁", + "toDarkMode": "切換到深色模式", + "toLightMode": "切換到淺色模式", + "myProfile": "個人首頁", + "dashboard": "管理面板", + "exceedQuota": "您的已用容量已超過容量配額,請盡快刪除多餘文件" + }, + "fileManager": { + "open": "打開", + "openParentFolder": "打開所在目錄", + "download": "下載", + "batchDownload": "打包下載", + "share": "分享", + "rename": "重新命名", + "move": "移動", + "delete": "刪除", + "moreActions": "更多操作", + "refresh": "重新整理", + "compress": "壓縮", + "newFolder": "建立資料夾", + "newFile": "建立文件", + "showFullPath": "顯示路徑", + "listView": "列表", + "gridViewSmall": "小圖示", + "gridViewLarge": "大圖示", + "paginationSize": "分頁大小", + "paginationOption": "{{option}} / 頁", + "noPagination": "不分頁", + "sortMethod": "排序方式", + "sortMethods": { + "A-Z": "A-Z", + "Z-A": "Z-A", + "oldestUploaded": "最早上傳", + "newestUploaded": "最新上傳", + "oldestModified": "最早修改", + "newestModified": "最新修改", + "smallest": "最小", + "largest": "最大" + }, + "shareCreateBy": "由 {{nick}} 建立", + "name": "名稱", + "size": "大小", + "lastModified": "修改日期", + "currentFolder": "當前目錄", + "backToParentFolder": "上級目錄", + "folders": "資料夾", + "files": "文件", + "listError": ":( 請求時出現錯誤", + "dropFileHere": "拖拽文件至此", + "orClickUploadButton": "或點擊右下方「上傳文件」按鈕新增文件", + "nothingFound": "什麼都沒有找到", + "uploadFiles": "上傳文件", + "uploadFolder": "上傳目錄", + "newRemoteDownloads": "離線下載", + "enter": "進入", + "getSourceLink": "獲取外鏈", + "getSourceLinkInBatch": "批次獲取外鏈", + "createRemoteDownloadForTorrent": "建立離線下載任務", + "decompress": "解壓縮", + "createShareLink": "建立分享連結", + "viewDetails": "詳細訊息", + "copy": "複製", + "bytes": " ({{bytes}} 位元組)", + "storagePolicy": "儲存策略", + "inheritedFromParent": "跟隨父目錄", + "childFolders": "包含目錄", + "childFiles": "包含文件", + "childCount": "{{num}} 個", + "parentFolder": "所在目錄", + "rootFolder": "根目錄", + "modifiedAt": "修改於", + "createdAt": "建立於", + "statisticAt": "統計於 <1>", + "musicPlayer": "音訊播放", + "closeAndStop": "退出播放", + "playInBackground": "後台播放", + "copyTo": "複製到", + "copyToDst": "複製到 <0>{{dst}}", + "errorReadFileContent": "無法讀取文件內容:{{msg}}", + "wordWrap": "自動換行", + "pdfLoadingError": "PDF 載入失敗:{{msg}}", + "subtitleSwitchTo": "字幕切換到:{{subtitle}}", + "noSubtitleAvailable": "影片目錄下沒有可用字幕文件 (支援:ASS/SRT/VTT)", + "subtitle": "選擇字幕", + "playlist": "播放列表", + "openInExternalPlayer": "用外部播放器打開", + "searchResult": "搜尋結果", + "preparingBathDownload": "正在準備打包下載...", + "preparingDownload": "獲取下載網址...", + "browserBatchDownload": "瀏覽器端打包", + "browserBatchDownloadDescription": "由瀏覽器即時下載並打包,並非所有環境都支援。", + "serverBatchDownload": "服務端中轉打包", + "serverBatchDownloadDescription": "由服務端中轉打包並即時發送到用戶端下載。", + "selectArchiveMethod": "選擇打包下載方式", + "batchDownloadStarted": "打包下載已開始,請不要關閉此分頁", + "batchDownloadError": "打包遇到錯誤:{{msg}}", + "userDenied": "用戶拒絕", + "directoryDownloadReplace": "替換對象", + "directoryDownloadReplaceDescription": "將會替換 {{duplicates}} 等共 {{num}} 個對象。", + "directoryDownloadSkip": "跳過對象", + "directoryDownloadSkipDescription": "將會跳過 {{duplicates}} 等共 {{num}} 個對象。", + "selectDirectoryDuplicationMethod": "重複對象處理方式", + "directoryDownloadStarted": "下載已開始,請不要關閉此分頁", + "directoryDownloadFinished": "下載完成,無失敗對象", + "directoryDownloadFinishedWithError": "下載完成, 失敗 {{failed}} 個對象", + "directoryDownloadPermissionError": "無權限操作,請允許讀寫本地文件" + }, + "modals": { + "processing": "處理中...", + "duplicatedObjectName": "新名稱與已有文件重複", + "duplicatedFolderName": "資料夾名稱重複", + "taskCreated": "任務已建立", + "taskCreateFailed": "{{failed}} 個任務建立失敗:{{details}}", + "linkCopied": "連結已複製", + "getSourceLinkTitle": "獲取文件外鏈", + "sourceLink": "文件外鏈", + "folderName": "資料夾名稱", + "create": "建立", + "fileName": "檔案名稱", + "renameDescription": "輸入 <0>{{name}} 的新名稱:", + "newName": "新名稱", + "moveToTitle": "移動至", + "moveToDescription": "移動至 <0>{{name}}", + "saveToTitle": "儲存至", + "saveToTitleDescription": "儲存至 <0>{{name}}", + "deleteTitle": "刪除對象", + "deleteOneDescription": "確定要刪除 <0>{{name}} 嗎?", + "deleteMultipleDescription": "確定要刪除這 {{num}} 個對象嗎?", + "newRemoteDownloadTitle": "新增離線下載任務", + "remoteDownloadURL": "下載連結", + "remoteDownloadURLDescription": "輸入文件下載網址,一行一個,支援 HTTP(s) / FTP / 磁力鏈", + "remoteDownloadDst": "下載至", + "createTask": "建立任務", + "downloadTo": "下載至 <0>{{name}}", + "decompressTo": "解壓縮至", + "decompressToDst": "解壓縮至 <0>{{name}}", + "defaultEncoding": "預設", + "chineseMajorEncoding": "簡體中文常見編碼", + "selectEncoding": "選擇 ZIP 文件特殊字元編碼", + "noEncodingSelected": "未選擇編碼方式", + "listingFiles": "列取文件中...", + "listingFileError": "列取文件時出錯:{{message}}", + "generatingSourceLinks": "生成外鏈中...", + "noFileCanGenerateSourceLink": "沒有可以生成外鏈的文件", + "sourceBatchSizeExceeded": "當前用戶組最大可同時為 {{limit}} 個文件生成外鏈", + "zipFileName": "ZIP 檔案名", + "shareLinkShareContent": "我向你分享了:{{name}} 連結:{{link}}", + "shareLinkPasswordInfo": " 密碼: {{password}}", + "createShareLink": "建立分享連結", + "usePasswordProtection": "使用密碼保護", + "sharePassword": "分享密碼", + "randomlyGenerate": "隨機生成", + "expireAutomatically": "自動過期", + "downloadLimitOptions": "{{num}} 次下載", + "or": "或者", + "5minutes": "5 分鐘", + "1hour": "1 小時", + "1day": "1 天", + "7days": "7 天", + "30days": "30 天", + "custom": "自訂", + "seconds": "秒", + "downloads": "次下載", + "downloadSuffix": "後過期", + "allowPreview": "允許預覽", + "allowPreviewDescription": "是否允許在分享頁面預覽文件內容", + "shareLink": "分享連結", + "sendLink": "發送連結", + "directoryDownloadReplaceNotifiction": "已覆蓋 {{name}}", + "directoryDownloadSkipNotifiction": "已跳過 {{name}}", + "directoryDownloadTitle": "下載", + "directoryDownloadStarted": "開始下載 {{name}}", + "directoryDownloadFinished": "下載完成", + "directoryDownloadError": "遇到錯誤:{{msg}}", + "directoryDownloadErrorNotification": "下載 {{name}} 遇到錯誤:{{msg}}", + "directoryDownloadAutoscroll": "自動滾動", + "directoryDownloadCancelled": "已取消下載", + "advanceOptions": "高級選項", + "forceDelete": "強制刪除文件", + "forceDeleteDes": "強制刪除文件記錄,無論物理文件是否被成功刪除", + "unlinkOnly": "僅解除連結", + "unlinkOnlyDes": "僅刪除文件記錄,物理文件不會被刪除" + }, + "uploader": { + "fileNotMatchError": "所選擇文件與原始文件不符", + "unknownError": "出現未知錯誤:{{msg}}", + "taskListEmpty": "沒有上傳任務", + "hideTaskList": "隱藏列表", + "uploadTasks": "上傳隊列", + "moreActions": "更多操作", + "addNewFiles": "新增新文件", + "toggleTaskList": "展開/摺疊隊列", + "pendingInQueue": "排隊中...", + "preparing": "準備中...", + "processing": "處理中...", + "progressDescription": "已上傳 {{uploaded}} , 共 {{total}} - {{percentage}}%", + "progressDescriptionFull": "{{speed}} 已上傳 {{uploaded}} , 共 {{total}} - {{percentage}}%", + "progressDescriptionPlaceHolder": "已上傳 - ", + "uploadedTo": "已上傳至 ", + "rootFolder": "根目錄", + "unknownStatus": "未知", + "resumed": "斷點續傳", + "resumable": "可恢復進度", + "retry": "重試", + "deleteTask": "刪除任務記錄", + "cancelAndDelete": "取消並刪除", + "selectAndResume": "選取同樣文件並恢復上傳", + "fileName": "檔案名:", + "fileSize": "檔案大小:", + "sessionExpiredIn": "<0> 過期", + "chunkDescription": "({{total}} 個分片, 每個分片 {{size}})", + "noChunks": "(無分片)", + "destination": "儲存路徑:", + "uploadSession": "上傳會話:", + "errorDetails": "錯誤訊息:", + "uploadSessionCleaned": "上傳會話已清除", + "hideCompletedTooltip": "列表中不顯示已完成、失敗、被取消的任務", + "hideCompleted": "隱藏已完成任務", + "addTimeAscTooltip": "最先新增的任務排在最前", + "addTimeAsc": "最先新增靠前", + "addTimeDescTooltip": "最後新增的任務排在最前", + "addTimeDesc": "最後新增靠前", + "showInstantSpeedTooltip": "單個任務上傳速度展示為瞬時速度", + "showInstantSpeed": "瞬時速度", + "showAvgSpeedTooltip": "單個任務上傳速度展示為平均速度", + "showAvgSpeed": "平均速度", + "cleanAllSessionTooltip": "清空服務端所有未完成的上傳會話", + "cleanAllSession": "清空所有上傳會話", + "cleanCompletedTooltip": "清除列表中已完成、失敗、被取消的任務", + "cleanCompleted": "清除已完成任務", + "retryFailedTasks": "重試所有失敗任務", + "retryFailedTasksTooltip": "重試隊列中所有已失敗的任務", + "setConcurrentTooltip": "設定同時進行的任務數量", + "setConcurrent": "設定並行數量", + "sizeExceedLimitError": "檔案大小超出儲存策略限制(最大:{{max}})", + "suffixNotAllowedError": "儲存策略不支援上傳此副檔名的文件(目前支援:{{supported}})", + "createUploadSessionError": "無法建立上傳會話", + "deleteUploadSessionError": "無法刪除上傳會話", + "requestError": "請求失敗: {{msg}} ({{url}})", + "chunkUploadError": "分片 [{{index}}] 上傳失敗", + "conflictError": "同名文件的上傳任務已經在處理中", + "chunkUploadErrorWithMsg": "分片上傳失敗: {{msg}}", + "chunkUploadErrorWithRetryAfter": "(請在 {{retryAfter}} 秒後重試)", + "emptyFileError": "暫不支援上傳空文件至 OneDrive,請透過建立文件按鈕建立空文件", + "finishUploadError": "無法完成文件上傳", + "finishUploadErrorWithMsg": "無法完成文件上傳: {{msg}}", + "ossFinishUploadError": "無法完成文件上傳: {{msg}} ({{code}})", + "cosUploadFailed": "上傳失敗: {{msg}} ({{code}})", + "upyunUploadFailed": "上傳失敗: {{msg}}", + "parseResponseError": "無法解析響應: {{msg}} ({{content}})", + "concurrentTaskNumber": "同時上傳的任務數量", + "dropFileHere": "鬆開滑鼠開始上傳" + }, + "share": { + "expireInXDays": "{{num}} 天後到期", + "days":"{{count}} day", + "days_other":"{{count}} days", + "expireInXHours": "{{num}} 小時後到期", + "hours":"an hour", + "hours_other":"{{count}} hours", + "createdBy": "此分享由 <0>{{nick}} 建立", + "sharedBy": "<0>{{nick}} 向您分享了 {{num}} 個文件", + "files":"1 file", + "files_other":"{{count}} files", + "statistics": "{{views}} 次瀏覽 • {{downloads}} 次下載 • {{time}}", + "views":"{{count}} view", + "views_other":"{{count}} views", + "downloads":"{{count}} download", + "downloads_other":"{{count}} downloads", + "privateShareTitle": "{{nick}} 的加密分享", + "enterPassword": "輸入分享密碼", + "continue": "繼續", + "shareCanceled": "分享已取消", + "listLoadingError": "載入失敗", + "sharedFiles": "我的分享", + "createdAtDesc": "建立日期由晚到早", + "createdAtAsc": "建立日期由早到晚", + "downloadsDesc": "下載次數由大到小", + "downloadsAsc": "下載次數由小到大", + "viewsDesc": "瀏覽次數由大到小", + "viewsAsc": "瀏覽次數由小到大", + "noRecords": "沒有分享記錄.", + "sourceNotFound": "[原始對象不存在]", + "expired": "已失效", + "changeToPublic": "變更為公開分享", + "changeToPrivate": "變更為私密分享", + "viewPassword": "查看密碼", + "disablePreview": "禁止預覽", + "enablePreview": "允許預覽", + "cancelShare": "取消分享", + "sharePassword": "分享密碼", + "readmeError": "無法讀取 README 內容:{{msg}}", + "enterKeywords": "請輸入搜尋關鍵字", + "searchResult": "搜尋結果", + "sharedAt": "分享於 <0>", + "pleaseLogin": "請先登入", + "cannotShare": "此文件無法預覽", + "preview": "預覽", + "incorrectPassword": "密碼不正確", + "shareNotExist": "分享不存在或已過期" + }, + "download": { + "failedToLoad": "載入失敗", + "active": "進行中", + "finished": "已完成", + "activeEmpty": "沒有下載中的任務", + "finishedEmpty": "沒有已完成的任務", + "loadMore": "載入更多", + "taskFileDeleted": "文件已刪除", + "unknownTaskName": "[未知]", + "taskCanceled": "任務已取消,狀態會在稍後更新", + "operationSubmitted": "操作成功,狀態會在稍後更新", + "deleteThisFile": "刪除此文件", + "openDstFolder": "打開存放目錄", + "selectDownloadingFile": "選擇要下載的文件", + "cancelTask": "取消任務", + "updatedAt": "更新於:", + "uploaded": "上傳大小:", + "uploadSpeed": "上傳速度:", + "InfoHash": "InfoHash:", + "seederCount": "做種者:", + "seeding": "做種中:", + "downloadNode": "節點:", + "isSeeding": "是", + "notSeeding": "否", + "chunkSize": "分片大小:", + "chunkNumbers": "分片數量:", + "taskDeleted": "刪除成功", + "transferFailed": "文件轉存失敗", + "downloadFailed": "下載出錯:{{msg}}", + "canceledStatus": "已取消", + "finishedStatus": "已完成", + "pending": "已完成,轉存排隊中", + "transferring": "已完成,轉存中", + "deleteRecord": "刪除記錄", + "createdAt": "建立日期:" + }, + "setting": { + "avatarUpdated": "頭像已更新,重新整理後生效", + "nickChanged": "暱稱已更改,重新整理後生效", + "settingSaved": "設定已儲存", + "themeColorChanged": "主題配色已更換", + "profile": "個人資料", + "avatar": "頭像", + "uid": "UID", + "nickname": "暱稱", + "group": "用戶組", + "regTime": "註冊時間", + "privacyAndSecurity": "安全隱私", + "profilePage": "個人首頁", + "accountPassword": "登入密碼", + "2fa": "二步驗證", + "enabled": "已開啟", + "disabled": "未開啟", + "appearance": "個性化", + "themeColor": "主題配色", + "darkMode": "黑暗模式", + "syncWithSystem": "跟隨系統", + "fileList": "文件列表", + "timeZone": "時區", + "webdavServer": "連接地址", + "userName": "使用者名稱", + "manageAccount": "帳號管理", + "uploadImage": "從文件上傳", + "useGravatar": "使用 Gravatar 頭像 ", + "changeNick": "修改暱稱", + "originalPassword": "原密碼", + "enable2FA": "啟用二步驗證", + "disable2FA": "關閉二步驗證", + "2faDescription": "請使用任意二步驗證APP或者支援二步驗證的密碼管理軟體掃描左側二維碼新增本站。掃描完成後請填寫二步驗證APP給出的6位驗證碼以開啟二步驗證。", + "inputCurrent2FACode": "請驗證當前二步驗證代碼。", + "timeZoneCode": "IANA 時區名稱標識", + "authenticatorRemoved": "憑證已刪除", + "authenticatorAdded": "驗證器已新增", + "browserNotSupported": "當前瀏覽器或環境不支援", + "removedAuthenticator": "刪除憑證", + "removedAuthenticatorConfirm": "確定要吊銷這個憑證嗎?", + "addNewAuthenticator": "新增新驗證器", + "hardwareAuthenticator": "外部認證器", + "copied": "已複製到剪切板", + "pleaseManuallyCopy": "當前瀏覽器不支援,請手動複製", + "webdavAccounts": "WebDAV 帳號管理", + "webdavHint": "WebDAV的地址為:{{url}};登入使用者名稱統一為:{{name}} ;密碼為所建立帳號的密碼。", + "annotation": "備註名", + "rootFolder": "相對根目錄", + "createdAt": "建立日期", + "action": "操作", + "readonlyOn": "開啓只讀", + "readonlyOff": "關閉只讀", + "useProxyOn": "開啓反代", + "useProxyOff": "關閉反代", + "delete": "刪除", + "listEmpty": "沒有記錄", + "createNewAccount": "建立新帳號", + "taskType": "任務類型", + "taskStatus": "狀態", + "lastProgress": "最後進度", + "errorDetails": "錯誤訊息", + "queueing": "排隊中", + "processing": "處理中", + "failed": "失敗", + "canceled": "取消", + "finished": "已完成", + "fileTransfer": "文件中轉", + "fileRecycle": "文件回收", + "importFiles": "匯入外部目錄", + "transferProgress": "已完成 {{num}} 個文件", + "waiting": "等待中", + "compressing": "壓縮中", + "decompressing": "解壓縮中", + "downloading": "下載中", + "transferring": "轉存中", + "indexing": "索引中", + "listing": "插入中", + "allShares": "全部分享", + "trendingShares": "熱門分享", + "totalShares": "分享總數", + "fileName": "檔案名", + "shareDate": "分享日期", + "downloadNumber": "下載次數", + "viewNumber": "瀏覽次數", + "language": "語言", + "iOSApp": "iOS 用戶端", + "connectByiOS": "透過 iOS 設備連接到 <0>{{title}}", + "downloadOurApp": "下載並安裝我們的 iOS 應用:", + "fillInEndpoint": "使用我們的 iOS 應用掃描下方二維碼(其他掃碼應用無效):", + "loginApp": "完成綁定,你可以開始使用 iOS 客戶端了。如果掃碼綁定遇到問題,你也可以嘗試手動輸入用戶名和密碼登入。", + "aboutCloudreve": "關於 Cloudreve", + "githubRepo": "GitHub 倉庫", + "homepage": "主頁" + }, + "vas": { + "loginWithQQ": "使用 QQ 登錄", + "quota": "容量配額", + "exceedQuota": "您的已用容量已超過容量配額,請儘快刪除多餘文件或購買容量", + "extendStorage": "擴容", + "folderPolicySwitched": "目錄存儲策略已切換", + "switchFolderPolicy": "切換目錄存儲策略", + "setPolicyForFolder": "爲當前目錄設置存儲策略: ", + "manageMount": "管理綁定", + "saveToMyFiles": "保存到我的文件", + "report": "舉報", + "migrateStoragePolicy": "轉移存儲策略", + "fileSaved": "文件已保存", + "sharePurchaseTitle": "確定要支付 {{score}} 積分 購買此分享?", + "sharePurchaseDescription": "購買後,您可以自由預覽、下載此分享的所有內容,一定期限內不會重複扣費。如果您已購買,請忽略此提示。", + "payToDownload": "付積分下載", + "creditToBePaid": "每人次下載需支付的積分", + "creditGainPredict": "預計每人次下載可到賬 {{num}} 積分", + "creditPrice": " ({{num}} 積分)", + "creditFree": " (免積分)", + "cancelSubscription": "解約成功,更改會在數分鐘後生效", + "qqUnlinked": "已解除與QQ賬戶的關聯", + "groupExpire": " <0> 過期", + "manuallyCancelSubscription": "手動解約當前用戶組", + "qqAccount": "QQ賬號", + "connect": "綁定", + "unlink": "解除綁定", + "credits": "積分", + "cancelSubscriptionTitle": "解約用戶組", + "cancelSubscriptionWarning": "將要退回到初始用戶組,且所支付金額無法退還,確定要繼續嗎?", + "mountPolicy": "存儲策略綁定", + "mountDescription": "爲目錄綁定存儲策略後,上傳至此目錄或此目錄下的子目錄的新文件將會使用綁定的存儲策略存儲。複製、移動到此目錄不會應用綁定的存儲策略;多個父目錄指定存儲策略時將會選擇最接近的父目錄的存儲策略。", + "mountNewFolder": "綁定新目錄", + "nsfw": "色情信息", + "malware": "包含病毒", + "copyright": "侵權", + "inappropriateStatements": "不恰當的言論", + "other": "其他", + "groupBaseQuota": "用戶組基礎容量", + "validPackQuota": "有效容量包附加容量", + "used": "已使用容量", + "total": "總容量", + "validStoragePack": "可用容量包", + "buyStoragePack": "購買容量包", + "useGiftCode": "使用激活碼兌換", + "packName": "容量包名稱", + "activationDate": "激活日期", + "validDuration": "有效期", + "expiredAt": "過期日期", + "days": "{{num}} 天", + "pleaseInputGiftCode": "請輸入激活碼", + "pleaseSelectAStoragePack": "請先選擇一個容量包", + "selectPaymentMethod": "選擇支付方式:", + "noAvailableMethod": "無可用支付方式", + "alipay": "支付寶掃碼", + "wechatPay": "微信掃碼", + "payByCredits": "積分支付", + "purchaseDuration": "購買時長倍數:", + "creditsNum": "充值積分數量:", + "store": "商店", + "storagePacks": "容量包", + "membership": "會員", + "buyCredits": "積分充值", + "subtotal": "當前費用:", + "creditsTotalNum": "{{num}} 積分", + "checkoutNow": "立即購買", + "recommended": "推薦", + "enterGiftCode": "輸入激活碼", + "qrcodeAlipay": "請使用 支付寶 掃描下方二維碼完成付款,付款完成後本頁面會自動刷新。", + "qrcodeWechat": "請使用 微信 掃描下方二維碼完成付款,付款完成後本頁面會自動刷新。", + "qrcodeCustom": "請掃描下方二維碼完成付款,付款完成後本頁面會自動刷新。", + "paymentCompleted": "支付完成", + "productDelivered": "您所購買的商品已到賬。", + "confirmRedeem": "確認兌換", + "productName": "商品名稱:", + "qyt": "數量:", + "duration": "時長:", + "subscribe": "購買用戶組", + "selected": "已選:", + "paymentQrcode": "付款二維碼", + "validDurationDays": "有效期:{{num}} 天", + "reportSuccessful": "舉報成功", + "additionalDescription": "補充描述", + "announcement": "公告", + "dontShowAgain": "不再顯示", + "openPaymentLink": "直接打開支付鏈接" + } +} diff --git a/public/locales/zh-TW/common.json b/public/locales/zh-TW/common.json new file mode 100644 index 0000000..841b6ec --- /dev/null +++ b/public/locales/zh-TW/common.json @@ -0,0 +1,90 @@ +{ + "pageNotFound": "頁面不存在", + "unknownError": "未知錯誤", + "errLoadingSiteConfig": "無法載入站點配置:", + "newVersionRefresh": "當前頁面有新版本可用,準備重新整理。", + "errorDetails": "錯誤詳情", + "renderError": "頁面渲染出現錯誤,請嘗試重新整理此頁面。", + "ok": "確定", + "cancel": "取消", + "select": "選擇", + "copyToClipboard": "複製", + "close": "關閉", + "intlDateTime": "{{val, datetime}}", + "timeAgoLocaleCode": "zh_TW", + "forEditorLocaleCode": "zh-TW", + "artPlayerLocaleCode": "zh-tw", + "errors": { + "401": "請先登入", + "403": "此操作被禁止", + "404": "資源不存在", + "409": "發生衝突 ({{message}})", + "40001": "輸入參數有誤 ({{message}})", + "40002": "上傳失敗", + "40003": "目錄建立失敗", + "40004": "同名對象已存在", + "40005": "簽名過期", + "40006": "不支援的儲存策略類型", + "40007": "當前用戶組無法進行此操作", + "40011": "上傳會話不存在或已過期", + "40012": "分片序號無效 ({{message}})", + "40013": "正文長度無效 ({{message}})", + "40014": "超出批次獲取外鏈限制", + "40015": "超出最大離線下載任務數量限制", + "40016": "路徑不存在", + "40017": "該帳號已被封禁", + "40018": "該帳號未啟用", + "40019": "此功能未啟用", + "40020": "用戶信箱或密碼錯誤", + "40021": "用戶不存在", + "40022": "驗證代碼不正確", + "40023": "登入會話不存在", + "40024": "無法初始化 WebAuthn", + "40025": "驗證失敗", + "40026": "驗證碼錯誤", + "40027": "驗證失敗,請重新整理網頁重試", + "40028": "郵件發送失敗", + "40029": "無效的連結", + "40030": "此連結已過期", + "40032": "此信箱已被使用", + "40033": "用戶未啟用,已重新傳送啟用郵件", + "40034": "該用戶無法被啟用", + "40035": "儲存策略不存在", + "40039": "用戶組不存在", + "40044": "文件不存在", + "40045": "無法列取目錄下的對象", + "40047": "無法初始化文件系統", + "40048": "建立任務出錯", + "40049": "檔案大小超出限制", + "40050": "文件類型不允許", + "40051": "容量空間不足", + "40052": "對象名非法,請移除特殊字元", + "40053": "不支援對根目錄執行此操作", + "40054": "話當前目錄下已經有同名文件正在上傳中,請嘗試清空上傳會話", + "40055": "文件訊息不一致", + "40056": "不支援該格式的壓縮文件", + "40057": "可用儲存策略發生變化,請重新整理文件列表並重新新增此任務", + "40058": "分享不存在或已過期", + "40069": "密碼不正確", + "40070": "此分享無法預覽", + "40071": "簽名無效", + "50001": "資料庫操作失敗 ({{message}})", + "50002": "URL 或請求簽名失敗 ({{message}})", + "50004": "I/O 操作失敗 ({{message}})", + "50005": "內部錯誤 ({{message}})", + "50010": "目標節點不可用", + "50011": "文件元訊息查詢失敗" + }, + "vasErrors": { + "40031": "此 Email 服務提供商不可用,請更換其他 Email 地址", + "40059": "不能轉存自己的分享", + "40062": "積分不足", + "40063": "當前用戶組仍未過期,請前往個人設置手動解約後繼續", + "40064": "您當前已處於此用戶組中", + "40065": "兌換碼無效", + "40066": "您已綁定了QQ賬號,請先解除綁定", + "40067": "此QQ賬號已被綁定其他賬號", + "40068": "此QQ號未綁定任何賬號", + "40072": "管理員無法升級至其他用戶組" + } +} diff --git a/public/locales/zh-TW/dashboard.json b/public/locales/zh-TW/dashboard.json new file mode 100644 index 0000000..8605d28 --- /dev/null +++ b/public/locales/zh-TW/dashboard.json @@ -0,0 +1,977 @@ +{ + "errors":{ + "40036": "預設儲存策略無法刪除", + "40037": "有 {{message}} 個文件仍在使用此儲存策略,請先刪除這些文件", + "40038": "有 {{message}} 個用戶組綁定了此儲存策略,請先解除綁定", + "40040": "無法對系統用戶組執行此操作", + "40041": "有 {{message}} 位用戶仍屬於此用戶組,請先刪除這些用戶或者更改用戶組", + "40042": "無法更改初始用戶的用戶組", + "40043": "無法對初始用戶執行此操作", + "40046": "無法對主機節點執行此操作", + "40060": "從機無法向主機發送回調請求,請檢查主機端 參數設定 - 站點訊息 - 站點URL設定,並確保從機可以連接到此地址 ({{message}})", + "40061": "Cloudreve 版本不一致 ({{message}})", + "50008": "設置項更新失敗 ({{message}})", + "50009": "跨域策略新增失敗" + }, + "nav": { + "summary": "面板首頁", + "settings": "參數設定", + "basicSetting": "站點訊息", + "publicAccess": "註冊與登入", + "email": "郵件", + "transportation": "傳輸與通信", + "appearance": "外觀", + "image": "圖像與預覽", + "captcha": "驗證碼", + "storagePolicy": "儲存策略", + "nodes": "離線下載節點", + "groups": "用戶組", + "users": "用戶", + "files": "文件", + "shares": "分享", + "tasks": "持久任務", + "remoteDownload": "離線下載", + "generalTasks": "一般任務", + "title": "控制台", + "dashboard": "Cloudreve 控制台" + }, + "summary": { + "newsletterError": "Cloudreve 公告載入失敗", + "confirmSiteURLTitle": "確定站點URL設定", + "siteURLNotSet": "您尚未設定站點URL,是否要將其設定為當前的 {{current}} ?", + "siteURLNotMatch": "您設定的站點URL與當前實際不一致,是否要將其設定為當前的 {{current}} ?", + "siteURLDescription": "此設定非常重要,請確保其與您站點的實際地址一致。你可以在 參數設定 - 站點訊息 中更改此設定。", + "ignore": "忽略", + "changeIt": "更改", + "trend": "趨勢", + "summary": "總計", + "totalUsers": "註冊用戶", + "totalFiles": "文件總數", + "publicShares": "公開分享總數", + "privateShares": "私密分享總數", + "homepage": "官方網站", + "documents": "用戶指南", + "github": "Github 倉庫", + "forum": "討論社群", + "forumLink": "https://forum.cloudreve.org", + "telegramGroup": "Telegram 群組", + "telegramGroupLink": "https://t.me/cloudreve_official", + "buyPro": "購買捐助版", + "publishedAt": "發表於 <0>", + "newsTag": "notice" + }, + "settings": { + "saved": "設定已更改", + "save": "儲存", + "basicInformation": "基本訊息", + "mainTitle": "主標題", + "mainTitleDes": "站點的主標題", + "subTitle": "副標題", + "subTitleDes": "站點的副標題", + "siteKeywords": "關鍵字", + "siteKeywordsDes": "網站關鍵字,以英文逗號分隔", + "siteDescription": "站點描述", + "siteDescriptionDes": "站點描述訊息,可能會在分享頁面摘要內展示", + "siteURL": "站點 URL", + "siteURLDes": "非常重要,請確保與實際情況一致。使用雲端儲存策略、支付平台時,請填入可以被外網訪問的地址", + "customFooterHTML": "頁尾程式碼", + "customFooterHTMLDes": "在頁面底部插入的自訂 HTML 程式碼", + "announcement": "站點公告", + "announcementDes": "展示給已登入使用者的公告,留空不展示。當此項目內容變更時,所有使用者會重新看到公告", + "pwa": "漸進式應用 (PWA)", + "smallIcon": "小圖示", + "smallIconDes": "副檔名為 ico 的小圖示地址", + "mediumIcon": "中圖示", + "mediumIconDes": "192x192 的中等圖示地址,png 格式", + "largeIcon": "大圖示", + "largeIconDes": "512x512 的大圖示地址,png 格式。此圖示還會被用於在 iOS 用戶端切換站點時展示", + "displayMode": "展示模式", + "displayModeDes": "PWA 應用新增後的展示模式", + "themeColor": "主題色", + "themeColorDes": "CSS 色值,影響 PWA 啟動畫面上狀態欄、內容頁中狀態欄、地址欄的顏色", + "backgroundColor": "背景色", + "backgroundColorDes": "CSS 色值", + "hint": "提示", + "webauthnNoHttps": "Web Authn 需要您的站點啟用 HTTPS,並確認 參數設定 - 站點訊息 - 站點URL 也使用了 HTTPS 後才能開啟。", + "accountManagement": "註冊與登入", + "allowNewRegistrations": "允許新用戶註冊", + "allowNewRegistrationsDes": "關閉後,無法再透過前台註冊新的用戶", + "emailActivation": "郵件啟用", + "emailActivationDes": "開啟後,新用戶註冊需要點擊郵件中的啟用連結才能完成。請確認郵件發送設定是否正確,否則啟用郵件無法送達。", + "captchaForSignup": "註冊驗證碼", + "captchaForSignupDes": "是否啟用註冊表單驗證碼", + "captchaForLogin": "登入驗證碼", + "captchaForLoginDes": "是否啟用登入表單驗證碼", + "captchaForReset": "找回密碼驗證碼", + "captchaForResetDes": "是否啟用找回密碼表單驗證碼", + "webauthnDes": "是否允許用戶使用綁定的外部驗證器登入,站點必須啟用 HTTPS 才能使用。", + "webauthn": "外部驗證器登入", + "defaultGroup": "預設用戶組", + "defaultGroupDes": "用戶註冊後的初始用戶組", + "testMailSent": "測試郵件已發送", + "testSMTPSettings": "發件測試", + "testSMTPTooltip": "發送測試郵件前,請先儲存已更改的郵件設定;郵件發送結果不會立即回饋,如果您長時間未收到測試郵件,請檢查 Cloudreve 在終端輸出的錯誤日誌。", + "recipient": "收件人地址", + "send": "發送", + "smtp": "發信", + "senderName": "發件人名", + "senderNameDes": "郵件中展示的發件人姓名", + "senderAddress": "發件人信箱", + "senderAddressDes": "發件信箱的地址", + "smtpServer": "SMTP 伺服器", + "smtpServerDes": "發件伺服器地址,不含埠號", + "smtpPort": "SMTP 埠", + "smtpPortDes": "發件伺服器地址埠號", + "smtpUsername": "SMTP 使用者名稱", + "smtpUsernameDes": "發信信箱使用者名稱,一般與信箱地址相同", + "smtpPassword": "SMTP 密碼", + "smtpPasswordDes": "發信信箱密碼", + "replyToAddress": "回信信箱", + "replyToAddressDes": "用戶回復系統發送的郵件時,用於接收回信的信箱", + "enforceSSL": "強制使用 SSL 連接", + "enforceSSLDes": "是否強制使用 SSL 加密連接。如果無法發送郵件,可關閉此項, Cloudreve 會嘗試使用 STARTTLS 並決定是否使用加密連接", + "smtpTTL": "SMTP 連接有效期 (秒)", + "smtpTTLDes": "有效期內建立的 SMTP 連接會被新郵件發送請求復用", + "emailTemplates": "郵件模板", + "activateNewUser": "新用戶啟用", + "activateNewUserDes": "新用戶註冊後啟用郵件的模板", + "resetPassword": "重設密碼", + "resetPasswordDes": "密碼重設郵件模板", + "sendTestEmail": "發送測試郵件", + "transportation": "傳輸", + "workerNum": "Worker 數量", + "workerNumDes": "主機節點任務隊列最多並行執行的任務數,儲存後需要重啟 Cloudreve 生效", + "transitParallelNum": "中轉並行傳輸", + "transitParallelNumDes": "任務隊列中轉任務傳輸時,最大並行協程數", + "tempFolder": "臨時目錄", + "tempFolderDes": "用於存放解壓縮、壓縮等任務產生的臨時文件的目錄路徑", + "textEditMaxSize": "文件線上編輯最大尺寸", + "textEditMaxSizeDes": "文件文件可線上編輯的最大大小,超出此大小的文件無法線上編輯。此項設定適用於純文本文件、程式碼文件、Office 文件 (WOPI)", + "failedChunkRetry": "分片錯誤重試", + "failedChunkRetryDes": "分片上傳失敗後重試的最大次數,只適用於服務端上傳或中轉", + "cacheChunks": "快取流式分片文件以用於重試", + "cacheChunksDes": "開啟後,流式中轉分片上傳時會將分片數據快取在系統臨時目錄,以便用於分片上傳失敗後的重試;\n 關閉後,流式中轉分片上傳不會額外占用硬碟空間,但分片上傳失敗後整個上傳會立即失敗。", + "resetConnection": "上傳校驗失敗時強制重設連接", + "resetConnectionDes": "開啟後,如果本次策略、頭像等數據上傳校驗失敗,伺服器會強制重設連接", + "expirationDuration": "有效期 (秒)", + "batchDownload": "打包下載", + "downloadSession": "下載會話", + "previewURL": "預覽連結", + "docPreviewURL": "Office 文件預覽連結", + "uploadSession": "上傳會話", + "uploadSessionDes": "在上傳會話有效期內,對於支援的儲存策略,用戶可以斷點續傳未完成的任務。最大可設定的值受限於不同儲存策略服務商的規則。", + "downloadSessionForShared": "分享下載會話", + "downloadSessionForSharedDes": "設定時間內重複下載分享文件,不會被記入總下載次數", + "onedriveMonitorInterval": "OneDrive 用戶端上傳監控間隔", + "onedriveMonitorIntervalDes": "每間隔所設定時間,Cloudreve 會向 OneDrive 請求檢查用戶端上傳情況已確保用戶端上傳可控", + "onedriveCallbackTolerance": "OneDrive 回調等待", + "onedriveCallbackToleranceDes": "OneDrive 用戶端上傳完成後,等待回調的最大時間,如果超出會被認為上傳失敗", + "onedriveDownloadURLCache": "OneDrive 下載請求快取", + "onedriveDownloadURLCacheDes": "OneDrive 獲取文件下載 URL 後可將結果快取,減輕熱門文件下載API請求頻率", + "slaveAPIExpiration": "從機API請求超時(秒)", + "slaveAPIExpirationDes": "主機等待從機API請求響應的超時時間", + "heartbeatInterval": "節點心跳間隔(秒)", + "heartbeatIntervalDes": "主機節點向從機節點發送心跳的間隔", + "heartbeatFailThreshold": "心跳失敗重試閾值", + "heartbeatFailThresholdDes": "主機向從機發送心跳失敗後,主機可最大重試的次數。重試失敗後,節點會進入恢復模式", + "heartbeatRecoverModeInterval": "恢復模式心跳間隔(秒)", + "heartbeatRecoverModeIntervalDes": "節點因異常被主機標記為恢復模式後,主機嘗試重新連接節點的間隔", + "slaveTransitExpiration": "從機中轉超時(秒)", + "slaveTransitExpirationDes": "從機執行文件中轉任務可消耗的最長時間", + "nodesCommunication": "節點通信", + "cannotDeleteDefaultTheme": "不能刪除預設配色", + "keepAtLeastOneTheme": "請至少保留一個配色方案", + "duplicatedThemePrimaryColor": "主色調不能與已有配色重複", + "themes": "主題配色", + "colors": "關鍵色", + "themeConfig": "色彩配置", + "actions": "操作", + "wrongFormat": "格式不正確", + "createNewTheme": "新增配色方案", + "themeConfigDoc": "https://v4.mui.com/zh/customization/default-theme/", + "themeConfigDes": "完整的配置項可在 <0>預設主題 - Material-UI 查閱。", + "defaultTheme": "預設配色", + "defaultThemeDes": "用戶未指定偏好配色時,站點預設使用的配色方案", + "appearance": "界面", + "personalFileListView": "個人文件列表預設樣式", + "personalFileListViewDes": "用戶未指定偏好樣式時,個人文件頁面列表預設樣式", + "sharedFileListView": "目錄分享頁列表預設樣式", + "sharedFileListViewDes": "用戶未指定偏好樣式時,目錄分享頁面的預設樣式", + "primaryColor": "主色調", + "primaryColorText": "主色調文字", + "secondaryColor": "輔色調", + "secondaryColorText": "輔色調文字", + "avatar": "頭像", + "gravatarServer": "Gravatar 伺服器", + "gravatarServerDes": "Gravatar 伺服器地址,可選擇使用國內鏡像", + "avatarFilePath": "頭像儲存路徑", + "avatarFilePathDes": "用戶上傳自訂頭像的儲存路徑", + "avatarSize": "頭像檔案大小限制", + "avatarSizeDes": "用戶可上傳頭像文件的最大大小", + "smallAvatarSize": "小頭像尺寸", + "mediumAvatarSize": "中頭像尺寸", + "largeAvatarSize": "大頭像尺寸", + "filePreview": "文件預覽", + "officePreviewService": "Office 文件預覽服務", + "officePreviewServiceDes": "可使用以下替換變數:", + "officePreviewServiceSrcDes": "文件 URL", + "officePreviewServiceSrcB64Des": " Base64 編碼後的文件 URL", + "officePreviewServiceName": "檔案名", + "thumbnails": "縮圖", + "localOnlyInfo": "以下設置只針對本機儲存策略有效。", + "thumbnailDoc": "有關配置縮圖的更多信息,請參閱 <0>官方文件。", + "thumbnailDocLink":"https://docs.cloudreve.org/use/thumbnails", + "thumbnailBasic": "基本設定", + "generators": "生成器", + "thumbMaxSize": "最大原始文件尺寸", + "thumbMaxSizeDes": "可生成縮圖的最大原始文件的大小,超出此大小的文件不會生成縮圖", + "generatorProxyWarning": "預設情況下,非本機儲存策略只會使用「儲存策略原生」生成器。你可以透過開啓「生成器代理」功能擴展第三方儲存策略的縮圖能力。", + "policyBuiltin": "儲存策略原生", + "policyBuiltinDes": "使用儲存提供方原生的圖像處理接口。對於本機和 S3 策略,這一生成器不可用,將會自動順沿其他生成器。對於其他儲存策略,支援的原始圖像格式和大小限制請參考 Cloudreve 文件。", + "cloudreveBuiltin":"Cloudreve 內建", + "cloudreveBuiltinDes": "使用 Cloudreve 內建的圖像處理能力,僅支援 PNG、JPEG、GIF 格式的圖片。", + "libreOffice": "LibreOffice", + "libreOfficeDes": "使用 LibreOffice 生成 Office 文件的縮圖。這一生成器依賴於任一其他圖像生成器(Cloudreve 內建 或 VIPS)。", + "vips": "VIPS", + "vipsDes": "使用 libvips 處理縮圖圖像,支援更多圖像格式,資源消耗更低。", + "thumbDependencyWarning": "LibreOffice 生成器依賴於 Cloudreve 內建 或 VIPS 生成器,請開啓其中任一生成器。", + "ffmpeg": "FFmpeg", + "ffmpegDes": "使用 FFmpeg 生成影片縮圖。", + "executable": "可執行文件", + "executableDes": "第三方生成器可執行文件的地址或命令", + "executableTest": "測試", + "executableTestSuccess": "生成器正常,版本:{{version}}", + "generatorExts": "可用副檔名", + "generatorExtsDes": "此生成器可用的文件副檔名列表,多個請使用半形逗號 , 隔開", + "ffmpegSeek": "縮圖截取位置", + "ffmpegSeekDes": "定義縮圖截取的時間,推薦選擇較小值以加速生成過程。如果超出影片實際長度,會導致縮圖截取失敗", + "generatorProxy": "生成器代理", + "enableThumbProxy": "使用生成器代理", + "proxyPolicyList": "啓動代理的儲存策略", + "proxyPolicyListDes": "可多選。選中後,儲存策略不支援原生生成縮圖的類型會由 Cloudreve 代理生成", + "thumbWidth": "縮圖寬度", + "thumbHeight": "縮圖高度", + "thumbSuffix": "縮圖文件後綴", + "thumbConcurrent": "縮圖生成並行數量", + "thumbConcurrentDes": "-1 表示自動決定", + "thumbFormat": "縮圖格式", + "thumbFormatDes": "可選:png/jpg", + "thumbQuality": "圖像質量", + "thumbQualityDes": "壓縮質量百分比,只針對 jpg 編碼有效", + "thumbGC": "生成完成後立即回收記憶體", + "captcha": "驗證碼", + "captchaType": "驗證碼類型", + "plainCaptcha": "普通", + "reCaptchaV2": "reCAPTCHA V2", + "tencentCloudCaptcha": "騰訊雲驗證碼", + "captchaProvider": "驗證碼類型", + "plainCaptchaTitle": "普通驗證碼", + "captchaWidth": "寬度", + "captchaHeight": "高度", + "captchaLength": "長度", + "captchaMode": "模式", + "captchaModeNumber": "數字", + "captchaModeLetter": "字母", + "captchaModeMath": "算數", + "captchaModeNumberLetter": "數字+字母", + "captchaElement": "驗證碼的形式", + "complexOfNoiseText": "加強干擾文字", + "complexOfNoiseDot": "加強干擾點", + "showHollowLine": "使用空心線", + "showNoiseDot": "使用噪點", + "showNoiseText": "使用干擾文字", + "showSlimeLine": "使用波浪線", + "showSineLine": "使用正弦線", + "siteKey": "Site KEY", + "siteKeyDes": "<0>應用管理頁面 獲取到的的 網站金鑰", + "siteSecret": "Secret", + "siteSecretDes": "<0>應用管理頁面 獲取到的的 秘鑰", + "secretID": "SecretId", + "secretIDDes": "<0>訪問金鑰頁面 獲取到的的 SecretId", + "secretKey": "SecretKey", + "secretKeyDes": "<0>訪問金鑰頁面 獲取到的的 SecretKey", + "tCaptchaAppID": "APPID", + "tCaptchaAppIDDes": "<0>圖形驗證頁面 獲取到的的 APPID", + "tCaptchaSecretKey": "App Secret Key", + "tCaptchaSecretKeyDes": "<0>圖形驗證頁面 獲取到的的 App Secret Key", + "staticResourceCache": "靜態公共資源快取", + "staticResourceCacheDes": "公共可訪問的靜態資源(如:本機策略直鏈、文件下載連接)的快取有效期", + "wopiClient": "WOPI 客戶端", + "wopiClientDes": "透過對接支援 WOPI 協議的線上文件處理系統,擴展 Cloudreve 的文件線上預覽和編輯能力。詳情請參考 <0>官方文件。", + "wopiDocLink": "https://docs.cloudreve.org/use/wopi", + "enableWopi": "使用 WOPI", + "wopiEndpoint": "WOPI Discovery Endpoint", + "wopiEndpointDes": "WOPI 客戶端發現 API 的端點地址", + "wopiSessionTtl": "編輯會話有效期(秒)", + "wopiSessionTtlDes": "用戶打開線上編輯文件會話的有效期,超出此期限的會話無法繼續儲存新更改" + }, + "policy": { + "sharp": "#", + "name": "名稱", + "type": "類型", + "childFiles": "下屬文件數", + "totalSize": "數據量", + "actions": "操作", + "authSuccess": "授權成功", + "policyDeleted": "儲存策略已刪除", + "newStoragePolicy": "新增儲存策略", + "all": "全部", + "local": "本機儲存", + "remote": "從機儲存", + "qiniu": "七牛", + "upyun": "又拍雲", + "oss": "阿里雲 OSS", + "cos": "騰訊雲 COS", + "onedrive": "OneDrive", + "s3": "AWS S3", + "refresh": "重新整理", + "delete": "刪除", + "edit": "編輯", + "editInProMode": "專家模式編輯", + "editInWizardMode": "嚮導模式編輯", + "selectAStorageProvider": "選擇儲存方式", + "comparesStoragePolicies": "儲存策略對比", + "comparesStoragePoliciesLink": "https://docs.cloudreve.org/use/policy/compare", + "storagePathStep": "上傳路徑", + "sourceLinkStep": "直鏈設定", + "uploadSettingStep": "上傳設定", + "finishStep": "完成", + "policyAdded": "儲存策略已新增", + "policySaved": "儲存策略已儲存", + "editLocalStoragePolicy": "修改本機儲存策略", + "addLocalStoragePolicy": "新增本機儲存策略", + "optional": "可選", + "pathMagicVarDes": "請在下方輸入文件的儲存目錄路徑,可以為絕對路徑或相對路徑(相對於 Cloudreve)。路徑中可以使用魔法變數,文件在上傳時會自動替換這些變數為相應值; 可用魔法變數可參考 <0>路徑魔法變數列表。", + "pathOfFolderToStoreFiles": "儲存目錄", + "filePathMagicVarDes": "是否需要對儲存的物理文件進行重新命名?此處的重新命名不會影響最終呈現給用戶的 檔案名。檔案名也可使用魔法變數, 可用魔法變數可參考 <0>檔案名魔法變數列表。", + "autoRenameStoredFile": "開啟重新命名", + "keepOriginalFileName": "不開啟", + "renameRule": "命名規則", + "next": "下一步", + "enableGettingPermanentSourceLink": "是否允許獲取文件永久直鏈?", + "enableGettingPermanentSourceLinkDes": "開啟後,用戶可以請求獲得能直接訪問到文件內容的直鏈,適用於圖床應用或自用。您可能還需要在用戶組設定中開啟此功能,用戶才可以獲取直鏈。", + "allowed": "允許", + "forbidden": "禁止", + "useCDN": "是否要對下載/直鏈使用 CDN?", + "useCDNDes": "開啟後,用戶訪問文件時的 URL 中的域名部分會被替換為 CDN 域名。", + "use": "使用", + "notUse": "不使用", + "cdnDomain": "選擇協議並填寫 CDN 域名", + "cdnPrefix": "CDN 前綴", + "back": "上一步", + "limitFileSize": "是否限制上傳的單檔案大小?", + "limit": "限制", + "notLimit": "不限制", + "enterSizeLimit": "輸入限制:", + "maxSizeOfSingleFile": "單檔案大小限制", + "limitFileExt": "是否限制上傳文件副檔名?", + "enterFileExt": "輸入允許上傳的文件副檔名,多個請以半形逗號 , 隔開", + "extList": "副檔名列表", + "chunkSizeLabel": "請指定分片上傳時的分片大小,填寫為 0 表示不使用分片上傳。", + "chunkSizeDes": "啟用分片上傳後,用戶上傳的文件將會被切分成分片逐個上傳到儲存端,當上傳中斷後,用戶可以選擇從上次上傳的分片後繼續開始上傳。", + "chunkSize": "分片上傳大小", + "nameThePolicy": "最後一步,為此儲存策略命名:", + "policyName": "儲存策略名", + "finish": "完成", + "furtherActions": "要使用此儲存策略,請到用戶組管理頁面,為相應用戶組綁定此儲存策略。", + "backToList": "返回儲存策略列表", + "magicVar": { + "fileNameMagicVar": "檔案名魔法變數", + "pathMagicVar": "路徑魔法變數", + "variable": "魔法變數", + "description": "描述", + "example": "範例", + "16digitsRandomString": "16 位隨機字元", + "8digitsRandomString": "8 位隨機字元", + "secondTimestamp": "秒級時間戳", + "nanoTimestamp": "奈秒級時間戳", + "uid": "用戶 ID", + "originalFileName": "原始檔案名", + "originFileNameNoext": "原始檔案名無副檔名", + "extension": "文件副檔名", + "uuidV4": "UUID V4", + "date": "日期", + "dateAndTime": "日期時間", + "year": "年份", + "month": "月份", + "day": "日", + "hour": "小時", + "minute": "分鐘", + "second": "秒", + "userUploadPath": "用戶上傳路徑" + }, + "storageNode": "儲存端配置", + "communicationOK": "通信正常", + "editRemoteStoragePolicy": "修改從機儲存策略", + "addRemoteStoragePolicy": "新增從機儲存策略", + "remoteDescription": "從機儲存策略允許你使用同樣執行了 Cloudreve 的伺服器作為儲存端, 用戶上傳下載流量透過 HTTP 直傳。", + "remoteCopyBinaryDescription": "將和主站相同版本的 Cloudreve 程序複製至要作為從機的伺服器上。", + "remoteSecretDescription": "下方為系統為您隨機生成的從機端金鑰,一般無需改動,如果有自訂需求,可將您的金鑰填入下方:", + "remoteSecret": "從機密鑰", + "modifyRemoteConfig": "修改從機配置文件。", + "addRemoteConfigDes": " 在從機端 Cloudreve 的同級目錄下新增 <0>conf.ini 文件,填入從機配置,啟動/重啟從機端 Cloudreve。以下為一個可供參考的配置例子,其中金鑰部分已幫您填寫為上一步所生成的。", + "remoteConfigDifference": "從機端配置檔案格式大致與主站端相同,區別在於:", + "remoteConfigDifference1": "<0>System 分區下的 <1>mode 欄位必須更改為 <2>slave。", + "remoteConfigDifference2": "必須指定 <0>Slave 分區下的 <1>Secret 欄位,其值為第二步裡填寫或生成的金鑰。", + "remoteConfigDifference3": "必須啟動跨域配置,即 <0>CORS 欄位的內容,具體可參考上文範例或官方文件。如果配置不正確,用戶將無法透過 Web 端向從機上傳文件。", + "inputRemoteAddress": "填寫從機地址。", + "inputRemoteAddressDes": "如果主站啟用了 HTTPS,從機也需要啟用,並在下方填入 HTTPS 協議的地址。", + "remoteAddress": "從機地址", + "testCommunicationDes": "完成以上步驟後,你可以點擊下方的測試按鈕測試通信是否正常。", + "testCommunication": "測試從機通信", + "pathMagicVarDesRemote": "請在下方輸入文件的儲存目錄路徑,可以為絕對路徑或相對路徑(相對於 從機的 Cloudreve)。路徑中可以使用魔法變數,文件在上傳時會自動替換這些變數為相應值; 可用魔法變數可參考 <0>路徑魔法變數列表。", + "storageBucket": "儲存空間", + "editQiniuStoragePolicy": "修改七牛儲存策略", + "addQiniuStoragePolicy": "新增七牛儲存策略", + "wanSiteURLDes": "在使用此儲存策略前,請確保您在 參數設定 - 站點訊息 - 站點URL 中填寫的 地址與實際相符,並且 <0>能夠被外網正常訪問。", + "createQiniuBucket": "前往 <0>七牛控制面板 建立對象儲存資源。", + "enterQiniuBucket": "在下方填寫您在七牛建立儲存空間時指定的「儲存空間名稱」:", + "qiniuBucketName": "儲存空間名稱", + "bucketTypeDes": "在下方選擇您建立的空間類型,推薦選擇「私有空間」以獲得更高的安全性。", + "privateBucket": "私有", + "publicBucket": "公有", + "bucketCDNDes": "填寫您為儲存空間綁定的 CDN 加速域名。", + "bucketCDNDomain": "CDN 加速域名", + "qiniuCredentialDes": "在七牛控制面板進入 個人中心 - 金鑰管理,在下方填寫獲得到的 AK、SK。", + "ak": "AK", + "sk": "SK", + "cannotEnableForPrivateBucket": "私有空間開啟外鏈功能後,還需要在用戶組裡設定開啟「使用重定向的外鏈」,否則無法正常生成外鏈", + "limitMimeType": "是否限制上傳文件 MimeType?", + "mimeTypeDes": "輸入允許上傳的 MimeType,多個請以半形逗號 , 隔開。七牛伺服器會偵測文件內容以判斷 MimeType,再用判斷值跟指定值進行匹配,匹配成功則允許上傳。", + "mimeTypeList": "MimeType 列表", + "chunkSizeLabelQiniu": "請指定分片上傳時的分片大小,範圍 1 MB - 1 GB。", + "createPlaceholderDes": "是否要再用戶開始上傳時就建立占位符文件並扣除用戶容量?開啟後,可以防止用戶惡意發起多個上傳請求但不完成上傳。", + "createPlaceholder": "建立占位符文件", + "notCreatePlaceholder": "不建立", + "corsSettingStep": "跨域策略", + "corsPolicyAdded": "跨域策略已新增", + "editOSSStoragePolicy": "修改阿里雲 OSS 儲存策略", + "addOSSStoragePolicy": "新增阿里雲 OSS 儲存策略", + "createOSSBucketDes": "前往 <0>OSS 管理控制台 建立 Bucket。注意:建立空間類型只能選擇 <1>標準儲存 或 <2>低頻訪問,暫不支援 <3>歸檔儲存。", + "ossBucketNameDes": "在下方填寫您建立 Bucket 時指定的 <0>Bucket 名稱:", + "bucketName": "Bucket 名稱", + "publicReadBucket": "公共讀", + "ossEndpointDes": "轉到所建立 Bucket 的概覽頁面,填寫 <0>訪問域名 欄目下 <1>外網訪問 一行中間的 <2>EndPoint(地域節點)。", + "endpoint": "EndPoint", + "endpointDomainOnly": "格式不合法,只需輸入域名部分即可", + "ossLANEndpointDes": "如果您的 Cloudreve 部署在阿里雲端計算服務中,並且與 OSS 處在同一可用區下,您可以額外指定使用內網 EndPoint 以節省流量開支。是否要在服務端發送請求時使用 OSS 內網 EndPoint?", + "intranetEndPoint": "內網 EndPoint", + "ossCDNDes": "是否要使用配套的 阿里雲CDN 加速 OSS 訪問?", + "createOSSCDNDes": "前往 <0>阿里雲 CDN 管理控制台 建立 CDN 加速域名,並設定源站為剛建立的 OSS Bucket。在下方填寫 CDN 加速域名,並選擇是否使用 HTTPS:", + "ossAKDes": "在阿里雲 <0>安全訊息管理 頁面獲取 用戶 AccessKey,並填寫在下方。", + "shouldNotContainSpace": "不能含有空格", + "nameThePolicyFirst": "為此儲存策略命名:", + "chunkSizeLabelOSS": "請指定分片上傳時的分片大小,範圍 100 KB ~ 5 GB。", + "ossCORSDes": "此儲存策略需要正確配置跨域策略後才能使用 Web 端上傳文件,Cloudreve 可以幫您自動設定,您也可以參考文件步驟手動設定。如果您已設定過此 Bucket 的跨域策略,此步驟可以跳過。", + "letCloudreveHelpMe": "讓 Cloudreve 幫我設定", + "skip": "跳過", + "editUpyunStoragePolicy": "修改又拍雲端儲存策略", + "addUpyunStoragePolicy": "新增又拍雲端儲存策略", + "createUpyunBucketDes": "前往 <0>又拍雲面板 建立雲端儲存服務。", + "storageServiceNameDes": "在下方填寫所建立的服務名稱:", + "storageServiceName": "服務名稱", + "operatorNameDes": "為此服務建立或授權有讀取、寫入、刪除權限的操作員,然後將操作員訊息填寫在下方:", + "operatorName": "操作員名", + "operatorPassword": "操作員密碼", + "upyunCDNDes": "填寫為雲端儲存服務綁定的域名,並根據實際情況選擇是否使用 HTTPS:", + "upyunOptionalDes": "此步驟可保持預設並跳過,但是強烈建議您跟隨此步驟操作。", + "upyunTokenDes": "前往所建立雲端儲存服務的 功能配置 面板,轉到 訪問配置 選項卡,開啟 Token 防盜鏈並設定密碼。", + "tokenEnabled": "已開啟 Token 防盜鏈", + "tokenDisabled": "未開啟 Token 防盜鏈", + "upyunTokenSecretDes": "填寫您所設定的 Token 防盜鏈 金鑰", + "upyunTokenSecret": "Token 防盜鏈 金鑰", + "cannotEnableForTokenProtectedBucket": "開啟 Token 防盜鏈後無法使用直鏈功能", + "callbackFunctionStep": "雲函數回調", + "callbackFunctionAdded": "回調雲函數已新增", + "editCOSStoragePolicy": "修改騰訊雲 COS 儲存策略", + "addCOSStoragePolicy": "新增騰訊雲 COS 儲存策略", + "createCOSBucketDes": "前往 <0>COS 管理控制台 建立儲存桶。", + "cosBucketNameDes": "轉到所建立儲存桶的基礎配置頁面,將 <0>空間名稱 填寫在下方:", + "cosBucketFormatError": "空間名格式不正確, 舉例:ccc-1252109809", + "cosBucketTypeDes": "在下方選擇您建立的空間的訪問權限類型,推薦選擇 <0>私有讀寫 以獲得更高的安全性,私有空間無法開啟「獲取直鏈」功能。", + "cosPrivateRW": "私有讀寫", + "cosPublicRW": "公共讀私有寫", + "cosAccessDomainDes": "轉到所建立 Bucket 的基礎配置,填寫 <0>基本訊息 欄目下 給出的 <1>訪問域名。", + "accessDomain": "訪問域名", + "cosCDNDes": "是否要使用配套的 騰訊雲CDN 加速 COS 訪問?", + "cosCDNDomainDes": "前往 <0>騰訊雲 CDN 管理控制台 建立 CDN 加速域名,並設定源站為剛建立的 COS 儲存桶。在下方填寫 CDN 加速域名,並選擇是否使用 HTTPS:", + "cosCredentialDes": "在騰訊雲 <0>訪問金鑰 頁面獲取一對訪問金鑰,並填寫在下方。請確保這對金鑰擁有 COS 和 SCF 服務的訪問權限。", + "secretId": "SecretId", + "secretKey": "SecretKey", + "cosCallbackDes": "COS 儲存桶 用戶端直傳需要借助騰訊雲的 <0>雲函數 產品以確保上傳回調可控。如果您打算將此儲存策略自用,或者分配給可信賴用戶組,此步驟可以跳過。如果是作為公有使用,請務必建立回調雲函數。", + "cosCallbackCreate": "Cloudreve 可以嘗試幫你自動建立回調雲函數,請選擇 COS 儲存桶 所在地域後繼續。建立可能會花費數秒鐘,請耐心等待。建立前請確保您的騰訊雲帳號已開啟雲函數服務。", + "cosBucketRegion": "儲存桶所在地區", + "ap-beijing": "華北地區(北京)", + "ap-chengdu": "西南地區(成都)", + "ap-guangzhou": "華南地區(廣州)", + "ap-guangzhou-open": "華南地區(廣州Open)", + "ap-hongkong": "港澳台地區(中國香港)", + "ap-mumbai": "亞太南部(孟買)", + "ap-shanghai": "華東地區(上海)", + "na-siliconvalley": "美國西部(矽谷)", + "na-toronto": "北美地區(多倫多)", + "applicationRegistration": "應用授權", + "grantAccess": "帳號授權", + "warning": "警告", + "odHttpsWarning": "您必須啟用 HTTPS 才能使用 OneDrive/SharePoint 儲存策略;啟用後同步更改 參數設定 - 站點訊息 - 站點URL。", + "editOdStoragePolicy": "修改 OneDrive/SharePoint 儲存策略", + "addOdStoragePolicy": "新增 OneDrive/SharePoint 儲存策略", + "creatAadAppDes": "前往 <0>Azure Active Directory 控制台 (國際版帳號) 或者 <1>Azure Active Directory 控制台 (世紀互聯帳號) 並登入,登入後進入<2>Azure Active Directory 管理面板,這裡登入使用的帳號和最終儲存使用的 OneDrive 所屬帳號可以不同。", + "createAadAppDes2": "進入左側 <0>應用註冊 選單,並點擊 <1>新註冊 按鈕。", + "createAadAppDes3": "填寫應用註冊表單。其中,名稱可任取;<0>受支援的帳戶類型 選擇為 <1>任何組織目錄(任何 Azure AD 目錄 - 多租戶)中的帳戶和個人 Microsoft 帳戶(例如,Skype、Xbox);<2>重定向 URI (可選) 請選擇 <3>Web,並填寫 <4>{{url}}; 其他保持預設即可", + "aadAppIDDes": "建立完成後進入應用管理的 <0>概覽 頁面,複製 <1>應用程式(用戶端) ID 並填寫在下方:", + "aadAppID": "應用程式(用戶端) ID", + "addAppSecretDes": "進入應用管理頁面左側的 <0>證書和密碼 選單,點擊 <1>新增用戶端密碼 按鈕,<2>截止期限 選擇為 <3>從不。建立完成後將用戶端密碼的值填寫在下方:", + "aadAppSecret": "用戶端密碼", + "aadAccountCloudDes": "選擇您的 Microsoft 365 帳號類型:", + "multiTenant": "國際版", + "gallatin": "世紀互聯版", + "sharePointDes": "是否將文件存放在 SharePoint 中?", + "saveToSharePoint": "存到指定 SharePoint 中", + "saveToOneDrive": "存到帳號預設 OneDrive 驅動器中", + "spSiteURL": "SharePoint 站點地址", + "odReverseProxyURLDes": "是否要在文件下載時替換為使用自建的反代伺服器?", + "odReverseProxyURL": "反代伺服器地址", + "chunkSizeLabelOd": "請指定分片上傳時的分片大小,OneDrive 要求必須為 320 KiB (327,680 bytes) 的整數倍。", + "limitOdTPSDes": "是否限制服務端 OneDrive API 請求頻率?", + "tps": "TPS 限制", + "tpsDes": "限制此儲存策略每秒向 OneDrive 發送 API 請求最大數量。超出此頻率的請求會被限速。多個 Cloudreve 節點轉存文件時,它們會各自使用自己的限流桶,請根據情況按比例調低此數值。Web 端上傳請求並不受此限制。", + "tpsBurst": "TPS 突發請求", + "tpsBurstDes": "請求空閒時,Cloudreve 可將指定數量的名額預留給未來的突發流量使用。", + "odOauthDes": "但是你需要點擊下方按鈕,並使用 OneDrive 登入授權以完成初始化後才能使用。日後你可以在儲存策略列表頁面重新進行授權。", + "gotoAuthPage": "轉到授權頁面", + "s3SelfHostWarning": "S3 類型儲存策略目前僅可用於自己使用,或者是給受信任的用戶組使用。", + "editS3StoragePolicy": "修改 AWS S3 儲存策略", + "addS3StoragePolicy": "新增 AWS S3 儲存策略", + "s3BucketDes": "前往 AWS S3 控制台建立儲存桶,在下方填寫您建立儲存桶時指定的 <0>Bucket 名稱:", + "publicAccessDisabled": "阻止全部公共訪問權限", + "publicAccessEnabled": "允許公共讀取", + "s3EndpointDes": "(可選) 指定儲存桶的 EndPoint(地域節點),填寫為完整的 URL 格式,比如 <0>https://bucket.region.example.com。留空則將使用系統生成的預設接入點。", + "selectRegionDes": "選擇儲存桶所在的區域,或者手動輸入區域代碼", + "enterAccessCredentials": "獲取訪問金鑰,並填寫在下方。", + "accessKey": "AccessKey", + "chunkSizeLabelS3": "請指定分片上傳時的分片大小,範圍 5 MB ~ 5 GB。", + "editPolicy": "編輯儲存策略", + "setting":"設置項", + "value": "值", + "description": "描述", + "id": "ID", + "policyID": "儲存策略編號", + "policyType": "儲存策略類型", + "server": "Server", + "policyEndpoint": "儲存端 Endpoint", + "bucketID": "儲存桶標識", + "yes": "是", + "no": "否", + "privateBucketDes": "是否為私有空間", + "resourceRootURL": "文件資源根 URL", + "resourceRootURLDes": "預覽/獲取文件外鏈時生成 URL 的前綴", + "akDes": "AccessKey / 更新 Token", + "maxSizeBytes": "最大單文件尺寸 (Bytes)", + "maxSizeBytesDes": "最大可上傳的文件尺寸,填寫為 0 表示不限制", + "autoRename": "自動重新命名", + "autoRenameDes": "是否根據規則對上傳物理文件重新命名", + "storagePath": "儲存路徑", + "storagePathDes": "文件物理儲存路徑", + "fileName": "儲存檔案名", + "fileNameDes": "文件物理儲存檔案名", + "allowGetSourceLink": "允許獲取外鏈", + "allowGetSourceLinkDes": "是否允許獲取外鏈。注意,某些儲存策略類型不支援,即使在此開啟,獲取的外鏈也無法使用", + "upyunToken": "又拍雲防盜鏈 Token", + "upyunOnly": "僅對又拍雲端儲存策略有效", + "allowedFileExtension": "允許文件副檔名", + "emptyIsNoLimit": "留空表示不限制", + "allowedMimetype": "允許的 MimeType", + "qiniuOnly": "僅對七牛儲存策略有效", + "odRedirectURL": "OneDrive 重定向地址", + "noModificationNeeded": "一般新增後無需修改", + "odReverseProxy": "OneDrive 反代伺服器地址", + "odOnly": "僅對 OneDrive 儲存策略有效", + "odDriverID": "OneDrive/SharePoint 驅動器資源標識", + "odDriverIDDes": "僅對 OneDrive 儲存策略有效,留空則使用用戶的預設 OneDrive 驅動器", + "s3Region": "Amazon S3 Region", + "s3Only": "僅對 Amazon S3 儲存策略有效", + "lanEndpoint": "內網 EndPoint", + "ossOnly": "僅對 OSS 儲存策略有效", + "chunkSizeBytes": "上傳分片大小 (Bytes)", + "chunkSizeBytesDes": "分片上傳時單個分片的大小,僅部分儲存策略支援", + "placeHolderWithSize": "上傳前預支用戶儲存", + "placeHolderWithSizeDes": "是否在上傳會話建立時就對用戶儲存進行預支,僅部分儲存策略支援", + "saveChanges": "儲存更改", + "s3EndpointPathStyle": "選擇 S3 Endpoint 地址的格式,如果您不知道該選什麼,保持預設即可。某些第三方 S3 相容儲存策略可能需要更改此選項。開啟後,將會強制使用路徑格式地址,比如 <0>http://s3.amazonaws.com/BUCKET/KEY。", + "usePathEndpoint": "強制路徑格式", + "useHostnameEndpoint": "主機名優先", + "thumbExt": "可生成縮圖的文件副檔名", + "thumbExtDes": "留空表示使用儲存策略預定義集合。對本機、S3儲存策略無效" + }, + "node": { + "#": "#", + "name": "名稱", + "status": "當前狀態", + "features": "已啟用功能", + "action": "操作", + "remoteDownload": "離線下載", + "nodeDisabled": "節點已暫停使用", + "nodeEnabled": "節點已啟用", + "nodeDeleted": "節點已刪除", + "disabled": "未啟用", + "online": "線上", + "offline": "離線", + "addNewNode": "接入新節點", + "refresh": "重新整理", + "enableNode": "啟用節點", + "disableNode": "暫停使用節點", + "edit": "編輯", + "delete": "刪除", + "slaveNodeDes": "您可以新增同樣執行了 Cloudreve 的伺服器作為從機端,正常執行工作的從機端可以為主機分擔某些非同步任務(如離線下載)。請參考下面嚮導部署並配置連接 Cloudreve 從機節點。<0>如果你已經在目標伺服器上部署了從機儲存策略,您可以跳過本頁面的某些步驟,只將從機密鑰、伺服器地址在這裡填寫並保持與從機儲存策略中一致即可。 在後續版本中,從機儲存策略的相關配置會合併到這裡。", + "overwriteDes": "; 以下為可選的設定,對應主機節點的相關參數,可以透過配置文件應用到從機節點,請根據<0>; 實際情況調整。更改下面設定需要重啟從機節點後生效。", + "workerNumDes": "任務隊列最多並行執行的任務數", + "parallelTransferDes": "任務隊列中轉任務傳輸時,最大並行協程數", + "chunkRetriesDes": "中轉分片上傳失敗後重試的最大次數", + "multipleMasterDes": "一個從機 Cloudreve 實例可以對接多個 Cloudreve 主節點,只需在所有主節點中新增此從機節點並保持金鑰一致即可。", + "ariaSuccess": "連接成功,Aria2 版本為:{{version}}", + "slave": "從機", + "master": "主機", + "aria2Des": "Cloudreve 的離線下載功能由 <0>Aria2 驅動。如需使用,請在目標節點伺服器上以和執行 Cloudreve 相同的用戶身份啟動 Aria2, 並在 Aria2 的配置文件中開啟 RPC 服務,<1>Aria2 需要和{{mode}} Cloudreve 進程共用相同的文件系統。 更多訊息及指引請參考文件的 <2>離線下載 章節。", + "slaveTakeOverRemoteDownload": "是否需要此節點接管離線下載任務?", + "masterTakeOverRemoteDownload": "是否需要主機接管離線下載任務?", + "routeTaskSlave": "開啟後,用戶的離線下載請求可以被分流到此節點處理。", + "routeTaskMaster": "開啟後,用戶的離線下載請求可以被分流到主機處理。", + "enable": "啟用", + "disable": "關閉", + "slaveNodeTarget": "在目標節點伺服器上與節點", + "masterNodeTarget": "在與", + "aria2ConfigDes": "{{target}} Cloudreve 進程相同的文件系統環境下啟動 Aria2 進程。在啟動 Aria2 時,需要在其配置文件中啟用 RPC 服務,並設定 RPC Secret,以便後續使用。以下為一個供參考的配置:", + "enableRPCComment": "啟用 RPC 服務", + "rpcPortComment": "RPC 監聽埠", + "rpcSecretComment": "RPC 授權令牌,可自行設定", + "rpcConfigDes": "推薦在日常啟動流程中,先啟動 Aria2,再啟動節點 Cloudreve,這樣節點 Cloudreve 可以向 Aria2 訂閱事件通知,下載狀態變更處理更及時。當然,如果沒有這一流程,節點 Cloudreve 也會透過輪詢追蹤任務狀態。", + "rpcServerDes": "在下方填寫{{mode}} Cloudreve 與 Aria2 通信的 RPC 服務地址。一般可填寫為 <0>http://127.0.0.1:6800/,其中埠號 <1>6800 與上文配置文件中 <2>rpc-listen-port保持一致。", + "rpcServer": "RPC 伺服器地址", + "rpcServerHelpDes": "包含埠的完整 RPC 伺服器地址,例如:http://127.0.0.1:6800/,留空表示不啟用 Aria2 服務", + "rpcTokenDes": "RPC 授權令牌,與 Aria2 配置文件中 <0>rpc-secret 保持一致,未設定請留空。", + "aria2PathDes": "在下方填寫 Aria2 用作臨時下載目錄的 節點上的 <0>絕對路徑,節點上的 Cloudreve 進程需要此目錄的讀、寫、執行權限。", + "aria2SettingDes": "在下方按需要填寫一些 Aria2 額外參數訊息。", + "refreshInterval": "狀態更新間隔 (秒)", + "refreshIntervalDes": "Cloudreve 向 Aria2 請求更新任務狀態的間隔。", + "rpcTimeout": "RPC 調用超時 (秒)", + "rpcTimeoutDes": "調用 RPC 服務時最長等待時間", + "globalOptions": "全局任務參數", + "globalOptionsDes": "建立下載任務時攜帶的額外設定參數,以 JSON 編碼後的格式書寫,您可也可以將這些設定寫在 Aria2 配置文件裡,可用參數請查閱官方文件", + "testAria2Des": "完成以上步驟後,你可以點擊下方的測試按鈕測試{{mode}} Cloudreve 向 Aria2 通信是否正常。", + "testAria2DesSlaveAddition": "在進行測試前請先確保您已進行並透過上一頁面中的「從機通信測試」。", + "testAria2": "測試 Aria2 通信", + "aria2DocURL": "https://docs.cloudreve.org/use/aria2", + "nameNode": "為此節點命名:", + "loadBalancerRankDes": "為此節點指定負載均衡權重,數值為整數。某些負載均衡策略會根據此數值加權選擇節點", + "loadBalancerRank": "負載均衡權重", + "nodeSaved": "節點已儲存!", + "nodeSavedFutureAction": "如果您新增了新節點,還需要在節點列表手動啟動節點才能正常使用。", + "backToNodeList": "返回節點列表", + "communication": "通信配置", + "otherSettings": "雜項訊息", + "finish": "完成", + "nodeAdded": "節點已新增", + "nodeSavedNow": "節點已儲存", + "editNode": "編輯節點", + "addNode": "新增節點" + }, + "group": { + "#": "#", + "name": "名稱", + "type": "儲存策略", + "count": "下屬用戶數", + "size": "最大容量", + "action": "操作", + "deleted": "用戶組已刪除", + "new": "新增用戶組", + "aria2FormatError": "Aria2 設置項格式錯誤", + "atLeastOnePolicy": "至少要為用戶組選擇一個儲存策略", + "added": "用戶組已新增", + "saved": "用戶組已儲存", + "editGroup": "編輯 {{group}}", + "nameOfGroup": "用戶組名", + "nameOfGroupDes": "用戶組的名稱", + "storagePolicy": "儲存策略", + "storageDes": "指定用戶組的儲存策略。", + "initialStorageQuota": "初始容量", + "initialStorageQuotaDes": "用戶組下的用戶初始可用最大容量", + "downloadSpeedLimit": "下載限速", + "downloadSpeedLimitDes": "填寫為 0 表示不限制。開啟限制後,此用戶組下的用戶下載所有支援限速的儲存策略下的文件時,下載最大速度會被限制。", + "bathSourceLinkLimit": "批次生成外鏈數量限制", + "bathSourceLinkLimitDes": "對於支援的儲存策略下的文件,允許用戶單次批次獲取外鏈的最大文件數量,填寫為 0 表示不允許批次生成外鏈。", + "allowCreateShareLink": "允許建立分享", + "allowCreateShareLinkDes": "關閉後,用戶無法建立分享連結", + "allowDownloadShare": "允許下載分享", + "allowDownloadShareDes": "關閉後,用戶無法下載別人建立的文件分享", + "allowWabDAV": "WebDAV", + "allowWabDAVDes": "關閉後,用戶無法透過 WebDAV 協議連接至網路硬碟", + "allowWabDAVProxy": "WebDAV 代理", + "allowWabDAVProxyDes": "啓用後, 用戶可以配置 WebDAV 代理下載文件的流量", + "disableMultipleDownload": "禁止多次下載請求", + "disableMultipleDownloadDes": "只針對本機儲存策略有效。開啟後,用戶無法使用多執行緒下載工具。", + "allowRemoteDownload": "離線下載", + "allowRemoteDownloadDes": "是否允許用戶建立離線下載任務", + "aria2Options": "Aria2 任務參數", + "aria2OptionsDes": "此用戶組建立離線下載任務時額外攜帶的參數,以 JSON 編碼後的格式書寫,您可也可以將這些設定寫在 Aria2 配置文件裡,可用參數請查閱官方文件", + "aria2BatchSize": "Aria2 批次下載最大數量", + "aria2BatchSizeDes": "允許用戶同時進行的離線下載任務數量,填寫為 0 或留空表示不限制。", + "serverSideBatchDownload": "服務端打包下載", + "serverSideBatchDownloadDes": "是否允許用戶多選文件使用服務端中轉打包下載,關閉後,用戶仍然可以使用純 Web 端打包下載功能。", + "compressTask": "壓縮/解壓縮 任務", + "compressTaskDes": "是否用戶建立 壓縮/解壓縮 任務", + "compressSize": "待壓縮文件最大大小", + "compressSizeDes": "用戶可建立的壓縮任務的文件最大總大小,填寫為 0 表示不限制", + "decompressSize": "待解壓文件最大大小", + "decompressSizeDes": "用戶可建立的解壓縮任務的文件最大總大小,填寫為 0 表示不限制", + "redirectedSource": "使用重定向的外鏈", + "redirectedSourceDes": "開啟後,用戶獲取的文件外鏈將由 Cloudreve 中轉,連結較短。關閉後,用戶獲取的文件外鏈會變成文件的原始連結。部分儲存策略獲取的非中轉外鏈無法保持永久有效,請參閱 <0>比較儲存策略。", + "advanceDelete": "允許使用高級文件刪除選項", + "advanceDeleteDes": "開啓後,用戶在前臺刪除文件時可以選擇是否強制刪除、是否僅解除物理連結。這些選項與後臺管理面板刪除文件時類似,請只開放給可信用戶組。" + }, + "user": { + "deleted": "用戶已刪除", + "new": "新增用戶", + "filter": "過濾", + "selectedObjects": "已選擇 {{num}} 個對象", + "nick": "暱稱", + "email": "Email", + "group": "用戶組", + "status": "狀態", + "usedStorage": "已用空間", + "active": "正常", + "notActivated": "未啟用", + "banned": "被封禁", + "bannedBySys": "超額封禁", + "toggleBan": "封禁/解封", + "filterCondition": "過濾條件", + "all": "全部", + "userStatus": "用戶狀態", + "searchNickUserName": "搜尋 暱稱 / 使用者名稱", + "apply": "應用", + "added": "用戶已新增", + "saved": "用戶已儲存", + "editUser": "編輯 {{nick}}", + "password": "密碼", + "passwordDes": "留空表示不修改", + "groupDes": "用戶所屬用戶組", + "2FASecret": "二步驗證金鑰", + "2FASecretDes": "用戶二步驗證器的金鑰,清空表示未啟用。" + }, + "file": { + "name": "檔案名", + "deleteAsync": "刪除任務將在後台執行", + "import": "從外部匯入", + "forceDelete": "強制刪除", + "size": "大小", + "uploader": "上傳者", + "createdAt": "建立於", + "uploading": "上傳中", + "unknownUploader": "未知", + "uploaderID": "上傳者 ID", + "searchFileName": "搜尋檔案名", + "storagePolicy": "儲存策略", + "selectTargetUser": "請先選擇目標用戶", + "importTaskCreated": "匯入任務已建立,您可以在「持久任務」中查看執行情況", + "manuallyPathOnly": "選擇的儲存策略只支援手動輸入路徑", + "selectFolder": "選擇目錄", + "importExternalFolder": "匯入外部目錄", + "importExternalFolderDes": "您可以將儲存策略中已有文件、目錄結構匯入到 Cloudreve 中,匯入操作不會額外占用物理儲存空間,但仍會正常扣除用戶已用容量空間,空間不足時將停止匯入。", + "storagePolicyDes": "選擇要匯入文件目前儲存所在的儲存策略", + "targetUser": "目標用戶", + "targetUserDes": "選擇要將文件匯入到哪個用戶的文件系統中,可透過暱稱、信箱搜尋用戶", + "srcFolderPath": "原始目錄路徑", + "select": "選擇", + "selectSrcDes": "要匯入的目錄在儲存端的路徑", + "dstFolderPath": "目的目錄路徑", + "dstFolderPathDes": "要將目錄匯入到用戶文件系統中的路徑", + "recursivelyImport": "遞迴匯入子目錄", + "recursivelyImportDes": "是否將目錄下的所有子目錄遞迴匯入", + "createImportTask": "建立匯入任務", + "unlink": "解除關聯(保留物理文件)" + }, + "share": { + "deleted": "分享已刪除", + "objectName": "對象名", + "views": "瀏覽", + "downloads": "下載", + "price": "積分", + "autoExpire": "自動過期", + "owner": "分享者", + "createdAt": "分享於", + "public": "公開", + "private": "私密", + "afterNDownloads":"{{num}} 次下載後", + "none": "無", + "srcType": "源文件類型", + "folder": "目錄", + "file": "文件" + }, + "task": { + "taskDeleted": "任務已刪除", + "howToConfigAria2": "如何配置離線下載?", + "srcURL": "源地址", + "node": "處理節點", + "createdBy": "建立者", + "ready": "就緒", + "downloading": "下載中", + "paused": "暫停中", + "seeding": "做種中", + "error": "出錯", + "finished": "完成", + "canceled": "取消/停止", + "unknown": "未知", + "aria2Des": "Cloudreve 的離線下載支援主從分散模式。您可以配置多個 Cloudreve 從機節點,這些節點可以用來處理離線下載任務,分散主節點的壓力。當然,您也可以配置只在主節點上處理離線下載任務,這是最簡單的一種方式。", + "masterAria2Des": "如果您只需要為主機啟用離線下載功能,請 <0>點擊這裡 編輯主節點;", + "slaveAria2Des": "如果您想要在從機節點上分散處理離線下載任務,請 <0>點擊這裡 新增並配置新節點。", + "editGroupDes": "當你新增多個可用於離線下載的節點後,主節點會將離線下載請求輪流發送到這些節點處理。節點離線下載配置完成後,您可能還需要 <0>到這裡 編輯用戶組,為對應用戶組開啟離線下載權限。", + "lastProgress": "最後進度", + "errorMsg": "錯誤訊息" + }, + "vas": { + "vas": "增值服務", + "reports": "舉報", + "orders": "訂單", + "initialFiles": "初始文件", + "initialFilesDes": "指定用戶註冊後初始擁有的文件。輸入文件 ID 搜索並添加現有文件。", + "filterEmailProvider": "郵箱過濾", + "filterEmailProviderDisabled": "不啓用", + "filterEmailProviderWhitelist": "白名單", + "filterEmailProviderBlacklist": "黑名單", + "filterEmailProviderDes": "過濾註冊郵箱域", + "filterEmailProviderRule": "郵箱域過濾規則", + "filterEmailProviderRuleDes": "多個域請使用半角逗號隔開", + "qqConnect": "QQ互聯", + "qqConnectHint": "創建應用時,回調地址請填寫:{{url}}", + "enableQQConnect": "開啓QQ互聯", + "enableQQConnectDes": "是否允許綁定QQ、使用QQ登錄本站", + "loginWithoutBinding": "未綁定時可直接登錄", + "loginWithoutBindingDes": "開啓後,如果用戶使用了QQ登錄,但是沒有已綁定的註冊用戶,系統會爲其創建用戶並登錄。這種方式創建的用戶日後只能使用QQ登錄。", + "appid": "APP ID", + "appidDes": "應用管理頁面獲取到的的 APP ID", + "appKey": "APP KEY", + "appKeyDes": "應用管理頁面獲取到的的 APP KEY", + "overuseReminder": "超額提醒", + "overuseReminderDes": "用戶因增值服務過期,容量超出限制後發送的提醒郵件模板", + "vasSetting": "支付/雜項設置", + "storagePack": "容量包", + "purchasableGroups": "可購用戶組", + "giftCodes": "兌換碼", + "alipay": "支付寶當面付", + "enable": "開啓", + "appID": "App- ID", + "appIDDes": "當面付應用的 APPID", + "rsaPrivate": "RSA 應用私鑰", + "rsaPrivateDes": "當面付應用的 RSA2 (SHA256) 私鑰,一般是由您自己生成。詳情參考 <0>生成 RSA 密鑰。", + "alipayPublicKey": "支付寶公鑰", + "alipayPublicKeyDes": "由支付寶提供,可在 應用管理 - 應用信息 - 接口加簽方式 中獲取。", + "wechatPay": "微信官方掃碼支付", + "applicationID": "應用 ID", + "applicationIDDes": "直連商戶申請的公衆號或移動應用appid", + "merchantID": "直連商戶號", + "merchantIDDes": "直連商戶的商戶號,由微信支付生成並下發。", + "apiV3Secret": "API v3 密鑰", + "apiV3SecretDes": "商戶需先在【商戶平臺】-【API安全】的頁面設置該密鑰,請求才能通過微信支付的簽名校驗。密鑰的長度爲 32 個字節。", + "mcCertificateSerial": "商戶證書序列號", + "mcCertificateSerialDes": "登錄商戶平臺【API安全】-【API證書】-【查看證書】,可查看商戶 API 證書序列號。", + "mcAPISecret": "商戶API 私鑰", + "mcAPISecretDes": "私鑰文件 apiclient_key.pem 的內容。", + "payjs": "PAYJS 微信支付", + "payjsWarning": "此服務由第三方平臺 <0>PAYJS 提供, 產生的任何糾紛與 Cloudreve 開發者無關。", + "mcNumber": "商戶號", + "mcNumberDes": "可在 PAYJS 管理面板首頁看到", + "communicationSecret": "通信密鑰", + "otherSettings": "雜項設置", + "banBufferPeriod": "封禁緩衝期 (秒)", + "banBufferPeriodDes": "用戶保持容量超額狀態的最長時長,超出時長該用戶會被系統凍結。", + "allowSellShares": "允許爲分享定價", + "allowSellSharesDes": "開啓後,用戶可爲分享設定積分價格,下載需要扣除積分。", + "creditPriceRatio": "積分到賬比率 (%)", + "creditPriceRatioDes": "購買下載設定價格的分享,分享者實際到賬的積分比率。", + "creditPrice": "積分價格 (分)", + "creditPriceDes": "充值積分時的價格", + "allowReportShare": "允許舉報分享", + "allowReportShareDes": "開啟後,任意用戶可對分享進行檢舉,有資料庫被填滿的風險", + "add": "添加", + "name": "名稱", + "price": "單價", + "duration": "時長", + "size": "大小", + "actions": "操作", + "orCredits": " 或 {{num}} 積分", + "highlight": "突出展示", + "yes": "是", + "no": "否", + "productName": "商品名", + "qyt": "數量", + "code": "兌換碼", + "status": "狀態", + "invalidProduct": "已失效商品", + "used": "已使用", + "notUsed": "未使用", + "generatingResult": "生成結果", + "addStoragePack": "添加容量包", + "editStoragePack": "編輯容量包", + "productNameDes": "商品展示名稱", + "packSizeDes": "容量包的大小", + "durationDay": "有效期 (天)", + "durationDayDes": "每個容量包的有效期", + "priceYuan": "單價 (元)", + "packPriceDes": "容量包的單價", + "priceCredits": "單價 (積分)", + "priceCreditsDes": "使用積分購買時的價格,填寫爲 0 表示不能使用積分購買", + "editMembership": "編輯可購用戶組", + "addMembership": "添加可購用戶組", + "group": "用戶組", + "groupDes": "購買後升級的用戶組", + "durationGroupDes": "購買後升級的用戶組單位購買時間的有效期", + "groupPriceDes": "用戶組的單價", + "productDescription": "商品描述 (一行一個)", + "productDescriptionDes": "購買頁面展示的商品描述", + "highlightDes": "開啓後,在商品選擇頁面會被突出展示", + "generateGiftCode": "生成兌換碼", + "numberOfCodes": "生成數量", + "numberOfCodesDes": "激活碼批量生成數量", + "linkedProduct": "對應商品", + "productQyt": "商品數量", + "productQytDes": "對於積分類商品,此處爲積分數量,其他商品爲時長倍數", + "freeDownload": "免積分下載分享", + "freeDownloadDes": "開啓後,用戶可以免費下載需付積分的分享", + "credits": "積分", + "markSuccessful": "標記成功", + "markAsResolved": "標記爲已處理", + "reportedContent": "舉報對象", + "reason": "原因", + "description": "補充描述", + "reportTime": "舉報時間", + "invalid": "[已失效]", + "deleteShare": "刪除分享", + "orderDeleted": "訂單記錄已刪除", + "orderName": "訂單名", + "product": "商品", + "orderNumber": "訂單號", + "paidBy": "支付方式", + "orderOwner": "創建者", + "unpaid": "未支付", + "paid": "已支付", + "shareLink": "分享鏈接", + "volPurchase": "客戶端 VOL 授權需要單獨在 <0>授權管理面板 購買。VOL 授權允許您的用戶免費使用 <1>Cloudreve iOS 客戶端 連接到您的站點,無需用戶再付費訂閱 iOS 客戶端。購買授權後請點擊下方同步授權。", + "iosVol": "iOS 客戶端批量授權 (VOL)", + "mobileApp": "移動客戶端", + "syncLicense": "同步授權", + "volSynced": "VOL 授權已同步", + "showAppPromotion": "展示客戶端引導頁面", + "showAppPromotionDes": "開啓後,用戶可以在 “連接與掛載” 頁面中看到移動客戶端的使用引導", + "customPayment": "自定義付款渠道", + "customPaymentDes":"通過實現 Cloudreve 兼容付款接口來對接其他第三方支付平臺,詳情請參考 <0>官方文檔。", + "customPaymentDocumentLink":"https://docs.cloudreve.org/use/pro/pay", + "customPaymentName": "付款方式名稱", + "customPaymentNameDes": "用於展示給用戶的付款方式名稱", + "customPaymentSecretDes": "Cloudreve 用於簽名付款請求的密鑰", + "customPaymentEndpoint":"支付接口地址", + "customPaymentEndpointDes":"創建支付訂單時請求的接口 URL", + "appFeedback": "反饋頁面 URL", + "appForum": "用戶論壇 URL", + "appLinkDes": "用於在 App 設置頁面展示,留空即不展示鏈接按鈕,僅當 VOL 授權有效時此項設置纔會生效。" + } +} diff --git a/public/static/img/appstore.svg b/public/static/img/appstore.svg new file mode 100644 index 0000000..072b425 --- /dev/null +++ b/public/static/img/appstore.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/img/cloudreve.svg b/public/static/img/cloudreve.svg new file mode 100644 index 0000000..0a2c6c1 --- /dev/null +++ b/public/static/img/cloudreve.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/img/cos.png b/public/static/img/cos.png new file mode 100644 index 0000000..f511b89 Binary files /dev/null and b/public/static/img/cos.png differ diff --git a/public/static/img/favicon.ico b/public/static/img/favicon.ico new file mode 100644 index 0000000..82bcc51 Binary files /dev/null and b/public/static/img/favicon.ico differ diff --git a/public/static/img/local.png b/public/static/img/local.png new file mode 100644 index 0000000..13ab953 Binary files /dev/null and b/public/static/img/local.png differ diff --git a/public/static/img/logo192.png b/public/static/img/logo192.png new file mode 100644 index 0000000..28020b0 Binary files /dev/null and b/public/static/img/logo192.png differ diff --git a/public/static/img/logo512.png b/public/static/img/logo512.png new file mode 100644 index 0000000..1f14fa3 Binary files /dev/null and b/public/static/img/logo512.png differ diff --git a/public/static/img/onedrive.png b/public/static/img/onedrive.png new file mode 100644 index 0000000..c76920c Binary files /dev/null and b/public/static/img/onedrive.png differ diff --git a/public/static/img/oss.png b/public/static/img/oss.png new file mode 100644 index 0000000..46dca72 Binary files /dev/null and b/public/static/img/oss.png differ diff --git a/public/static/img/qiniu.png b/public/static/img/qiniu.png new file mode 100644 index 0000000..5c7574b Binary files /dev/null and b/public/static/img/qiniu.png differ diff --git a/public/static/img/remote.png b/public/static/img/remote.png new file mode 100644 index 0000000..4d648fe Binary files /dev/null and b/public/static/img/remote.png differ diff --git a/public/static/img/s3.png b/public/static/img/s3.png new file mode 100644 index 0000000..544bc6f Binary files /dev/null and b/public/static/img/s3.png differ diff --git a/public/static/img/upyun.png b/public/static/img/upyun.png new file mode 100644 index 0000000..67ff1dd Binary files /dev/null and b/public/static/img/upyun.png differ diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..58c5447 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,199 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'production'; +process.env.NODE_ENV = 'production'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + + +const path = require('path'); +const chalk = require('react-dev-utils/chalk'); +const fs = require('fs-extra'); +const webpack = require('webpack'); +const configFactory = require('../config/webpack.config'); +const paths = require('../config/paths'); +const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); +const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); +const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); +const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); +const printBuildError = require('react-dev-utils/printBuildError'); + +const measureFileSizesBeforeBuild = + FileSizeReporter.measureFileSizesBeforeBuild; +const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; +const useYarn = fs.existsSync(paths.yarnLockFile); + +// These sizes are pretty large. We'll warn for bundles exceeding them. +const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; +const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; + +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +// Generate configuration +const config = configFactory('production'); + +// We require that you explicitly set browsers and do not fall back to +// browserslist defaults. +const { checkBrowsers } = require('react-dev-utils/browsersHelper'); +checkBrowsers(paths.appPath, isInteractive) + .then(() => { + // First, read the current file sizes in build directory. + // This lets us display how much they changed later. + return measureFileSizesBeforeBuild(paths.appBuild); + }) + .then(previousFileSizes => { + // Remove all content but keep the directory so that + // if you're in it, you don't end up in Trash + fs.emptyDirSync(paths.appBuild); + // Merge with the public folder + copyPublicFolder(); + // Start the webpack build + return build(previousFileSizes); + }) + .then( + ({ stats, previousFileSizes, warnings }) => { + if (warnings.length) { + console.log(chalk.yellow('Compiled with warnings.\n')); + console.log(warnings.join('\n\n')); + console.log( + '\nSearch for the ' + + chalk.underline(chalk.yellow('keywords')) + + ' to learn more about each warning.' + ); + console.log( + 'To ignore, add ' + + chalk.cyan('// eslint-disable-next-line') + + ' to the line before.\n' + ); + } else { + console.log(chalk.green('Compiled successfully.\n')); + } + + console.log('File sizes after gzip:\n'); + printFileSizesAfterBuild( + stats, + previousFileSizes, + paths.appBuild, + WARN_AFTER_BUNDLE_GZIP_SIZE, + WARN_AFTER_CHUNK_GZIP_SIZE + ); + console.log(); + + const appPackage = require(paths.appPackageJson); + const publicUrl = paths.publicUrl; + const publicPath = config.output.publicPath; + const buildFolder = path.relative(process.cwd(), paths.appBuild); + printHostingInstructions( + appPackage, + publicUrl, + publicPath, + buildFolder, + useYarn + ); + }, + err => { + const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true'; + if (tscCompileOnError) { + console.log(chalk.yellow( + 'Compiled with the following type errors (you may want to check these before deploying your app):\n' + )); + printBuildError(err); + } else { + console.log(chalk.red('Failed to compile.\n')); + printBuildError(err); + process.exit(1); + } + } + ) + .catch(err => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); + +// Create the production build and print the deployment instructions. +function build(previousFileSizes) { + // We used to support resolving modules according to `NODE_PATH`. + // This now has been deprecated in favor of jsconfig/tsconfig.json + // This lets you use absolute paths in imports inside large monorepos: + if (process.env.NODE_PATH) { + console.log( + chalk.yellow( + 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.' + ) + ); + console.log(); + } + + console.log('Creating an optimized production build...'); + + const compiler = webpack(config); + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + let messages; + if (err) { + if (!err.message) { + return reject(err); + } + messages = formatWebpackMessages({ + errors: [err.message], + warnings: [], + }); + } else { + messages = formatWebpackMessages( + stats.toJson({ all: false, warnings: true, errors: true }) + ); + } + if (messages.errors.length) { + // Only keep the first error. Others are often indicative + // of the same problem, but confuse the reader with noise. + if (messages.errors.length > 1) { + messages.errors.length = 1; + } + return reject(new Error(messages.errors.join('\n\n'))); + } + if ( + process.env.CI && + (typeof process.env.CI !== 'string' || + process.env.CI.toLowerCase() !== 'false') && + messages.warnings.length + ) { + console.log( + chalk.yellow( + '\nTreating warnings as errors because process.env.CI = true.\n' + + 'Most CI servers set it automatically.\n' + ) + ); + return reject(new Error(messages.warnings.join('\n\n'))); + } + + return resolve({ + stats, + previousFileSizes, + warnings: messages.warnings, + }); + }); + }); +} + +function copyPublicFolder() { + fs.copySync(paths.appPublic, paths.appBuild, { + dereference: true, + filter: file => file !== paths.appHtml, + }); +} diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 0000000..dd89084 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,147 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'development'; +process.env.NODE_ENV = 'development'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + + +const fs = require('fs'); +const chalk = require('react-dev-utils/chalk'); +const webpack = require('webpack'); +const WebpackDevServer = require('webpack-dev-server'); +const clearConsole = require('react-dev-utils/clearConsole'); +const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); +const { + choosePort, + createCompiler, + prepareProxy, + prepareUrls, +} = require('react-dev-utils/WebpackDevServerUtils'); +const openBrowser = require('react-dev-utils/openBrowser'); +const paths = require('../config/paths'); +const configFactory = require('../config/webpack.config'); +const createDevServerConfig = require('../config/webpackDevServer.config'); + +const useYarn = fs.existsSync(paths.yarnLockFile); +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +// Tools like Cloud9 rely on this. +const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; +const HOST = process.env.HOST || '0.0.0.0'; + +if (process.env.HOST) { + console.log( + chalk.cyan( + `Attempting to bind to HOST environment variable: ${chalk.yellow( + chalk.bold(process.env.HOST) + )}` + ) + ); + console.log( + `If this was unintentional, check that you haven't mistakenly set it in your shell.` + ); + console.log( + `Learn more here: ${chalk.yellow('https://bit.ly/CRA-advanced-config')}` + ); + console.log(); +} + +// We require that you explicitly set browsers and do not fall back to +// browserslist defaults. +const { checkBrowsers } = require('react-dev-utils/browsersHelper'); +checkBrowsers(paths.appPath, isInteractive) + .then(() => { + // We attempt to use the default port but if it is busy, we offer the user to + // run on a different port. `choosePort()` Promise resolves to the next free port. + return choosePort(HOST, DEFAULT_PORT); + }) + .then(port => { + if (port == null) { + // We have not found a port. + return; + } + const config = configFactory('development'); + const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; + const appName = require(paths.appPackageJson).name; + const useTypeScript = fs.existsSync(paths.appTsConfig); + const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true'; + const urls = prepareUrls(protocol, HOST, port); + const devSocket = { + warnings: warnings => + devServer.sockWrite(devServer.sockets, 'warnings', warnings), + errors: errors => + devServer.sockWrite(devServer.sockets, 'errors', errors), + }; + // Create a webpack compiler that is configured with custom messages. + const compiler = createCompiler({ + appName, + config, + devSocket, + urls, + useYarn, + useTypeScript, + tscCompileOnError, + webpack, + }); + // Load proxy config + const proxySetting = require(paths.appPackageJson).proxy; + const proxyConfig = prepareProxy(proxySetting, paths.appPublic); + // Serve webpack assets generated by the compiler over a web server. + const serverConfig = createDevServerConfig( + proxyConfig, + urls.lanUrlForConfig + ); + const devServer = new WebpackDevServer(compiler, serverConfig); + // Launch WebpackDevServer. + devServer.listen(port, HOST, err => { + if (err) { + return console.log(err); + } + if (isInteractive) { + clearConsole(); + } + + // We used to support resolving modules according to `NODE_PATH`. + // This now has been deprecated in favor of jsconfig/tsconfig.json + // This lets you use absolute paths in imports inside large monorepos: + if (process.env.NODE_PATH) { + console.log( + chalk.yellow( + 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.' + ) + ); + console.log(); + } + + console.log(chalk.cyan('Starting the development server...\n')); + openBrowser(urls.localUrlForBrowser); + }); + + ['SIGINT', 'SIGTERM'].forEach(function(sig) { + process.on(sig, function() { + devServer.close(); + process.exit(); + }); + }); + }) + .catch(err => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 0000000..b57cb38 --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,53 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'test'; +process.env.NODE_ENV = 'test'; +process.env.PUBLIC_URL = ''; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + + +const jest = require('jest'); +const execSync = require('child_process').execSync; +let argv = process.argv.slice(2); + +function isInGitRepository() { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +function isInMercurialRepository() { + try { + execSync('hg --cwd . root', { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +// Watch unless on CI or explicitly running all tests +if ( + !process.env.CI && + argv.indexOf('--watchAll') === -1 && + argv.indexOf('--watchAll=false') === -1 +) { + // https://github.com/facebook/create-react-app/issues/5210 + const hasSourceControl = isInGitRepository() || isInMercurialRepository(); + argv.push(hasSourceControl ? '--watch' : '--watchAll'); +} + + +jest.run(argv); diff --git a/src/Admin.js b/src/Admin.js new file mode 100644 index 0000000..8f656fa --- /dev/null +++ b/src/Admin.js @@ -0,0 +1,233 @@ +import React, { useEffect, useState } from "react"; +import { CssBaseline, makeStyles } from "@material-ui/core"; +import AlertBar from "./component/Common/Snackbar"; +import Dashboard from "./component/Admin/Dashboard"; +import { useHistory } from "react-router"; +import Auth from "./middleware/Auth"; +import { Route, Switch } from "react-router-dom"; +import { ThemeProvider } from "@material-ui/styles"; +import createTheme from "@material-ui/core/styles/createMuiTheme"; +import { zhCN } from "@material-ui/core/locale"; +import Index from "./component/Admin/Index"; +import SiteInformation from "./component/Admin/Setting/SiteInformation"; +import Access from "./component/Admin/Setting/Access"; +import Mail from "./component/Admin/Setting/Mail"; +import UploadDownload from "./component/Admin/Setting/UploadDownload"; +import VAS from "./component/Admin/Setting/VAS"; +import Theme from "./component/Admin/Setting/Theme"; +import ImageSetting from "./component/Admin/Setting/Image"; +import Policy from "./component/Admin/Policy/Policy"; +import AddPolicy from "./component/Admin/Policy/AddPolicy"; +import EditPolicyPreload from "./component/Admin/Policy/EditPolicy"; +import Group from "./component/Admin/Group/Group"; +import GroupForm from "./component/Admin/Group/GroupForm"; +import EditGroupPreload from "./component/Admin/Group/EditGroup"; +import User from "./component/Admin/User/User"; +import UserForm from "./component/Admin/User/UserForm"; +import EditUserPreload from "./component/Admin/User/EditUser"; +import File from "./component/Admin/File/File"; +import Share from "./component/Admin/Share/Share"; +import Order from "./component/Admin/Order/Order"; +import Download from "./component/Admin/Task/Download"; +import Task from "./component/Admin/Task/Task"; +import Import from "./component/Admin/File/Import"; +import ReportList from "./component/Admin/Report/ReportList"; +import Captcha from "./component/Admin/Setting/Captcha"; +import Node from "./component/Admin/Node/Node"; +import AddNode from "./component/Admin/Node/AddNode"; +import EditNode from "./component/Admin/Node/EditNode"; + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + }, + content: { + flexGrow: 1, + padding: 0, + minWidth: 0, + }, + toolbar: theme.mixins.toolbar, +})); + +const theme = createTheme( + { + palette: { + background: {}, + }, + shape:{ + borderRadius:12, + }, + overrides: { + MuiButton: { + root: { + textTransform: "none", + }, + }, + MuiTab: { + root: { + textTransform: "none", + }, + }, + }, + }, + zhCN +); + +export default function Admin() { + const classes = useStyles(); + const history = useHistory(); + const [show, setShow] = useState(false); + + useEffect(() => { + const user = Auth.GetUser(); + if (user && user.group) { + if (user.group.id !== 1) { + history.push("/home"); + return; + } + setShow(true); + return; + } + history.push("/login"); + // eslint-disable-next-line + }, []); + + return ( + + +
+ + + {show && ( + ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + /> + )} +
+
+
+ ); +} diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..7385499 --- /dev/null +++ b/src/App.js @@ -0,0 +1,280 @@ +import React, { Suspense } from "react"; +import AuthRoute from "./middleware/AuthRoute"; +import NoAuthRoute from "./middleware/NoAuthRoute"; +import Navbar from "./component/Navbar/Navbar.js"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; +import AlertBar from "./component/Common/Snackbar"; +import { createMuiTheme, lighten } from "@material-ui/core/styles"; +import { useSelector } from "react-redux"; +import { Redirect, Route, Switch, useRouteMatch } from "react-router-dom"; +import Auth from "./middleware/Auth"; +import { CssBaseline, makeStyles, ThemeProvider } from "@material-ui/core"; +import PageLoading from "./component/Placeholder/PageLoading.js"; +import { changeThemeColor } from "./utils"; +import NotFound from "./component/Share/NotFound"; +// Lazy loads +import LoginForm from "./component/Login/LoginForm"; +import FileManager from "./component/FileManager/FileManager.js"; +import VideoPreview from "./component/Viewer/Video.js"; +import SearchResult from "./component/Share/SearchResult"; +import MyShare from "./component/Share/MyShare"; +import Download from "./component/Download/Download"; +import SharePreload from "./component/Share/SharePreload"; +import DocViewer from "./component/Viewer/Doc"; +import TextViewer from "./component/Viewer/Text"; +import Quota from "./component/VAS/Quota"; +import BuyQuota from "./component/VAS/BuyQuota"; +import WebDAV from "./component/Setting/WebDAV"; +import Tasks from "./component/Setting/Tasks"; +import Profile from "./component/Setting/Profile"; +import UserSetting from "./component/Setting/UserSetting"; +import QQCallback from "./component/Login/QQ"; +import Register from "./component/Login/Register"; +import Activation from "./component/Login/Activication"; +import ResetForm from "./component/Login/ResetForm"; +import Reset from "./component/Login/Reset"; +import CodeViewer from "./component/Viewer/Code"; +import SiteNotice from "./component/Modals/SiteNotice"; +import MusicPlayer from "./component/FileManager/MusicPlayer"; +import EpubViewer from "./component/Viewer/Epub"; +import { useTranslation } from "react-i18next"; + +const PDFViewer = React.lazy(() => + import(/* webpackChunkName: "pdf" */ "./component/Viewer/PDF") +); + +export default function App() { + const themeConfig = useSelector((state) => state.siteConfig.theme); + const isLogin = useSelector((state) => state.viewUpdate.isLogin); + const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + const { t } = useTranslation(); + + const theme = React.useMemo(() => { + themeConfig.palette.type = prefersDarkMode ? "dark" : "light"; + const prefer = Auth.GetPreference("theme_mode"); + if (prefer) { + themeConfig.palette.type = prefer; + } + const theme = createMuiTheme({ + ...themeConfig, + palette: { + ...themeConfig.palette, + primary: { + ...themeConfig.palette.primary, + main: + themeConfig.palette.type === "dark" + ? lighten(themeConfig.palette.primary.main, 0.3) + : themeConfig.palette.primary.main, + }, + }, + shape: { + ...themeConfig.shape, + borderRadius: 12, + }, + overrides: { + MuiButton: { + root: { + textTransform: "none", + }, + }, + MuiTab: { + root: { + textTransform: "none", + }, + }, + }, + }); + changeThemeColor( + themeConfig.palette.type === "dark" + ? theme.palette.background.default + : theme.palette.primary.main + ); + return theme; + }, [prefersDarkMode, themeConfig]); + + const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + }, + content: { + flexGrow: 1, + padding: theme.spacing(0), + minWidth: 0, + }, + toolbar: theme.mixins.toolbar, + })); + + const classes = useStyles(); + + const { path } = useRouteMatch(); + return ( + + +
+ + + +
+
+ + + + + + + <> + + + + + + + + + + + + + + + + + + + }> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + }> + + + + + + + + + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/App.test.js b/src/App.test.js new file mode 100644 index 0000000..e08573e --- /dev/null +++ b/src/App.test.js @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./App"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/src/component/Admin/Common/DomainInput.js b/src/component/Admin/Common/DomainInput.js new file mode 100644 index 0000000..dc8e2ff --- /dev/null +++ b/src/component/Admin/Common/DomainInput.js @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from "react"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import FormHelperText from "@material-ui/core/FormHelperText"; + +export default function DomainInput({ onChange, value, required, label }) { + const [domain, setDomain] = useState(""); + const [protocol, setProtocol] = useState("https://"); + const [error, setError] = useState(); + + useState(() => { + value = value ? value : ""; + if (value.startsWith("https://")) { + setDomain(value.replace("https://", "")); + setProtocol("https://"); + } else { + if (value !== "") { + setDomain(value.replace("http://", "")); + setProtocol("http://"); + } + } + }, [value]); + + useEffect(() => { + if (protocol === "http://" && window.location.protocol === "https:") { + setError( + "您当前站点启用了 HTTPS ,此处选择 HTTP 可能会导致无法连接。" + ); + } else { + setError(""); + } + }, [protocol]); + + return ( + + {label} + { + setDomain(e.target.value); + onChange({ + target: { + value: protocol + e.target.value, + }, + }); + }} + required={required} + startAdornment={ + + + + } + /> + {error !== "" && ( + {error} + )} + + ); +} diff --git a/src/component/Admin/Common/FileSelector.js b/src/component/Admin/Common/FileSelector.js new file mode 100644 index 0000000..67d399a --- /dev/null +++ b/src/component/Admin/Common/FileSelector.js @@ -0,0 +1,94 @@ +import React, { useCallback } from "react"; +import Autocomplete from "@material-ui/lab/Autocomplete"; +import TextField from "@material-ui/core/TextField"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Typography from "@material-ui/core/Typography"; +import { toggleSnackbar } from "../../../redux/explorer"; + +export default function FileSelector({ onChange, value, label }) { + const [selectValue, setSelectValue] = React.useState( + value.map((v) => { + return { + ID: v, + Name: "文件ID " + v, + }; + }) + ); + const [loading, setLoading] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + const [options, setOptions] = React.useState([]); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + React.useEffect(() => { + let active = true; + if ( + inputValue === "" || + selectValue.findIndex((v) => v.ID.toString() === inputValue) >= 0 + ) { + setOptions([]); + return; + } + + setLoading(true); + API.post("/admin/file/list", { + page: 1, + page_size: 10, + order_by: "id desc", + conditions: { + id: inputValue, + }, + searches: {}, + }) + .then((response) => { + if (active) { + let newOptions = []; + newOptions = [...newOptions, ...response.data.items]; + setOptions(newOptions); + } + setLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + setLoading(false); + }); + return () => { + active = false; + }; + }, [selectValue, inputValue]); + + return ( + + typeof option === "string" ? option : option.Name + } + filterOptions={(x) => x} + loading={loading} + autoComplete + includeInputInList + filterSelectedOptions + value={selectValue} + onInputChange={(event, newInputValue) => { + setInputValue(newInputValue); + }} + onChange={(event, newValue) => { + setSelectValue(newValue); + onChange(JSON.stringify(newValue.map((v) => v.ID))); + }} + renderInput={(params) => ( + + )} + renderOption={(option) => ( + {option.Name} + )} + /> + ); +} diff --git a/src/component/Admin/Common/PolicySelector.js b/src/component/Admin/Common/PolicySelector.js new file mode 100644 index 0000000..d6eb7d8 --- /dev/null +++ b/src/component/Admin/Common/PolicySelector.js @@ -0,0 +1,95 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../../redux/explorer"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; +import Input from "@material-ui/core/Input"; +import Chip from "@material-ui/core/Chip"; +import MenuItem from "@material-ui/core/MenuItem"; +import { getSelectItemStyles } from "../../../utils"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import { FormControl } from "@material-ui/core"; +import API from "../../../middleware/Api"; +import { useTheme } from "@material-ui/core/styles"; + +export default function PolicySelector({ + onChange, + value, + label, + helperText, + filter, +}) { + const [policies, setPolicies] = useState({}); + const theme = useTheme(); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/policy/list", { + page: 1, + page_size: 10000, + order_by: "id asc", + conditions: {}, + }) + .then((response) => { + const res = {}; + let data = response.data.items; + if (filter) { + data = data.filter(filter); + } + + data.forEach((v) => { + res[v.ID] = v.Name; + }); + setPolicies(res); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, []); + + return ( + + {label} + + + {helperText} + + + ); +} diff --git a/src/component/Admin/Common/SizeInput.js b/src/component/Admin/Common/SizeInput.js new file mode 100644 index 0000000..95946e8 --- /dev/null +++ b/src/component/Admin/Common/SizeInput.js @@ -0,0 +1,101 @@ +import React, { useCallback, useEffect, useState } from "react"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../../redux/explorer"; +import FormHelperText from "@material-ui/core/FormHelperText"; + +const unitTransform = (v) => { + if (!v || v.toString() === "0") { + return [0, 1024 * 1024]; + } + for (let i = 4; i >= 0; i--) { + const base = Math.pow(1024, i); + if (v % base === 0) { + return [v / base, base]; + } + } +}; + +export default function SizeInput({ + onChange, + min, + value, + required, + label, + max, + suffix, +}) { + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const [unit, setUnit] = useState(1); + const [val, setVal] = useState(value); + const [err, setError] = useState(""); + + useEffect(() => { + onChange({ + target: { + value: (val * unit).toString(), + }, + }); + if (val * unit > max || val * unit < min) { + setError("不符合尺寸限制"); + } else { + setError(""); + } + }, [val, unit, max, min]); + + useEffect(() => { + const res = unitTransform(value); + setUnit(res[1]); + setVal(res[0]); + }, []); + + return ( + + {label} + setVal(e.target.value)} + required={required} + endAdornment={ + + + + } + /> + {err !== "" && {err}} + + ); +} diff --git a/src/component/Admin/Dashboard.js b/src/component/Admin/Dashboard.js new file mode 100644 index 0000000..43cbfbc --- /dev/null +++ b/src/component/Admin/Dashboard.js @@ -0,0 +1,477 @@ +import React, { useCallback, useEffect, useState } from "react"; +import clsx from "clsx"; +import { lighten, makeStyles, useTheme } from "@material-ui/core/styles"; +import Drawer from "@material-ui/core/Drawer"; +import AppBar from "@material-ui/core/AppBar"; +import Toolbar from "@material-ui/core/Toolbar"; +import List from "@material-ui/core/List"; +import Typography from "@material-ui/core/Typography"; +import Divider from "@material-ui/core/Divider"; +import IconButton from "@material-ui/core/IconButton"; +import MenuIcon from "@material-ui/icons/Menu"; +import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; +import ChevronRightIcon from "@material-ui/icons/ChevronRight"; +import ListItem from "@material-ui/core/ListItem"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; +import ListItemText from "@material-ui/core/ListItemText"; +import UserAvatar from "../Navbar/UserAvatar"; +import { + Assignment, + AttachMoney, + Category, + CloudDownload, + Contactless, + Contacts, + Group, + Home, + Image, + InsertDriveFile, + Language, + ListAlt, + Mail, + Palette, + Person, + Report, + Settings, + SettingsEthernet, + Share, + ShoppingCart, + Storage, +} from "@material-ui/icons"; +import { withStyles } from "@material-ui/core"; +import MuiExpansionPanel from "@material-ui/core/ExpansionPanel"; +import MuiExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary"; +import MuiExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails"; +import { useHistory, useLocation } from "react-router"; +import { useRouteMatch } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { changeSubTitle } from "../../redux/viewUpdate/action"; +import pathHelper from "../../utils/page"; +import { useTranslation } from "react-i18next"; + +const ExpansionPanel = withStyles({ + root: { + maxWidth: "100%", + boxShadow: "none", + "&:not(:last-child)": { + borderBottom: 0, + }, + "&:before": { + display: "none", + }, + "&$expanded": { margin: 0 }, + }, + expanded: {}, +})(MuiExpansionPanel); + +const ExpansionPanelSummary = withStyles({ + root: { + minHeight: 0, + padding: 0, + + "&$expanded": { + minHeight: 0, + }, + }, + content: { + maxWidth: "100%", + margin: 0, + display: "block", + "&$expanded": { + margin: "0", + }, + }, + expanded: {}, +})(MuiExpansionPanelSummary); + +const ExpansionPanelDetails = withStyles((theme) => ({ + root: { + display: "block", + padding: theme.spacing(0), + }, +}))(MuiExpansionPanelDetails); + +const drawerWidth = 240; + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + width: "100%", + }, + appBar: { + zIndex: theme.zIndex.drawer + 1, + transition: theme.transitions.create(["width", "margin"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + }, + appBarShift: { + marginLeft: drawerWidth, + width: `calc(100% - ${drawerWidth}px)`, + transition: theme.transitions.create(["width", "margin"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen, + }), + }, + menuButton: { + marginRight: 36, + }, + hide: { + display: "none", + }, + drawer: { + width: drawerWidth, + flexShrink: 0, + whiteSpace: "nowrap", + }, + drawerOpen: { + width: drawerWidth, + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen, + }), + }, + drawerClose: { + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + overflowX: "hidden", + width: theme.spacing(7) + 1, + [theme.breakpoints.up("sm")]: { + width: theme.spacing(9) + 1, + }, + }, + title: { + flexGrow: 1, + }, + toolbar: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + padding: theme.spacing(0, 1), + ...theme.mixins.toolbar, + }, + content: { + flexGrow: 1, + padding: theme.spacing(3), + }, + sub: { + paddingLeft: 36, + color: theme.palette.text.secondary, + }, + subMenu: { + backgroundColor: theme.palette.background.default, + paddingTop: 0, + paddingBottom: 0, + }, + active: { + backgroundColor: lighten(theme.palette.primary.main, 0.8), + color: theme.palette.primary.main, + "&:hover": { + backgroundColor: lighten(theme.palette.primary.main, 0.7), + }, + }, + activeText: { + fontWeight: 500, + }, + activeIcon: { + color: theme.palette.primary.main, + }, +})); + +const items = [ + { + title: "nav.summary", + icon: , + path: "home", + }, + { + title: "nav.settings", + icon: , + sub: [ + { + title: "nav.basicSetting", + path: "basic", + icon: , + }, + { + title: "nav.publicAccess", + path: "access", + icon: , + }, + { + title: "nav.email", + path: "mail", + icon: , + }, + { + title: "nav.transportation", + path: "upload", + icon: , + }, + { + title: "vas.vas", + path: "vas", + icon: , + }, + { + title: "nav.appearance", + path: "theme", + icon: , + }, + { + title: "nav.image", + path: "image", + icon: , + }, + { + title: "nav.captcha", + path: "captcha", + icon: , + }, + ], + }, + { + title: "nav.storagePolicy", + icon: , + path: "policy", + }, + { + title: "nav.nodes", + icon: , + path: "node", + }, + { + title: "nav.groups", + icon: , + path: "group", + }, + { + title: "nav.users", + icon: , + path: "user", + }, + { + title: "nav.files", + icon: , + path: "file", + }, + { + title: "nav.shares", + icon: , + path: "share", + }, + { + title: "vas.reports", + icon: , + path: "report", + }, + { + title: "vas.orders", + icon: , + path: "order", + }, + { + title: "nav.tasks", + icon: , + sub: [ + { + title: "nav.remoteDownload", + path: "download", + icon: , + }, + { + title: "nav.generalTasks", + path: "task", + icon: , + }, + ], + }, +]; + +export default function Dashboard({ content }) { + const { t } = useTranslation("dashboard"); + const classes = useStyles(); + const theme = useTheme(); + const [open, setOpen] = useState(!pathHelper.isMobile()); + const [menuOpen, setMenuOpen] = useState(null); + const history = useHistory(); + const location = useLocation(); + + const handleDrawerOpen = () => { + setOpen(true); + }; + + const handleDrawerClose = () => { + setOpen(false); + }; + + const dispatch = useDispatch(); + const SetSubTitle = useCallback( + (title) => dispatch(changeSubTitle(title)), + [dispatch] + ); + + useEffect(() => { + SetSubTitle(t("nav.title")); + }, []); + + useEffect(() => { + return () => { + SetSubTitle(); + }; + }, []); + + const { path } = useRouteMatch(); + + return ( +
+ + + + + + + {t("nav.dashboard")} + + + + + +
+ + {theme.direction === "rtl" ? ( + + ) : ( + + )} + +
+ + + {items.map((item) => { + if (item.path !== undefined) { + return ( + + history.push("/admin/" + item.path) + } + button + className={clsx({ + [classes.active]: location.pathname.startsWith( + "/admin/" + item.path + ), + })} + key={item.title} + > + + {item.icon} + + + + ); + } + return ( + // eslint-disable-next-line react/jsx-key + { + setMenuOpen(isExpanded ? item.title : null); + }} + > + + + {item.icon} + + + + + + {item.sub.map((sub) => ( + + history.push( + "/admin/" + sub.path + ) + } + className={clsx({ + [classes.sub]: open, + [classes.active]: location.pathname.startsWith( + "/admin/" + sub.path + ), + })} + button + key={sub.title} + > + + {sub.icon} + + + + ))} + + + + ); + })} + +
+
+
+ {content(path)} +
+
+ ); +} diff --git a/src/component/Admin/Dialogs/AddGroupk.js b/src/component/Admin/Dialogs/AddGroupk.js new file mode 100644 index 0000000..e2019c9 --- /dev/null +++ b/src/component/Admin/Dialogs/AddGroupk.js @@ -0,0 +1,268 @@ +import React, { useEffect, useState } from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import FormControl from "@material-ui/core/FormControl"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import Switch from "@material-ui/core/Switch"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles(() => ({ + formContainer: { + margin: "8px 0 8px 0", + }, +})); + +const defaultGroup = { + name: "", + group_id: 2, + time: "", + price: "", + score: "", + des: "", + highlight: false, +}; + +const groupEditToForm = (target) => { + return { + ...target, + time: (target.time / 86400).toString(), + price: (target.price / 100).toString(), + score: target.score.toString(), + des: target.des.join("\n"), + }; +}; + +export default function AddGroup({ open, onClose, onSubmit, groupEdit }) { + const { t } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tCommon } = useTranslation("common"); + const classes = useStyles(); + const [groups, setGroups] = useState([]); + const [group, setGroup] = useState(defaultGroup); + + useEffect(() => { + if (groupEdit) { + setGroup(groupEditToForm(groupEdit)); + } else { + setGroup(defaultGroup); + } + }, [groupEdit]); + + useEffect(() => { + if (open && groups.length === 0) { + API.get("/admin/groups") + .then((response) => { + setGroups(response.data); + }) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}); + } + // eslint-disable-next-line + }, [open]); + + const handleChange = (name) => (event) => { + setGroup({ + ...group, + [name]: event.target.value, + }); + }; + + const handleCheckChange = (name) => (event) => { + setGroup({ + ...group, + [name]: event.target.checked, + }); + }; + + const submit = (e) => { + e.preventDefault(); + const groupCopy = { ...group }; + groupCopy.time = parseInt(groupCopy.time) * 86400; + groupCopy.price = parseInt(groupCopy.price) * 100; + groupCopy.score = parseInt(groupCopy.score); + groupCopy.id = groupEdit ? groupEdit.id : new Date().valueOf(); + groupCopy.des = groupCopy.des.split("\n"); + onSubmit(groupCopy, groupEdit !== null); + }; + + return ( + +
+ + {groupEdit ? t("editMembership") : t("addMembership")} + + + +
+ + + {t("name")} + + + + {t("productNameDes")} + + +
+ +
+ + + {t("group")} + + + + {t("groupDes")} + + +
+ +
+ + + {t("durationDay")} + + + + {t("durationGroupDes")} + + +
+ +
+ + + {t("priceYuan")} + + + + {t("groupPriceDes")} + + +
+ +
+ + + {t("priceCredits")} + + + + {t("priceCreditsDes")} + + +
+ +
+ + + {t("productDescription")} + + + + {t("productDescriptionDes")} + + +
+ +
+ + + } + label={t("highlight")} + /> + + {t("highlightDes")} + + +
+
+
+ + + + +
+
+ ); +} diff --git a/src/component/Admin/Dialogs/AddPack.js b/src/component/Admin/Dialogs/AddPack.js new file mode 100644 index 0000000..1da8306 --- /dev/null +++ b/src/component/Admin/Dialogs/AddPack.js @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import FormControl from "@material-ui/core/FormControl"; +import SizeInput from "../Common/SizeInput"; +import { makeStyles } from "@material-ui/core/styles"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles(() => ({ + formContainer: { + margin: "8px 0 8px 0", + }, +})); + +const packEditToForm = (target) => { + return { + ...target, + size: target.size.toString(), + time: (target.time / 86400).toString(), + price: (target.price / 100).toString(), + score: target.score.toString(), + }; +}; + +const defaultPack = { + name: "", + size: "1073741824", + time: "", + price: "", + score: "", +}; + +export default function AddPack({ open, onClose, onSubmit, packEdit }) { + const { t } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tCommon } = useTranslation("common"); + const classes = useStyles(); + const [pack, setPack] = useState(defaultPack); + + useEffect(() => { + if (packEdit) { + setPack(packEditToForm(packEdit)); + } else { + setPack(defaultPack); + } + }, [packEdit]); + + const handleChange = (name) => (event) => { + setPack({ + ...pack, + [name]: event.target.value, + }); + }; + + const submit = (e) => { + e.preventDefault(); + const packCopy = { ...pack }; + packCopy.size = parseInt(packCopy.size); + packCopy.time = parseInt(packCopy.time) * 86400; + packCopy.price = parseInt(packCopy.price) * 100; + packCopy.score = parseInt(packCopy.score); + packCopy.id = packEdit ? packEdit.id : new Date().valueOf(); + onSubmit(packCopy, packEdit !== null); + }; + + return ( + +
+ + {packEdit ? t("editStoragePack") : t("addStoragePack")} + + + +
+ + + {t("name")} + + + + {t("productNameDes")} + + +
+ +
+ + + + {t("packSizeDes")} + + +
+ +
+ + + {t("durationDay")} + + + + {t("durationDayDes")} + + +
+ +
+ + + {t("priceYuan")} + + + + {t("packPriceDes")} + + +
+ +
+ + + {t("priceCredits")} + + + + {t("priceCreditsDes")} + + +
+
+
+ + + + +
+
+ ); +} diff --git a/src/component/Admin/Dialogs/AddPolicy.js b/src/component/Admin/Dialogs/AddPolicy.js new file mode 100644 index 0000000..c8a128e --- /dev/null +++ b/src/component/Admin/Dialogs/AddPolicy.js @@ -0,0 +1,145 @@ +import React from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import Typography from "@material-ui/core/Typography"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import Grid from "@material-ui/core/Grid"; +import Card from "@material-ui/core/Card"; +import CardActionArea from "@material-ui/core/CardActionArea"; +import { makeStyles } from "@material-ui/core/styles"; +import CardMedia from "@material-ui/core/CardMedia"; +import CardContent from "@material-ui/core/CardContent"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + cardContainer: { + display: "flex", + }, + cover: { + width: 100, + height: 60, + }, + card: {}, + content: { + flex: "1 0 auto", + }, + bg: { + backgroundColor: theme.palette.background.default, + padding: "24px 24px", + }, + dialogFooter: { + justifyContent: "space-between", + }, +})); + +const policies = [ + { + name: "local", + img: "local.png", + path: "/admin/policy/add/local", + }, + { + name: "remote", + img: "remote.png", + path: "/admin/policy/add/remote", + }, + { + name: "qiniu", + img: "qiniu.png", + path: "/admin/policy/add/qiniu", + }, + { + name: "oss", + img: "oss.png", + path: "/admin/policy/add/oss", + }, + { + name: "upyun", + img: "upyun.png", + path: "/admin/policy/add/upyun", + }, + { + name: "cos", + img: "cos.png", + path: "/admin/policy/add/cos", + }, + { + name: "onedrive", + img: "onedrive.png", + path: "/admin/policy/add/onedrive", + }, + { + name: "s3", + img: "s3.png", + path: "/admin/policy/add/s3", + }, +]; + +export default function AddPolicy({ open, onClose }) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const { t: tCommon } = useTranslation("common"); + const classes = useStyles(); + + const location = useHistory(); + + return ( + + + {t("selectAStorageProvider")} + + + + {policies.map((v, index) => ( + + + { + location.push(v.path); + onClose(); + }} + className={classes.cardContainer} + > + + + + {t(v.name)} + + + + + + ))} + + + + + + + + ); +} diff --git a/src/component/Admin/Dialogs/AddRedeem.js b/src/component/Admin/Dialogs/AddRedeem.js new file mode 100644 index 0000000..043c66c --- /dev/null +++ b/src/component/Admin/Dialogs/AddRedeem.js @@ -0,0 +1,183 @@ +import React, { useCallback, useState } from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import FormControl from "@material-ui/core/FormControl"; +import { makeStyles } from "@material-ui/core/styles"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles(() => ({ + formContainer: { + margin: "8px 0 8px 0", + }, +})); + +export default function AddRedeem({ open, onClose, products, onSuccess }) { + const { t } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tCommon } = useTranslation("common"); + const { t: tApp } = useTranslation(); + const classes = useStyles(); + const [input, setInput] = useState({ + num: 1, + id: 0, + time: 1, + }); + const [loading, setLoading] = useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const handleChange = (name) => (event) => { + setInput({ + ...input, + [name]: event.target.value, + }); + }; + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + input.num = parseInt(input.num); + input.id = parseInt(input.id); + input.time = parseInt(input.time); + input.type = 2; + for (let i = 0; i < products.length; i++) { + if (products[i].id === input.id) { + if (products[i].group_id !== undefined) { + input.type = 1; + } else { + input.type = 0; + } + break; + } + } + + API.post("/admin/redeem", input) + .then((response) => { + onSuccess(response.data); + onClose(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( + +
+ + {t("generateGiftCode")} + + + +
+ + + {t("numberOfCodes")} + + + + {t("numberOfCodesDes")} + + +
+ +
+ + + {t("linkedProduct")} + + + +
+ +
+ + + {t("productQyt")} + + + + {t("productQytDes")} + + +
+
+
+ + + + +
+
+ ); +} diff --git a/src/component/Admin/Dialogs/Alert.js b/src/component/Admin/Dialogs/Alert.js new file mode 100644 index 0000000..afdfaea --- /dev/null +++ b/src/component/Admin/Dialogs/Alert.js @@ -0,0 +1,33 @@ +import React from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import Typography from "@material-ui/core/Typography"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import { useTranslation } from "react-i18next"; + +export default function AlertDialog({ title, msg, open, onClose }) { + const { t } = useTranslation("common"); + return ( + + {title} + + + {msg} + + + + + + + ); +} diff --git a/src/component/Admin/Dialogs/CreateTheme.js b/src/component/Admin/Dialogs/CreateTheme.js new file mode 100644 index 0000000..a457240 --- /dev/null +++ b/src/component/Admin/Dialogs/CreateTheme.js @@ -0,0 +1,355 @@ +import React, { useCallback, useState } from "react"; +import DialogContent from "@material-ui/core/DialogContent"; +import { CompactPicker } from "react-color"; +import Typography from "@material-ui/core/Typography"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import Grid from "@material-ui/core/Grid"; +import TextField from "@material-ui/core/TextField"; +import { createMuiTheme, makeStyles } from "@material-ui/core/styles"; +import AppBar from "@material-ui/core/AppBar"; +import Toolbar from "@material-ui/core/Toolbar"; +import IconButton from "@material-ui/core/IconButton"; +import { Add, Menu } from "@material-ui/icons"; +import { ThemeProvider } from "@material-ui/styles"; +import Fab from "@material-ui/core/Fab"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + picker: { + "& div": { + boxShadow: "none !important", + }, + marginTop: theme.spacing(1), + }, + "@global": { + ".compact-picker:parent ": { + boxShadow: "none !important", + }, + }, + statusBar: { + height: 24, + width: "100%", + }, + fab: { + textAlign: "right", + }, +})); + +export default function CreateTheme({ open, onClose, onSubmit }) { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const { t: tGlobal } = useTranslation("common"); + const classes = useStyles(); + const [theme, setTheme] = useState({ + palette: { + primary: { + main: "#3f51b5", + contrastText: "#fff", + }, + secondary: { + main: "#d81b60", + contrastText: "#fff", + }, + }, + }); + + const subTheme = useCallback(() => { + try { + return createMuiTheme(theme); + } catch (e) { + return createMuiTheme({}); + } + }, [theme]); + + return ( + + + + + + + {t("primaryColor")} + + { + setTheme({ + ...theme, + palette: { + ...theme.palette, + primary: { + ...theme.palette.primary, + main: e.target.value, + }, + }, + }); + }} + fullWidth + /> +
+ { + setTheme({ + ...theme, + palette: { + ...theme.palette, + primary: { + ...theme.palette.primary, + main: c.hex, + }, + }, + }); + }} + /> +
+
+ + + {t("secondaryColor")} + + { + setTheme({ + ...theme, + palette: { + ...theme.palette, + secondary: { + ...theme.palette.secondary, + main: e.target.value, + }, + }, + }); + }} + fullWidth + /> +
+ { + setTheme({ + ...theme, + palette: { + ...theme.palette, + secondary: { + ...theme.palette.secondary, + main: c.hex, + }, + }, + }); + }} + /> +
+
+ + + {t("primaryColorText")} + + { + setTheme({ + ...theme, + palette: { + ...theme.palette, + primary: { + ...theme.palette.primary, + contrastText: e.target.value, + }, + }, + }); + }} + fullWidth + /> +
+ { + setTheme({ + ...theme, + palette: { + ...theme.palette, + primary: { + ...theme.palette.primary, + contrastText: c.hex, + }, + }, + }); + }} + /> +
+
+ + + {t("secondaryColorText")} + + { + setTheme({ + ...theme, + palette: { + ...theme.palette, + secondary: { + ...theme.palette.secondary, + contrastText: e.target.value, + }, + }, + }); + }} + fullWidth + /> +
+ { + setTheme({ + ...theme, + palette: { + ...theme.palette, + secondary: { + ...theme.palette.secondary, + contrastText: c.hex, + }, + }, + }); + }} + /> +
+
+
+ + +
+ + + + + + + Color + + + +
+ +
+ + + +
+
+ + + + + + + + +
+ ); +} diff --git a/src/component/Admin/Dialogs/FileFilter.js b/src/component/Admin/Dialogs/FileFilter.js new file mode 100644 index 0000000..5cbc96f --- /dev/null +++ b/src/component/Admin/Dialogs/FileFilter.js @@ -0,0 +1,134 @@ +import React, { useCallback, useEffect, useState } from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import TextField from "@material-ui/core/TextField"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +export default function FileFilter({ setFilter, setSearch, open, onClose }) { + const { t } = useTranslation("dashboard", { keyPrefix: "file" }); + const { t: tDashboard } = useTranslation("dashboard"); + const { t: tCommon } = useTranslation("common"); + const [input, setInput] = useState({ + policy_id: "all", + user_id: "", + }); + const [policies, setPolicies] = useState([]); + const [keywords, setKeywords] = useState(""); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const handleChange = (name) => (event) => { + setInput({ ...input, [name]: event.target.value }); + }; + + useEffect(() => { + API.post("/admin/policy/list", { + page: 1, + page_size: 10000, + order_by: "id asc", + conditions: {}, + }) + .then((response) => { + setPolicies(response.data.items); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, []); + + const submit = () => { + const res = {}; + Object.keys(input).forEach((v) => { + if (input[v] !== "all" && input[v] !== "") { + res[v] = input[v]; + } + }); + setFilter(res); + if (keywords !== "") { + setSearch({ + name: keywords, + }); + } else { + setSearch({}); + } + onClose(); + }; + + return ( + + + {tDashboard("user.filterCondition")} + + + + + {t("storagePolicy")} + + + + + + + + setKeywords(e.target.value)} + id="standard-basic" + label={t("searchFileName")} + /> + + + + + + + + ); +} diff --git a/src/component/Admin/Dialogs/MagicVar.js b/src/component/Admin/Dialogs/MagicVar.js new file mode 100644 index 0000000..4623eca --- /dev/null +++ b/src/component/Admin/Dialogs/MagicVar.js @@ -0,0 +1,180 @@ +import React from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import TableBody from "@material-ui/core/TableBody"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import { useTranslation } from "react-i18next"; + +const magicVars = [ + { + value: "{randomkey16}", + des: "16digitsRandomString", + example: "N6IimT5XZP324ACK", + fileOnly: false, + }, + { + value: "{randomkey8}", + des: "8digitsRandomString", + example: "gWz78q30", + fileOnly: false, + }, + { + value: "{timestamp}", + des: "secondTimestamp", + example: "1582692933", + fileOnly: false, + }, + { + value: "{timestamp_nano}", + des: "nanoTimestamp", + example: "1582692933231834600", + fileOnly: false, + }, + { + value: "{uid}", + des: "uid", + example: "1", + fileOnly: false, + }, + { + value: "{originname}", + des: "originalFileName", + example: "MyPico.mp4", + fileOnly: true, + }, + { + value: "{originname_without_ext}", + des: "originFileNameNoext", + example: "MyPico", + fileOnly: true, + }, + { + value: "{ext}", + des: "extension", + example: ".jpg", + fileOnly: true, + }, + { + value: "{uuid}", + des: "uuidV4", + example: "31f0a770-659d-45bf-a5a9-166c06f33281", + fileOnly: true, + }, + { + value: "{date}", + des: "date", + example: "20060102", + fileOnly: false, + }, + { + value: "{datetime}", + des: "dateAndTime", + example: "20060102150405", + fileOnly: false, + }, + { + value: "{year}", + des: "year", + example: "2006", + fileOnly: false, + }, + { + value: "{month}", + des: "month", + example: "01", + fileOnly: false, + }, + { + value: "{day}", + des: "day", + example: "02", + fileOnly: false, + }, + { + value: "{hour}", + des: "hour", + example: "15", + fileOnly: false, + }, + { + value: "{minute}", + des: "minute", + example: "04", + fileOnly: false, + }, + { + value: "{second}", + des: "second", + example: "05", + fileOnly: false, + }, +]; + +export default function MagicVar({ isFile, open, onClose, isSlave }) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy.magicVar" }); + const { t: tCommon } = useTranslation("common"); + return ( + + + {isFile ? t("fileNameMagicVar") : t("pathMagicVar")} + + + + + + + {t("variable")} + {t("description")} + {t("example")} + + + + {magicVars.map((m) => { + if (!m.fileOnly || isFile) { + return ( + + + {m.value} + + {t(m.des)} + {m.example} + + ); + } + })} + {!isFile && ( + + + {"{path}"} + + {t("userUploadPath")} + /MyFile/Documents/ + + )} + +
+
+
+ + + +
+ ); +} diff --git a/src/component/Admin/Dialogs/ShareFilter.js b/src/component/Admin/Dialogs/ShareFilter.js new file mode 100644 index 0000000..1cfe13a --- /dev/null +++ b/src/component/Admin/Dialogs/ShareFilter.js @@ -0,0 +1,103 @@ +import React, { useState } from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import TextField from "@material-ui/core/TextField"; +import { useTranslation } from "react-i18next"; + +export default function ShareFilter({ setFilter, setSearch, open, onClose }) { + const { t } = useTranslation("dashboard", { keyPrefix: "share" }); + const { t: tDashboard } = useTranslation("dashboard"); + const { t: tCommon } = useTranslation("common"); + const [input, setInput] = useState({ + is_dir: "all", + user_id: "", + }); + const [keywords, setKeywords] = useState(""); + + const handleChange = (name) => (event) => { + setInput({ ...input, [name]: event.target.value }); + }; + + const submit = () => { + const res = {}; + Object.keys(input).forEach((v) => { + if (input[v] !== "all" && input[v] !== "") { + res[v] = input[v]; + } + }); + setFilter(res); + if (keywords !== "") { + setSearch({ + source_name: keywords, + }); + } else { + setSearch({}); + } + onClose(); + }; + + return ( + + + {tDashboard("user.filterCondition")} + + + + + {t("srcType")} + + + + + + + + setKeywords(e.target.value)} + id="standard-basic" + label={tDashboard("file.searchFileName")} + /> + + + + + + + + ); +} diff --git a/src/component/Admin/Dialogs/UserFilter.js b/src/component/Admin/Dialogs/UserFilter.js new file mode 100644 index 0000000..b5bc6d8 --- /dev/null +++ b/src/component/Admin/Dialogs/UserFilter.js @@ -0,0 +1,139 @@ +import React, { useCallback, useEffect, useState } from "react"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import TextField from "@material-ui/core/TextField"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +export default function UserFilter({ setFilter, setSearch, open, onClose }) { + const { t } = useTranslation("dashboard", { keyPrefix: "user" }); + const { t: tCommon } = useTranslation("common"); + const [input, setInput] = useState({ + group_id: "all", + status: "all", + }); + const [groups, setGroups] = useState([]); + const [keywords, setKeywords] = useState(""); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const handleChange = (name) => (event) => { + setInput({ ...input, [name]: event.target.value }); + }; + + useEffect(() => { + API.get("/admin/groups") + .then((response) => { + setGroups(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, []); + + const submit = () => { + const res = {}; + Object.keys(input).forEach((v) => { + if (input[v] !== "all") { + res[v] = input[v]; + } + }); + setFilter(res); + if (keywords !== "") { + setSearch({ + nick: keywords, + email: keywords, + }); + } else { + setSearch({}); + } + onClose(); + }; + + return ( + + + {t("filterCondition")} + + + + + {t("group")} + + + + + + {t("userStatus")} + + + + + setKeywords(e.target.value)} + id="standard-basic" + label={t("searchNickUserName")} + /> + + + + + + + + ); +} diff --git a/src/component/Admin/File/File.js b/src/component/Admin/File/File.js new file mode 100644 index 0000000..bef842d --- /dev/null +++ b/src/component/Admin/File/File.js @@ -0,0 +1,502 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import { sizeToString } from "../../../utils"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import { useHistory } from "react-router"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete, DeleteForever, FilterList, LinkOff } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Checkbox from "@material-ui/core/Checkbox"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { lighten } from "@material-ui/core"; +import Link from "@material-ui/core/Link"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import Badge from "@material-ui/core/Badge"; +import FileFilter from "../Dialogs/FileFilter"; +import { formatLocalTime } from "../../../utils/datetime"; +import { toggleSnackbar } from "../../../redux/explorer"; +import Chip from "@material-ui/core/Chip"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + }, + headerRight: {}, + highlight: + theme.palette.type === "light" + ? { + color: theme.palette.secondary.main, + backgroundColor: lighten(theme.palette.secondary.light, 0.85), + } + : { + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondary.dark, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, + disabledBadge: { + marginLeft: theme.spacing(1), + height: 18, + }, +})); + +export default function File() { + const { t } = useTranslation("dashboard", { keyPrefix: "file" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [files, setFiles] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState({}); + const [users, setUsers] = useState({}); + const [search, setSearch] = useState({}); + const [orderBy, setOrderBy] = useState(["id", "desc"]); + const [filterDialog, setFilterDialog] = useState(false); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + + const history = useHistory(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/file/list", { + page: page, + page_size: pageSize, + order_by: orderBy.join(" "), + conditions: filter, + searches: search, + }) + .then((response) => { + setFiles(response.data.items); + setTotal(response.data.total); + setSelected([]); + setUsers(response.data.users); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, orderBy, filter, search]); + + const deleteFile = (id, unlink = false) => { + setLoading(true); + API.post("/admin/file/delete", { id: [id], unlink }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("deleteAsync"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteBatch = + (force, unlink = false) => + () => { + setLoading(true); + API.post("/admin/file/delete", { id: selected, force, unlink }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("deleteAsync"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = files.map((n) => n.ID); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleClick = (event, name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + return ( +
+ setFilterDialog(false)} + setSearch={setSearch} + setFilter={setFilter} + /> +
+ +
+ + setFilterDialog(true)} + > + + + + + + +
+
+ + + {selected.length > 0 && ( + + + {tDashboard("user.selectedObjects", { + num: selected.length, + })} + + + + + + + + + + + + + + + + + + )} + + + + + + 0 && + selected.length < files.length + } + checked={ + files.length > 0 && + selected.length === files.length + } + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + + + setOrderBy([ + "id", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + # + {orderBy[0] === "id" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + setOrderBy([ + "name", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("name")} + {orderBy[0] === "name" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + setOrderBy([ + "size", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("size")} + {orderBy[0] === "size" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("uploader")} + + + {t("createdAt")} + + + {tDashboard("policy.actions")} + + + + + {files.map((row) => ( + + + + handleClick(event, row.ID) + } + checked={isSelected(row.ID)} + /> + + {row.ID} + + + {row.Name} + {row.UploadSessionID && ( + + )} + + + + {sizeToString(row.Size)} + + + + {users[row.UserID] + ? users[row.UserID].Nick + : t("unknownUploader")} + + + + {formatLocalTime( + row.CreatedAt, + "YYYY-MM-DD H:mm:ss" + )} + + + + + deleteFile(row.ID) + } + size={"small"} + > + + + + + + deleteFile(row.ID, true) + } + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/File/Import.js b/src/component/Admin/File/Import.js new file mode 100644 index 0000000..ce10862 --- /dev/null +++ b/src/component/Admin/File/Import.js @@ -0,0 +1,481 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import Alert from "@material-ui/lab/Alert"; +import Fade from "@material-ui/core/Fade"; +import Paper from "@material-ui/core/Paper"; +import Popper from "@material-ui/core/Popper"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import Chip from "@material-ui/core/Chip"; +import { Dialog } from "@material-ui/core"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import PathSelector from "../../FileManager/PathSelector"; +import DialogActions from "@material-ui/core/DialogActions"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; +import { useHistory } from "react-router"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + userSelect: { + width: 400, + borderRadius: 0, + }, +})); + +function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, [value]); + + return debouncedValue; +} + +export default function Import() { + const { t } = useTranslation("dashboard", { keyPrefix: "file" }); + const { t: tCommon } = useTranslation("common"); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState({ + policy: 1, + userInput: "", + src: "", + dst: "", + recursive: true, + }); + const [anchorEl, setAnchorEl] = useState(null); + const [policies, setPolicies] = useState({}); + const [users, setUsers] = useState([]); + const [user, setUser] = useState(null); + const [selectRemote, setSelectRemote] = useState(false); + const [selectLocal, setSelectLocal] = useState(false); + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const handleCheckChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.checked, + }); + }; + + const history = useHistory(); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submit = (e) => { + e.preventDefault(); + if (user === null) { + ToggleSnackbar("top", "right", t("selectTargetUser"), "warning"); + return; + } + setLoading(true); + API.post("/admin/task/import", { + uid: user.ID, + policy_id: parseInt(options.policy), + src: options.src, + dst: options.dst, + recursive: options.recursive, + }) + .then(() => { + setLoading(false); + history.push("/admin/file"); + ToggleSnackbar( + "top", + "right", + t("importTaskCreated"), + "success" + ); + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const debouncedSearchTerm = useDebounce(options.userInput, 500); + + useEffect(() => { + if (debouncedSearchTerm !== "") { + API.post("/admin/user/list", { + page: 1, + page_size: 10000, + order_by: "id asc", + searches: { + nick: debouncedSearchTerm, + email: debouncedSearchTerm, + }, + }) + .then((response) => { + setUsers(response.data.items); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + } + }, [debouncedSearchTerm]); + + useEffect(() => { + API.post("/admin/policy/list", { + page: 1, + page_size: 10000, + order_by: "id asc", + conditions: {}, + }) + .then((response) => { + const res = {}; + response.data.items.forEach((v) => { + res[v.ID] = v; + }); + setPolicies(res); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const selectUser = (u) => { + setOptions({ + ...options, + userInput: "", + }); + setUser(u); + }; + + const setMoveTarget = (setter) => (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setter(path === "//" ? "/" : path); + }; + + const openPathSelector = (isSrcSelect) => { + if (isSrcSelect) { + if ( + !policies[options.policy] || + policies[options.policy].Type === "local" || + policies[options.policy].Type === "remote" + ) { + ToggleSnackbar( + "top", + "right", + t("manuallyPathOnly"), + "warning" + ); + return; + } + setSelectRemote(true); + } else { + if (user === null) { + ToggleSnackbar( + "top", + "right", + t("selectTargetUser"), + "warning" + ); + return; + } + setSelectLocal(true); + } + }; + + return ( +
+ setSelectRemote(false)} + aria-labelledby="form-dialog-title" + > + + {t("selectFolder")} + + + setOptions({ + ...options, + src: p, + }) + )} + /> + + + + + + setSelectLocal(false)} + aria-labelledby="form-dialog-title" + > + + {t("selectFolder")} + + + setOptions({ + ...options, + dst: p, + }) + )} + /> + + + + + +
+
+ + {t("importExternalFolder")} + +
+
+ + {t("importExternalFolderDes")} + +
+
+ + + {t("storagePolicy")} + + + + {t("storagePolicyDes")} + + +
+
+ + + {t("targetUser")} + + { + handleChange("userInput")(e); + setAnchorEl(e.currentTarget); + }} + startAdornment={ + user !== null && ( + + { + setUser(null); + }} + label={user.Nick} + /> + + ) + } + disabled={user !== null} + /> + 0 + } + anchorEl={anchorEl} + placement={"bottom"} + transition + > + {({ TransitionProps }) => ( + + + {users.map((u) => ( + + selectUser(u) + } + > + {u.Nick}{" "} + {"<" + u.Email + ">"} + + ))} + + + )} + + + {t("targetUserDes")} + + +
+ +
+ + + {t("srcFolderPath")} + + + { + handleChange("src")(e); + setAnchorEl(e.currentTarget); + }} + required + endAdornment={ + + } + /> + + + {t("selectSrcDes")} + + +
+ +
+ + + {t("dstFolderPath")} + + + { + handleChange("dst")(e); + setAnchorEl(e.currentTarget); + }} + required + endAdornment={ + + } + /> + + + {t("dstFolderPathDes")} + + +
+ +
+ + + } + label={t("recursivelyImport")} + /> + + {t("recursivelyImportDes")} + + +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Group/EditGroup.js b/src/component/Admin/Group/EditGroup.js new file mode 100644 index 0000000..40d202d --- /dev/null +++ b/src/component/Admin/Group/EditGroup.js @@ -0,0 +1,108 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import GroupForm from "./GroupForm"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +export default function EditGroupPreload() { + const { t } = useTranslation("dashboard", { keyPrefix: "group" }); + const [group, setGroup] = useState({}); + + const { id } = useParams(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + setGroup({}); + API.get("/admin/group/" + id) + .then((response) => { + // 布尔值转换 + ["ShareEnabled", "WebDAVEnabled"].forEach((v) => { + response.data[v] = response.data[v] ? "true" : "false"; + }); + [ + "archive_download", + "archive_task", + "relocate", + "one_time_download", + "share_download", + "webdav_proxy", + "share_free", + "aria2", + "redirected_source", + "advance_delete", + "select_node", + ].forEach((v) => { + if (response.data.OptionsSerialized[v] !== undefined) { + response.data.OptionsSerialized[v] = response.data + .OptionsSerialized[v] + ? "true" + : "false"; + } + }); + + // 整型转换 + ["MaxStorage", "SpeedLimit"].forEach((v) => { + response.data[v] = response.data[v].toString(); + }); + [ + "compress_size", + "decompress_size", + "source_batch", + "aria2_batch", + ].forEach((v) => { + if (response.data.OptionsSerialized[v] !== undefined) { + response.data.OptionsSerialized[v] = + response.data.OptionsSerialized[v].toString(); + } + }); + response.data.PolicyList = response.data.PolicyList.map((v) => { + return v.toString(); + }); + + response.data.OptionsSerialized.available_nodes = response.data + .OptionsSerialized.available_nodes + ? response.data.OptionsSerialized.available_nodes.map( + (v) => { + return v.toString(); + } + ) + : []; + + // JSON转换 + if ( + response.data.OptionsSerialized.aria2_options === undefined + ) { + response.data.OptionsSerialized.aria2_options = "{}"; + } else { + try { + response.data.OptionsSerialized.aria2_options = + JSON.stringify( + response.data.OptionsSerialized.aria2_options + ); + } catch (e) { + ToggleSnackbar( + "top", + "right", + t("aria2FormatError"), + "warning" + ); + return; + } + } + setGroup(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, [id]); + + return
{group.ID !== undefined && }
; +} diff --git a/src/component/Admin/Group/Group.js b/src/component/Admin/Group/Group.js new file mode 100644 index 0000000..b2d7e0a --- /dev/null +++ b/src/component/Admin/Group/Group.js @@ -0,0 +1,252 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import { sizeToString } from "../../../utils"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import { useHistory, useLocation } from "react-router"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete, Edit } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, +})); + +const columns = [ + { id: "#", minWidth: 50 }, + { id: "name", minWidth: 170 }, + { id: "type", label: "存储策略", minWidth: 170 }, + { + id: "count", + minWidth: 50, + align: "right", + }, + { + id: "size", + minWidth: 100, + align: "right", + }, + { + id: "action", + minWidth: 170, + align: "right", + }, +]; + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +export default function Group() { + const { t } = useTranslation("dashboard", { keyPrefix: "group" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [groups, setGroups] = useState([]); + const [statics, setStatics] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [policies, setPolicies] = React.useState({}); + + const location = useLocation(); + const history = useHistory(); + const query = useQuery(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/group/list", { + page: page, + page_size: pageSize, + order_by: "id desc", + }) + .then((response) => { + setGroups(response.data.items); + setStatics(response.data.statics); + setTotal(response.data.total); + setPolicies(response.data.policies); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + if (query.get("code") === "0") { + ToggleSnackbar("top", "right", "授权成功", "success"); + } else if (query.get("msg") && query.get("msg") !== "") { + ToggleSnackbar( + "top", + "right", + query.get("msg") + ", " + query.get("err"), + "warning" + ); + } + }, [location]); + + useEffect(() => { + loadList(); + }, [page, pageSize]); + + const deletePolicy = (id) => { + API.delete("/admin/group/" + id) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("deleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + return ( +
+
+ +
+ +
+
+ + + + + + + {columns.map((column) => ( + + {t(column.id)} + + ))} + + + + {groups.map((row) => ( + + {row.ID} + {row.Name} + + {row.PolicyList !== null && + row.PolicyList.map((pid, key) => { + let res = ""; + if (policies[pid]) { + res += policies[pid].Name; + } + if ( + key !== + row.PolicyList.length - 1 + ) { + res += " / "; + } + return res; + })} + + + {statics[row.ID] !== undefined && + statics[row.ID].toLocaleString()} + + + {statics[row.ID] !== undefined && + sizeToString(row.MaxStorage)} + + + + + history.push( + "/admin/group/edit/" + + row.ID + ) + } + size={"small"} + > + + + + + + deletePolicy(row.ID) + } + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/Group/GroupForm.js b/src/component/Admin/Group/GroupForm.js new file mode 100644 index 0000000..969467d --- /dev/null +++ b/src/component/Admin/Group/GroupForm.js @@ -0,0 +1,825 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import Chip from "@material-ui/core/Chip"; +import SizeInput from "../Common/SizeInput"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; +import Collapse from "@material-ui/core/Collapse"; +import { useHistory } from "react-router"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { Link } from "@material-ui/core"; +import { getSelectItemStyles } from "../../../utils"; +import NodeSelector from "./NodeSelector"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, +})); + +export default function GroupForm(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "group" }); + const { t: tVas } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [group, setGroup] = useState( + props.group + ? props.group + : { + ID: 0, + Name: "", + MaxStorage: "1073741824", // 转换类型 + ShareEnabled: "true", // 转换类型 + WebDAVEnabled: "true", // 转换类型 + SpeedLimit: "0", // 转换类型 + PolicyList: ["1"], // 转换类型,至少选择一个 + OptionsSerialized: { + // 批量转换类型 + share_download: "true", + aria2_options: "{}", // json decode + compress_size: "0", + decompress_size: "0", + source_batch: "0", + aria2_batch: "1", + available_nodes: [], + }, + } + ); + const [policies, setPolicies] = useState({}); + + const theme = useTheme(); + const history = useHistory(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/policy/list", { + page: 1, + page_size: 10000, + order_by: "id asc", + conditions: {}, + }) + .then((response) => { + const res = {}; + response.data.items.forEach((v) => { + res[v.ID] = v.Name; + }); + setPolicies(res); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, []); + + const handleChange = (name) => (event) => { + setGroup({ + ...group, + [name]: event.target.value, + }); + }; + + const handleCheckChange = (name) => (event) => { + const value = event.target.checked ? "true" : "false"; + setGroup({ + ...group, + [name]: value, + }); + }; + + const handleOptionCheckChange = (name) => (event) => { + const value = event.target.checked ? "true" : "false"; + setGroup({ + ...group, + OptionsSerialized: { + ...group.OptionsSerialized, + [name]: value, + }, + }); + }; + + const handleOptionChange = (name) => (event) => { + setGroup({ + ...group, + OptionsSerialized: { + ...group.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const submit = (e) => { + e.preventDefault(); + const groupCopy = { + ...group, + OptionsSerialized: { ...group.OptionsSerialized }, + }; + + // 布尔值转换 + ["ShareEnabled", "WebDAVEnabled"].forEach((v) => { + groupCopy[v] = groupCopy[v] === "true"; + }); + [ + "archive_download", + "archive_task", + "relocate", + "one_time_download", + "share_download", + "webdav_proxy", + "share_free", + "aria2", + "redirected_source", + "advance_delete", + "select_node", + ].forEach((v) => { + if (groupCopy.OptionsSerialized[v] !== undefined) { + groupCopy.OptionsSerialized[v] = + groupCopy.OptionsSerialized[v] === "true"; + } + }); + + // 整型转换 + ["MaxStorage", "SpeedLimit"].forEach((v) => { + groupCopy[v] = parseInt(groupCopy[v]); + }); + [ + "compress_size", + "decompress_size", + "source_batch", + "aria2_batch", + ].forEach((v) => { + if (groupCopy.OptionsSerialized[v] !== undefined) { + groupCopy.OptionsSerialized[v] = parseInt( + groupCopy.OptionsSerialized[v] + ); + } + }); + + groupCopy.PolicyList = groupCopy.PolicyList.map((v) => { + return parseInt(v); + }); + + groupCopy.OptionsSerialized.available_nodes = + groupCopy.OptionsSerialized.available_nodes.map((v) => { + return parseInt(v); + }); + + if (groupCopy.PolicyList.length < 1 && groupCopy.ID !== 3) { + ToggleSnackbar("top", "right", t("atLeastOnePolicy"), "warning"); + return; + } + + // JSON转换 + try { + groupCopy.OptionsSerialized.aria2_options = JSON.parse( + groupCopy.OptionsSerialized.aria2_options + ); + } catch (e) { + ToggleSnackbar("top", "right", t("aria2FormatError"), "warning"); + return; + } + + setLoading(true); + API.post("/admin/group", { + group: groupCopy, + }) + .then(() => { + history.push("/admin/group"); + ToggleSnackbar( + "top", + "right", + props.group ? t("saved") : t("added"), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+
+
+ + {group.ID === 0 && t("new")} + {group.ID !== 0 && + t("editGroup", { group: group.Name })} + + +
+ {group.ID !== 3 && ( + <> +
+ + + {t("nameOfGroup")} + + + + {t("nameOfGroupDes")} + + +
+ +
+ + + {t("availablePolicies")} + + + + {t("availablePoliciesDes")} + + +
+ +
+ + + + + {t("initialStorageQuotaDes")} + +
+ + )} + +
+ + + + + {t("downloadSpeedLimitDes")} + +
+ + {group.ID !== 3 && ( +
+ + + {t("bathSourceLinkLimit")} + + + + {t("bathSourceLinkLimitDes")} + + +
+ )} + + {group.ID !== 3 && ( +
+ + + } + label={t("allowCreateShareLink")} + /> + + {t("allowCreateShareLinkDes")} + + +
+ )} + +
+ + + } + label={t("allowDownloadShare")} + /> + + {t("allowDownloadShareDes")} + + +
+ +
+ + + } + label={tVas("freeDownload")} + /> + + {tVas("freeDownloadDes")} + + +
+ + {group.ID !== 3 && ( +
+ + + } + label={t("allowWabDAV")} + /> + + {t("allowWabDAVDes")} + + +
+ )} + + {group.ID !== 3 && group.WebDAVEnabled === "true" && ( +
+ + + } + label={t("allowWabDAVProxy")} + /> + + {t("allowWabDAVProxyDes")} + + +
+ )} + +
+ + + } + label={t("disableMultipleDownload")} + /> + + {t("disableMultipleDownloadDes")} + + +
+ + {group.ID !== 3 && ( +
+ + + } + label={t("allowRemoteDownload")} + /> + + {t("allowRemoteDownloadDes")} + + +
+ )} + + +
+ + + {t("aria2Options")} + + + + {t("aria2OptionsDes")} + + +
+
+ + + {t("aria2BatchSize")} + + + + {t("aria2BatchSizeDes")} + + +
+
+ + + {t("availableNodes")} + + + + {t("availableNodesDes")} + + +
+
+ + + } + label={t("allowSelectNode")} + /> + + {t("allowSelectNodeDes")} + + +
+
+ +
+ + + } + label={t("serverSideBatchDownload")} + /> + + {t("serverSideBatchDownloadDes")} + + +
+ + {group.ID !== 3 && ( +
+ + + } + label={t("compressTask")} + /> + + {t("compressTaskDes")} + + +
+ )} + + +
+ + + + + {t("compressSizeDes")} + +
+ +
+ + + + + {t("decompressSizeDes")} + +
+
+ + {group.ID !== 3 && ( +
+ + + } + label={t("migratePolicy")} + /> + + {t("migratePolicyDes")} + + +
+ )} + + {group.ID !== 3 && ( +
+ + + } + label={t("redirectedSource")} + /> + + , + ]} + /> + + +
+ )} + + {group.ID !== 3 && ( +
+ + + } + label={t("advanceDelete")} + /> + + {t("advanceDeleteDes")} + + +
+ )} +
+
+
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Group/NodeSelector.js b/src/component/Admin/Group/NodeSelector.js new file mode 100644 index 0000000..bd6fa15 --- /dev/null +++ b/src/component/Admin/Group/NodeSelector.js @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect, useState } from "react"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; +import Input from "@material-ui/core/Input"; +import Chip from "@material-ui/core/Chip"; +import MenuItem from "@material-ui/core/MenuItem"; +import Select from "@material-ui/core/Select"; +import { getSelectItemStyles } from "../../../utils"; +import { useTheme } from "@material-ui/core/styles"; + +export default function NodeSelector({ selected, handleChange }) { + const { t } = useTranslation("dashboard", { keyPrefix: "group" }); + const [nodes, setNodes] = useState({}); + const theme = useTheme(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/node/list", { + page: 1, + page_size: 10000, + order_by: "id asc", + conditions: {}, + }) + .then((response) => { + const res = {}; + response.data.items.forEach((v) => { + res[v.ID] = v.Name; + }); + setNodes(res); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, []); + + return ( + + ); +} diff --git a/src/component/Admin/Index.js b/src/component/Admin/Index.js new file mode 100644 index 0000000..b41ca1f --- /dev/null +++ b/src/component/Admin/Index.js @@ -0,0 +1,527 @@ +import React, { useCallback, useEffect, useState } from "react"; +import Grid from "@material-ui/core/Grid"; +import Paper from "@material-ui/core/Paper"; +import { + CartesianGrid, + Legend, + Line, + LineChart, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ResponsiveContainer } from "recharts/lib/component/ResponsiveContainer"; +import { makeStyles } from "@material-ui/core/styles"; +import pathHelper from "../../utils/page"; +import API from "../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Typography from "@material-ui/core/Typography"; +import Divider from "@material-ui/core/Divider"; +import List from "@material-ui/core/List"; +import ListItem from "@material-ui/core/ListItem"; +import ListItemAvatar from "@material-ui/core/ListItemAvatar"; +import Avatar from "@material-ui/core/Avatar"; +import { + Description, + Favorite, + FileCopy, + Forum, + GitHub, + Home, + Launch, + Lock, + People, + Public, + Telegram, +} from "@material-ui/icons"; +import ListItemText from "@material-ui/core/ListItemText"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; +import { blue, green, red, yellow } from "@material-ui/core/colors"; +import axios from "axios"; +import TimeAgo from "timeago-react"; +import Chip from "@material-ui/core/Chip"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import Dialog from "@material-ui/core/Dialog"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import { toggleSnackbar } from "../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + paper: { + padding: theme.spacing(3), + height: "100%", + }, + logo: { + width: 70, + }, + logoContainer: { + padding: theme.spacing(3), + display: "flex", + }, + title: { + marginLeft: 16, + }, + cloudreve: { + fontSize: 25, + color: theme.palette.text.secondary, + }, + version: { + color: theme.palette.text.hint, + }, + links: { + padding: theme.spacing(3), + }, + iconRight: { + minWidth: 0, + }, + userIcon: { + backgroundColor: blue[100], + color: blue[600], + }, + fileIcon: { + backgroundColor: yellow[100], + color: yellow[800], + }, + publicIcon: { + backgroundColor: green[100], + color: green[800], + }, + secretIcon: { + backgroundColor: red[100], + color: red[800], + }, +})); + +export default function Index() { + const { t } = useTranslation("dashboard"); + const classes = useStyles(); + const [lineData, setLineData] = useState([]); + // const [news, setNews] = useState([]); + // const [newsUsers, setNewsUsers] = useState({}); + const [open, setOpen] = React.useState(false); + const [siteURL, setSiteURL] = React.useState(""); + const [statistics, setStatistics] = useState({ + fileTotal: 0, + userTotal: 0, + publicShareTotal: 0, + secretShareTotal: 0, + }); + const [version, setVersion] = useState({ + backend: "-", + }); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const ResetSiteURL = () => { + setOpen(false); + API.patch("/admin/setting", { + options: [ + { + key: "siteURL", + value: window.location.origin, + }, + ], + }) + .then(() => { + setSiteURL(window.location.origin); + ToggleSnackbar("top", "right", t("settings.saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + API.get("/admin/summary") + .then((response) => { + const data = []; + response.data.date.forEach((v, k) => { + data.push({ + name: v, + file: response.data.files[k], + user: response.data.users[k], + share: response.data.shares[k], + }); + }); + setLineData(data); + setStatistics({ + fileTotal: response.data.fileTotal, + userTotal: response.data.userTotal, + publicShareTotal: response.data.publicShareTotal, + secretShareTotal: response.data.secretShareTotal, + }); + setVersion(response.data.version); + setSiteURL(response.data.siteURL); + if ( + response.data.siteURL === "" || + response.data.siteURL !== window.location.origin + ) { + setOpen(true); + } + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + + // axios + // .get("/api/v3/admin/news?tag=" + t("summary.newsTag")) + // .then((response) => { + // setNews(response.data.data); + // const res = {}; + // response.data.included.forEach((v) => { + // if (v.type === "users") { + // res[v.id] = v.attributes; + // } + // }); + // setNewsUsers(res); + // }) + // .catch((error) => { + // ToggleSnackbar( + // "top", + // "right", + // t("summary.newsletterError"), + // "warning" + // ); + // }); + }, []); + + return ( + + setOpen(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + + {t("summary.confirmSiteURLTitle")} + + + + + {siteURL === "" && + t("summary.siteURLNotSet", { + current: window.location.origin, + })} + {siteURL !== "" && + t("summary.siteURLNotMatch", { + current: window.location.origin, + })} + + + {t("summary.siteURLDescription")} + + + + + + + + + + + + {t("summary.trend")} + + + + + + + + + + + + + + + + + + + {t("summary.summary")} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ cloudreve +
+ + Cloudreve + + + {version.backend}{" "} + {version.is_plus === "true" && ( + + )} + +
+
+ +
+ + + window.open("https://cloudreve.org") + } + > + + + + + + + + + + window.open( + "https://github.com/cloudreve/cloudreve" + ) + } + > + + + + + + + + + + window.open("https://docs.cloudreve.org/") + } + > + + + + + + + + + + window.open(t("summary.forumLink")) + } + > + + + + + + + + + + window.open(t("summary.telegramGroupLink")) + } + > + + + + + + + + + + window.open("https://docs.cloudreve.org/use/pro/jie-shao") + } + > + + + + + + + + + +
+
+
+ {/* + + + {news && + news.map((v) => ( + <> + + window.open( + "https://forum.cloudreve.org/d/" + + v.id + ) + } + > + + + + + + {newsUsers[ + v.relationships + .startUser.data + .id + ] && + newsUsers[ + v.relationships + .startUser + .data.id + ].username}{" "} + + , + ]} + /> + + } + /> + + + + ))} + + + */} +
+ ); +} diff --git a/src/component/Admin/Node/AddNode.js b/src/component/Admin/Node/AddNode.js new file mode 100644 index 0000000..57479cb --- /dev/null +++ b/src/component/Admin/Node/AddNode.js @@ -0,0 +1,27 @@ +import React from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import NodeGuide from "./Guide/NodeGuide"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, +})); + +export default function AddNode() { + const classes = useStyles(); + return ( +
+ + + +
+ ); +} diff --git a/src/component/Admin/Node/EditNode.js b/src/component/Admin/Node/EditNode.js new file mode 100644 index 0000000..7ccc1a8 --- /dev/null +++ b/src/component/Admin/Node/EditNode.js @@ -0,0 +1,57 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import NodeGuide from "./Guide/NodeGuide"; +import { useParams } from "react-router"; +import { useDispatch } from "react-redux"; +import API from "../../../middleware/Api"; +import { toggleSnackbar } from "../../../redux/explorer"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, +})); + +export default function EditNode() { + const classes = useStyles(); + const { id } = useParams(); + const [node, setNode] = useState(null); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.get("/admin/node/" + id) + .then((response) => { + response.data.Rank = response.data.Rank.toString(); + response.data.Aria2OptionsSerialized.interval = response.data.Aria2OptionsSerialized.interval.toString(); + response.data.Aria2OptionsSerialized.timeout = response.data.Aria2OptionsSerialized.timeout.toString(); + response.data.Aria2Enabled = response.data.Aria2Enabled + ? "true" + : "false"; + setNode(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, [id]); + + return ( +
+ + {node && } + +
+ ); +} diff --git a/src/component/Admin/Node/Guide/Aria2RPC.js b/src/component/Admin/Node/Guide/Aria2RPC.js new file mode 100644 index 0000000..d065903 --- /dev/null +++ b/src/component/Admin/Node/Guide/Aria2RPC.js @@ -0,0 +1,454 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import Alert from "@material-ui/lab/Alert"; +import Box from "@material-ui/core/Box"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import API from "../../../../middleware/Api"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + pre: { + margin: "24px 0", + padding: "12px 18px", + overflow: "auto", + direction: "ltr", + borderRadius: "4px", + backgroundColor: "#272c34", + color: "#fff", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + }, + }, +})); + +export default function Aria2RPC(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "node" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const dispatch = useDispatch(); + const [loading, setLoading] = useState(false); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const testAria2 = () => { + setLoading(true); + API.post("/admin/node/aria2/test", { + type: props.node.Type, + server: props.node.Server, + secret: props.node.SlaveKey, + rpc: props.node.Aria2OptionsSerialized.server, + token: props.node.Aria2OptionsSerialized.token, + }) + .then((response) => { + ToggleSnackbar( + "top", + "right", + t("ariaSuccess", { version: response.data }), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const mode = props.node.Type === 0 ? t("slave") : t("master"); + + return ( +
{ + e.preventDefault(); + props.onSubmit(e); + }} + > + + + , + , + , + ]} + /> + + + +
+
+
1
+
+
+ + {props.node.Type === 0 + ? t("slaveTakeOverRemoteDownload") + : t("masterTakeOverRemoteDownload")} +
+ {props.node.Type === 0 + ? t("routeTaskSlave") + : t("routeTaskMaster")} +
+ +
+ + + } + label={t("enable")} + /> + } + label={t("disable")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("aria2ConfigDes", { + target: + props.node.Type === 0 + ? t("slaveNodeTarget") + : t("masterNodeTarget"), + })} + +
+                            # {t("enableRPCComment")}
+                            
+ enable-rpc=true +
# {t("rpcPortComment")} +
+ rpc-listen-port=6800 +
# {t("rpcSecretComment")} +
+ rpc-secret= + {props.node.Aria2OptionsSerialized.token} +
+
+ + + {t("rpcConfigDes")} + + +
+
+ +
+
+
3
+
+
+ + , + , + , + ]} + /> + +
+ + + {t("rpcServer")} + + + + {t("rpcServerHelpDes")} + + +
+
+
+ +
+
+
4
+
+
+ + ]} + /> + +
+ +
+
+
+ +
+
+
5
+
+
+ + ]} + /> + +
+ +
+
+
+ +
+
+
5
+
+
+ + {t("aria2SettingDes")} + +
+ + + {t("refreshInterval")} + + + + {t("refreshIntervalDes")} + + +
+
+ + + {t("rpcTimeout")} + + + + {t("rpcTimeoutDes")} + + +
+
+ + + {t("globalOptions")} + + + + {t("globalOptionsDes")} + + +
+
+
+ +
+
+
6
+
+
+ + {t("testAria2Des", { mode })} + {props.node.Type === 0 && + t("testAria2DesSlaveAddition")} + +
+ +
+
+
+
+ +
+ {props.activeStep !== 0 && ( + + )} + +
+ + ); +} diff --git a/src/component/Admin/Node/Guide/Communication.js b/src/component/Admin/Node/Guide/Communication.js new file mode 100644 index 0000000..6a99e49 --- /dev/null +++ b/src/component/Admin/Node/Guide/Communication.js @@ -0,0 +1,305 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import Alert from "@material-ui/lab/Alert"; +import Box from "@material-ui/core/Box"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + pre: { + margin: "24px 0", + padding: "12px 18px", + overflow: "auto", + direction: "ltr", + borderRadius: "4px", + backgroundColor: "#272c34", + color: "#fff", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + }, + }, +})); + +export default function Communication(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "node" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const dispatch = useDispatch(); + const [loading, setLoading] = useState(false); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const testSlave = () => { + setLoading(true); + + // 测试路径是否可用 + API.post("/admin/policy/test/slave", { + server: props.node.Server, + secret: props.node.SlaveKey, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + tDashboard("policy.communicationOK"), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
{ + e.preventDefault(); + props.onSubmit(e); + }} + > + + ]} + /> + + +
+
+
1
+
+
+ + {tDashboard("policy.remoteCopyBinaryDescription")} + +
+
+ +
+
+
2
+
+
+ + {tDashboard("policy.remoteSecretDescription")} + +
+ + + {tDashboard("policy.remoteSecret")} + + + +
+
+
+ +
+
+
3
+
+
+ + {tDashboard("policy.modifyRemoteConfig")} +
+ ]} + /> +
+
+                        [System]
+                        
+ Mode = slave +
+ Listen = :5212 +
+
+ [Slave] +
+ Secret = {props.node.SlaveKey} +
+
+ ,
]} + /> +
+ [OptionOverwrite] +
; {t("workerNumDes")} +
+ max_worker_num = 50 +
; {t("parallelTransferDes")} +
+ max_parallel_transfer = 10 +
; {t("chunkRetriesDes")} +
+ chunk_retries = 10 +
+ + {tDashboard("policy.remoteConfigDifference")} +
    +
  • + , + , + , + ]} + /> +
  • +
  • + , + , + ]} + /> +
  • +
+ {t("multipleMasterDes")} +
+
+
+ +
+
+
4
+
+
+ + {tDashboard("policy.inputRemoteAddress")} +
+ {tDashboard("policy.inputRemoteAddressDes")} +
+
+ + + {tDashboard("policy.remoteAddress")} + + + +
+
+
+ +
+
+
5
+
+
+ + {tDashboard("policy.testCommunicationDes")} + +
+ +
+
+
+ +
+ +
+ + ); +} diff --git a/src/component/Admin/Node/Guide/Completed.js b/src/component/Admin/Node/Guide/Completed.js new file mode 100644 index 0000000..1aa835b --- /dev/null +++ b/src/component/Admin/Node/Guide/Completed.js @@ -0,0 +1,98 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React from "react"; +import Typography from "@material-ui/core/Typography"; +import Button from "@material-ui/core/Button"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + pre: { + margin: "24px 0", + padding: "12px 18px", + overflow: "auto", + direction: "ltr", + borderRadius: "4px", + backgroundColor: "#272c34", + color: "#fff", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + }, + }, +})); + +export default function Completed(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "node" }); + const classes = useStyles(); + const history = useHistory(); + + return ( +
+ {t("nodeSaved")} + + {t("nodeSavedFutureAction")} + + +
+ +
+
+ ); +} diff --git a/src/component/Admin/Node/Guide/Metainfo.js b/src/component/Admin/Node/Guide/Metainfo.js new file mode 100644 index 0000000..a2b6ede --- /dev/null +++ b/src/component/Admin/Node/Guide/Metainfo.js @@ -0,0 +1,157 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import Button from "@material-ui/core/Button"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + pre: { + margin: "24px 0", + padding: "12px 18px", + overflow: "auto", + direction: "ltr", + borderRadius: "4px", + backgroundColor: "#272c34", + color: "#fff", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + }, + }, +})); + +export default function Metainfo(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "node" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const dispatch = useDispatch(); + const [loading, setLoading] = useState(false); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + return ( +
{ + e.preventDefault(); + props.onSubmit(e); + }} + > +
+
+
1
+
+
+ {t("nameNode")} +
+ + + +
+
+
+ +
+
+
2
+
+
+ + {t("loadBalancerRankDes")} + +
+ + + {t("loadBalancerRank")} + + + +
+
+
+ +
+ +
+
+ ); +} diff --git a/src/component/Admin/Node/Guide/NodeGuide.js b/src/component/Admin/Node/Guide/NodeGuide.js new file mode 100644 index 0000000..1d3e62f --- /dev/null +++ b/src/component/Admin/Node/Guide/NodeGuide.js @@ -0,0 +1,191 @@ +import React, { useCallback, useMemo, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import { randomStr } from "../../../../utils"; +import Communication from "./Communication"; +import Aria2RPC from "./Aria2RPC"; +import API from "../../../../middleware/Api"; +import Metainfo from "./Metainfo"; +import Completed from "./Completed"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const steps = [ + { + slaveOnly: true, + title: "communication", + optional: false, + component: function show(p) { + return ; + }, + }, + { + slaveOnly: false, + title: "remoteDownload", + optional: false, + component: function show(p) { + return ; + }, + }, + { + slaveOnly: false, + title: "otherSettings", + optional: false, + component: function show(p) { + return ; + }, + }, + { + slaveOnly: false, + title: "finish", + optional: false, + component: function show(p) { + return ; + }, + }, +]; + +export default function NodeGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "node" }); + const { t: tDashboard } = useTranslation("dashboard"); + const [activeStep, setActiveStep] = useState(0); + const [skipped, setSkipped] = React.useState(new Set()); + const [loading, setLoading] = useState(false); + const [node, setNode] = useState( + props.node + ? props.node + : { + Status: 1, + Type: 0, + Aria2Enabled: "false", + Server: "https://example.com:5212", + SlaveKey: randomStr(64), + MasterKey: randomStr(64), + Rank: "0", + Aria2OptionsSerialized: { + token: randomStr(32), + options: "{}", + interval: "10", + timeout: "10", + }, + } + ); + + const usedSteps = useMemo(() => { + return steps.filter((step) => !(step.slaveOnly && node.Type === 1)); + }, [node.Type]); + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const handleTextChange = (name) => (event) => { + setNode({ + ...node, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setNode({ + ...node, + Aria2OptionsSerialized: { + ...node.Aria2OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const nextStep = () => { + if (props.node || activeStep + 1 === steps.length - 1) { + setLoading(true); + + const nodeCopy = { ...node }; + nodeCopy.Aria2OptionsSerialized = { + ...node.Aria2OptionsSerialized, + }; + nodeCopy.Rank = parseInt(nodeCopy.Rank); + nodeCopy.Aria2OptionsSerialized.interval = parseInt( + nodeCopy.Aria2OptionsSerialized.interval + ); + nodeCopy.Aria2OptionsSerialized.timeout = parseInt( + nodeCopy.Aria2OptionsSerialized.timeout + ); + nodeCopy.Aria2Enabled = nodeCopy.Aria2Enabled === "true"; + API.post("/admin/node", { + node: nodeCopy, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + props.node ? t("nodeSavedNow") : t("nodeAdded"), + "success" + ); + setActiveStep(activeStep + 1); + setLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + } else { + setActiveStep(activeStep + 1); + } + }; + + return ( +
+ + {props.node ? t("editNode") : t("addNode")} + + + {usedSteps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {tDashboard("policy.optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + if (!(label.slaveOnly && node.Type === 1)) { + return ( + + + {t(label.title)} + + + ); + } + })} + + + {usedSteps[activeStep].component({ + onSubmit: (e) => nextStep(), + node: node, + loading: loading, + onBack: (e) => setActiveStep(activeStep - 1), + handleTextChange: handleTextChange, + activeStep: activeStep, + handleOptionChange: handleOptionChange, + })} +
+ ); +} diff --git a/src/component/Admin/Node/Node.js b/src/component/Admin/Node/Node.js new file mode 100644 index 0000000..87f076d --- /dev/null +++ b/src/component/Admin/Node/Node.js @@ -0,0 +1,333 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import { useHistory } from "react-router"; +import IconButton from "@material-ui/core/IconButton"; +import { + Cancel, + CheckCircle, + Delete, + Edit, + Pause, + PlayArrow, +} from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Chip from "@material-ui/core/Chip"; +import classNames from "classnames"; +import Box from "@material-ui/core/Box"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + disabledBadge: { + marginLeft: theme.spacing(1), + height: 18, + }, + disabledCell: { + color: theme.palette.text.disabled, + }, + verticalAlign: { + verticalAlign: "middle", + display: "inline-block", + }, +})); + +const columns = [ + { id: "#", minWidth: 50 }, + { id: "name", minWidth: 170 }, + { + id: "status", + minWidth: 50, + }, + { + id: "features", + minWidth: 170, + }, + { + id: "action", + minWidth: 170, + }, +]; + +const features = [ + { + field: "Aria2Enabled", + name: "remoteDownload", + }, +]; + +export default function Node() { + const { t } = useTranslation("dashboard", { keyPrefix: "node" }); + const classes = useStyles(); + const [nodes, setNodes] = useState([]); + const [isActive, setIsActive] = useState({}); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + + const history = useHistory(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/node/list", { + page: page, + page_size: pageSize, + order_by: "id desc", + }) + .then((response) => { + setNodes(response.data.items); + setTotal(response.data.total); + setIsActive(response.data.active); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const toggleNode = (id, desired) => { + setLoading(true); + API.patch("/admin/node/enable/" + id + "/" + desired) + .then((response) => { + loadList(); + ToggleSnackbar( + "top", + "right", + desired === 1 ? t("nodeDisabled") : t("nodeEnabled"), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteNode = (id) => { + setLoading(true); + API.delete("/admin/node/" + id) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("nodeDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize]); + + const getStatusBadge = (status) => { + if (status === 1) { + return ( + + ); + } + }; + + const getFeatureBadge = (node) => + features.map((feature) => { + if (node[feature.field]) { + return ( + + ); + } + }); + + const getRealStatusBadge = (status) => + status ? ( + + {" "} + {t("online")} + + ) : ( + + {" "} + {t("offline")} + + ); + + return ( +
+
+ +
+ +
+
+ + + + + + + {columns.map((column) => ( + + {t(column.id)} + + ))} + + + + {nodes.map((row) => ( + + {row.ID} + + {row.Name} + {getStatusBadge(row.Status)} + + + {getRealStatusBadge(isActive[row.ID])} + + + {getFeatureBadge(row)} + + + + + + toggleNode( + row.ID, + 1 - row.Status + ) + } + size={"small"} + > + {row.Status === 1 && ( + + )} + {row.Status !== 1 && } + + + + + history.push( + "/admin/node/edit/" + + row.ID + ) + } + size={"small"} + > + + + + + + deleteNode(row.ID) + } + disabled={loading} + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/Order/Order.js b/src/component/Admin/Order/Order.js new file mode 100644 index 0000000..f9a9fcb --- /dev/null +++ b/src/component/Admin/Order/Order.js @@ -0,0 +1,432 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Checkbox from "@material-ui/core/Checkbox"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { lighten } from "@material-ui/core"; +import Link from "@material-ui/core/Link"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import ShareFilter from "../Dialogs/ShareFilter"; +import { formatLocalTime } from "../../../utils/datetime"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, + highlight: + theme.palette.type === "light" + ? { + color: theme.palette.secondary.main, + backgroundColor: lighten(theme.palette.secondary.light, 0.85), + } + : { + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondary.dark, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, +})); + +export default function Order() { + const { t } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [orders, setOrders] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState({}); + const [users, setUsers] = useState({}); + const [search, setSearch] = useState({}); + const [orderBy, setOrderBy] = useState(["id", "desc"]); + const [filterDialog, setFilterDialog] = useState(false); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/order/list", { + page: page, + page_size: pageSize, + order_by: orderBy.join(" "), + conditions: filter, + searches: search, + }) + .then((response) => { + setUsers(response.data.users); + setOrders(response.data.items); + setTotal(response.data.total); + setSelected([]); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, orderBy, filter, search]); + + const deletePolicy = (id) => { + setLoading(true); + API.post("/admin/order/delete", { id: [id] }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("orderDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteBatch = () => { + setLoading(true); + API.post("/admin/order/delete", { id: selected }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("orderDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = orders.map((n) => n.ID); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleClick = (event, name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + return ( +
+ setFilterDialog(false)} + setSearch={setSearch} + setFilter={setFilter} + /> +
+
+ +
+
+ + + {selected.length > 0 && ( + + + {tDashboard("user.selectedObjects", { + num: selected.length, + })} + + + + + + + + )} + + + + + + 0 && + selected.length < orders.length + } + checked={ + orders.length > 0 && + selected.length === orders.length + } + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + + + setOrderBy([ + "id", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + # + {orderBy[0] === "id" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("orderName")} + + + {t("product")} + + + + setOrderBy([ + "order_no", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("orderNumber")} + {orderBy[0] === "order_no" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("price")} + + + {t("qyt")} + + + {t("status")} + + + {t("paidBy")} + + + {t("orderOwner")} + + + {tDashboard("file.createdAt")} + + + {tDashboard("policy.actions")} + + + + + {orders.map((row) => ( + + + + handleClick(event, row.ID) + } + checked={isSelected(row.ID)} + /> + + {row.ID} + {row.Name} + + {row.Type === 0 && t("storagePack")} + {row.Type === 1 && + t("purchasableGroups")} + {row.Type === 2 && t("credits")} + + {row.OrderNo} + + {row.Method === "score" && row.Price} + {row.Method !== "score" && ( + <> + ¥{(row.Price / 100).toFixed(2)} + + )} + + + {row.Num} + + + {row.Status === 0 && t("unpaid")} + {row.Status === 1 && t("paid")} + + + {row.Method === "score" && t("credits")} + {row.Method === "alipay" && t("alipay")} + {row.Method === "payjs" && t("payjs")} + {row.Method === "custom" && + t("customPayment")} + {row.Method === "weixin" && + t("wechatPay")} + + + + {users[row.UserID] + ? users[row.UserID].Nick + : tDashboard( + "file.unknownUploader" + )} + + + + {formatLocalTime(row.CreatedAt)} + + + + + deletePolicy(row.ID) + } + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/Policy/AddPolicy.js b/src/component/Admin/Policy/AddPolicy.js new file mode 100644 index 0000000..9e2c3c1 --- /dev/null +++ b/src/component/Admin/Policy/AddPolicy.js @@ -0,0 +1,45 @@ +import React from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import { useParams } from "react-router"; +import LocalGuide from "./Guid/LocalGuide"; +import RemoteGuide from "./Guid/RemoteGuide"; +import QiniuGuide from "./Guid/QiniuGuide"; +import OSSGuide from "./Guid/OSSGuide"; +import UpyunGuide from "./Guid/UpyunGuide"; +import COSGuide from "./Guid/COSGuide"; +import OneDriveGuide from "./Guid/OneDriveGuide"; +import S3Guide from "./Guid/S3Guide"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, +})); + +export default function AddPolicyParent() { + const classes = useStyles(); + + const { type } = useParams(); + + return ( +
+ + {type === "local" && } + {type === "remote" && } + {type === "qiniu" && } + {type === "oss" && } + {type === "upyun" && } + {type === "cos" && } + {type === "onedrive" && } + {type === "s3" && } + +
+ ); +} diff --git a/src/component/Admin/Policy/EditPolicy.js b/src/component/Admin/Policy/EditPolicy.js new file mode 100644 index 0000000..6b40e5a --- /dev/null +++ b/src/component/Admin/Policy/EditPolicy.js @@ -0,0 +1,80 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import { useParams } from "react-router"; +import LocalGuide from "./Guid/LocalGuide"; +import RemoteGuide from "./Guid/RemoteGuide"; +import QiniuGuide from "./Guid/QiniuGuide"; +import OSSGuide from "./Guid/OSSGuide"; +import UpyunGuide from "./Guid/UpyunGuide"; +import S3Guide from "./Guid/S3Guide"; +import COSGuide from "./Guid/COSGuide"; +import OneDriveGuide from "./Guid/OneDriveGuide"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import EditPro from "./Guid/EditPro"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { transformResponse } from "./utils"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, +})); + +export default function EditPolicyPreload() { + const classes = useStyles(); + const [type, setType] = useState(""); + const [policy, setPolicy] = useState({}); + + const { mode, id } = useParams(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + setType(""); + API.get("/admin/policy/" + id) + .then((response) => { + response = transformResponse(response); + setPolicy(response.data); + setType(response.data.Type); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, [id]); + + return ( +
+ + {mode === "guide" && ( + <> + {type === "local" && } + {type === "remote" && } + {type === "qiniu" && } + {type === "oss" && } + {type === "upyun" && } + {type === "cos" && } + {type === "onedrive" && ( + + )} + {type === "s3" && } + + )} + + {mode === "pro" && type !== "" && } + +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/COSGuide.js b/src/component/Admin/Policy/Guid/COSGuide.js new file mode 100644 index 0000000..51a64c2 --- /dev/null +++ b/src/component/Admin/Policy/Guid/COSGuide.js @@ -0,0 +1,1220 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import DomainInput from "../../Common/DomainInput"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import { getNumber } from "../../../../utils"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + }, +})); + +const steps = [ + { + title: "storageBucket", + optional: false, + }, + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "corsSettingStep", + optional: true, + }, + { + title: "callbackFunctionStep", + optional: true, + }, + { + title: "finishStep", + optional: false, + }, +]; + +export default function COSGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [skipped, setSkipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [useCDN, setUseCDN] = useState("false"); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "cos", + Name: "", + SecretKey: "", + AccessKey: "", + BaseURL: "", + Server: "", + IsPrivate: "true", + DirNameRule: "uploads/{year}/{month}/{day}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + placeholder_with_size: "false", + }, + } + ); + const [policyID, setPolicyID] = useState( + props.policy ? props.policy.ID : 0 + ); + const [region, setRegion] = useState("ap-chengdu"); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + if (useCDN === "false") { + policyCopy.BaseURL = policy.Server; + } + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then((response) => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(4); + setPolicyID(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + const createCORS = () => { + setLoading(true); + API.post("/admin/policy/cors", { + id: policyID, + }) + .then(() => { + ToggleSnackbar("top", "right", t("corsPolicyAdded"), "success"); + setActiveStep(5); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const creatCallback = () => { + setLoading(true); + API.post("/admin/policy/scf", { + id: policyID, + region: region, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + t("callbackFunctionAdded"), + "success" + ); + setActiveStep(6); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+ + {props.policy + ? t("editCOSStoragePolicy") + : t("addCOSStoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {t("optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + + {activeStep === 0 && ( +
{ + e.preventDefault(); + setActiveStep(1); + }} + > +
+
+
0
+
+
+ + ]} + /> + +
+
+ +
+
+
1
+
+
+ + , + ]} + /> + +
+
+ +
+
+
2
+
+
+ + ]} + /> + +
+ + + {t("qiniuBucketName")} + + + +
+
+
+ +
+
+
3
+
+
+ + ]} + /> + +
+ + + + } + label={t("cosPrivateRW")} + /> + + } + label={t("cosPublicRW")} + /> + + +
+
+
+ +
+
+
4
+
+
+ + , + , + ]} + /> + +
+ +
+
+
+ +
+
+
5
+
+
+ + {t("cosCDNDes")} + +
+ + { + setUseCDN(e.target.value); + }} + row + > + + } + label={t("use")} + /> + + } + label={t("notUse")} + /> + + +
+
+
+ + +
+
+
6
+
+
+ + , + ]} + /> + +
+ +
+
+
+
+ +
+
+
+ {getNumber(6, [useCDN === "true"])} +
+
+
+ + , + ]} + /> + +
+ + + {t("secretId")} + + + +
+
+ + + {t("secretKey")} + + + +
+
+
+ +
+
+
+ {getNumber(7, [useCDN === "true"])} +
+
+
+ + {t("nameThePolicyFirst")} + +
+ + + {t("policyName")} + + + +
+
+
+ +
+ +
+ + )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + setMagicVar("file")} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + { + if ( + policy.IsPrivate === "true" && + e.target.value === "true" + ) { + ToggleSnackbar( + "top", + "right", + t( + "cannotEnableForPrivateBucket" + ), + "warning" + ); + } + handleChange("IsOriginLinkEnable")( + e + ); + }} + row + > + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
+
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(3, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("createPlaceholderDes")} + +
+ + + + } + label={t("createPlaceholder")} + /> + + } + label={t("notCreatePlaceholder")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 4 && ( +
+
+
+
+ + {t("ossCORSDes")} + +
+ +
+
+
+
+ {" "} +
+ + )} + + {activeStep === 5 && ( +
+
+
+
+ + , + ]} + /> +
+
+
+ + {t("cosCallbackCreate")} + + +
+ + + {t("cosBucketRegion")} + + + +
+ +
+ +
+
+
+
+ {" "} +
+ + )} + + {activeStep === 6 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/EditPro.js b/src/component/Admin/Policy/Guid/EditPro.js new file mode 100644 index 0000000..f8b1e08 --- /dev/null +++ b/src/component/Admin/Policy/Guid/EditPro.js @@ -0,0 +1,690 @@ +import React, { useCallback, useState } from "react"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +export default function EditPro(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const [, setLoading] = useState(false); + const [policy, setPolicy] = useState(props.policy); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + return ( +
+ {t("editPolicy")} + +
+ + + + {t("setting")} + {t("value")} + {t("description")} + + + + + + {t("id")} + + {policy.ID} + {t("policyID")} + + + + {t("type")} + + {policy.Type} + {t("policyType")} + + + + {t("name")} + + + + + + + {t("policyName")} + + + + {t("server")} + + + + + + + {t("policyEndpoint")} + + + + {t("bucketName")} + + + + + + + {t("bucketID")} + + + + {t("privateBucket")} + + + + + + } + label={t("yes")} + /> + + } + label={t("no")} + /> + + + + {t("privateBucketDes")} + + + + {t("resourceRootURL")} + + + + + + + {t("resourceRootURLDes")} + + + + {t("accessKey")} + + + + + + + {t("akDes")} + + + + {t("secretKey")} + + + + + + + {t("secretKey")} + + + + {t("maxSizeBytes")} + + + + + + + {t("maxSizeBytesDes")} + + + + {t("autoRename")} + + + + + + } + label={t("yes")} + /> + + } + label={t("no")} + /> + + + + {t("autoRenameDes")} + + + + {t("storagePath")} + + + + + + + {t("storagePathDes")} + + + + {t("fileName")} + + + + + + + {t("fileNameDes")} + + + + {t("allowGetSourceLink")} + + + + + + } + label={t("yes")} + /> + + } + label={t("no")} + /> + + + + + {t("allowGetSourceLinkDes")} + + + + + {t("upyunToken")} + + + + + + + {t("upyunOnly")} + + + + {t("allowedFileExtension")} + + + + + + + {t("emptyIsNoLimit")} + + + + {t("allowedMimetype")} + + + + + + + {t("qiniuOnly")} + + + + {t("odRedirectURL")} + + + + + + + + {t("noModificationNeeded")} + + + + + {t("odReverseProxy")} + + + + + + + {t("odOnly")} + + + + {t("odDriverID")} + + + + + + + {t("odDriverIDDes")} + + + + {t("s3Region")} + + + + + + + {t("s3Only")} + + + + {t("lanEndpoint")} + + + + + + + {t("ossOnly")} + + + + {t("chunkSizeBytes")} + + + + + + + {t("chunkSizeBytesDes")} + + + + {t("placeHolderWithSize")} + + + + + + } + label={t("yes")} + /> + + } + label={t("no")} + /> + + + + + {t("placeHolderWithSizeDes")} + + + + + {t("tps")} + + + + + + + {t("odOnly")} + + + + {t("tpsBurst")} + + + + + + + {t("odOnly")} + + + + {t("usePathEndpoint")} + + + + + + } + label={t("yes")} + /> + + } + label={t("no")} + /> + + + + {t("s3Only")} + + + + {t("thumbExt")} + + + + + + + {t("thumbExtDes")} + + +
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Policy/Guid/LocalGuide.js b/src/component/Admin/Policy/Guid/LocalGuide.js new file mode 100644 index 0000000..c6c08db --- /dev/null +++ b/src/component/Admin/Policy/Guid/LocalGuide.js @@ -0,0 +1,797 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import DomainInput from "../../Common/DomainInput"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { getNumber } from "../../../../utils"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, +})); + +const steps = [ + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "finishStep", + optional: false, + }, +]; + +export default function LocalGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [skipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [useCDN, setUseCDN] = useState("false"); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "local", + Name: "", + DirNameRule: "uploads/{uid}/{path}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + BaseURL: "", + IsPrivate: "true", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + chunk_size: 25 << 20, + }, + } + ); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const checkPathSetting = (e) => { + e.preventDefault(); + setLoading(true); + + // 测试路径是否可用 + API.post("/admin/policy/test/path", { + path: policy.DirNameRule, + }) + .then(() => { + setActiveStep(1); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + // 处理存储策略 + if (useCDN === "false" || policy.IsOriginLinkEnable === "false") { + policyCopy.BaseURL = ""; + } + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(4); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + return ( +
+ + {props.policy + ? t("editLocalStoragePolicy") + : t("addLocalStoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {t("optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + {activeStep === 0 && ( +
+
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + setMagicVar("file")} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ +
+
+ )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("useCDN")} +
+ {t("useCDNDes")} +
+ +
+ + { + if ( + e.target.value === "false" + ) { + setPolicy({ + ...policy, + BaseURL: "", + }); + } + setUseCDN(e.target.value); + }} + row + > + + } + label={t("use")} + /> + + } + label={t("notUse")} + /> + + +
+
+
+ + +
+
+
3
+
+
+ + {t("cdnDomain")} + + +
+ +
+
+
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(3, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("chunkSizeLabel")} +
+ {t("chunkSizeDes")} +
+
+ +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
+
+
+
+ + {t("nameThePolicy")} + +
+ + + {t("policyName")} + + + +
+
+
+
+ {" "} + +
+ + )} + + {activeStep === 4 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/OSSGuide.js b/src/component/Admin/Policy/Guid/OSSGuide.js new file mode 100644 index 0000000..c04b1d8 --- /dev/null +++ b/src/component/Admin/Policy/Guid/OSSGuide.js @@ -0,0 +1,1204 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import DomainInput from "../../Common/DomainInput"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import { getNumber } from "../../../../utils"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + }, +})); + +const steps = [ + { + title: "storageBucket", + optional: false, + }, + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "corsSettingStep", + optional: true, + }, + { + title: "finishStep", + optional: false, + }, +]; + +export default function OSSGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [skipped, setSkipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [useCDN, setUseCDN] = useState("false"); + const [useLanEndpoint, setUseLanEndpoint] = useState( + props.policy && props.policy.OptionsSerialized.server_side_endpoint + ? props.policy.OptionsSerialized.server_side_endpoint !== "" + : false + ); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "oss", + Name: "", + SecretKey: "", + AccessKey: "", + BaseURL: "", + Server: "", + IsPrivate: "true", + DirNameRule: "uploads/{year}/{month}/{day}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + server_side_endpoint: "", + chunk_size: 25 << 20, + placeholder_with_size: "false", + }, + } + ); + const [policyID, setPolicyID] = useState( + props.policy ? props.policy.ID : 0 + ); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + if (useCDN === "false") { + policyCopy.BaseURL = ""; + } + + if (!useLanEndpoint) { + policyCopy.OptionsSerialized.server_side_endpoint = ""; + } + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then((response) => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(4); + setPolicyID(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + const createCORS = () => { + setLoading(true); + API.post("/admin/policy/cors", { + id: policyID, + }) + .then(() => { + ToggleSnackbar("top", "right", t("corsPolicyAdded"), "success"); + setActiveStep(5); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+ + {props.policy + ? t("editOSSStoragePolicy") + : t("addOSSStoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {t("optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + + {activeStep === 0 && ( +
{ + e.preventDefault(); + setActiveStep(1); + }} + > +
+
+
0
+
+
+ + ]} + /> + +
+
+ +
+
+
1
+
+
+ + , + , + , + , + ]} + /> + +
+
+ +
+
+
2
+
+
+ + ]} + /> + +
+ + + {t("bucketName")} + + + +
+
+
+ +
+
+
3
+
+
+ + {t("bucketTypeDes")} + +
+ + + + } + label={t("privateBucket")} + /> + + } + label={t("publicReadBucket")} + /> + + +
+
+
+ +
+
+
4
+
+
+ + , + , + , + ]} + /> + +
+ + + {t("endpoint")} + + [\\w\\-]*)(?:\\.))?(?[\\w\\-]*))\\.(?[\\w\\-]*)", + title: t("endpointDomainOnly"), + }} + /> + +
+
+
+ +
+
+
5
+
+
+ + {t("ossLANEndpointDes")} + +
+ + { + setUseLanEndpoint( + e.target.value === "true" + ); + }} + row + > + + } + label={t("use")} + /> + + } + label={t("notUse")} + /> + + +
+ +
+ + + {t("intranetEndPoint")} + + [\\w\\-]*)(?:\\.))?(?[\\w\\-]*))\\.(?[\\w\\-]*)", + title: t("endpointDomainOnly"), + }} + /> + +
+
+
+
+ +
+
+
6
+
+
+ + {t("ossCDNDes")} + +
+ + { + setUseCDN(e.target.value); + }} + row + > + + } + label={t("use")} + /> + + } + label={t("notUse")} + /> + + +
+
+
+ + +
+
+
7
+
+
+ + , + ]} + /> + +
+ +
+
+
+
+ +
+
+
+ {getNumber(7, [useCDN === "true"])} +
+
+
+ + , + ]} + /> + +
+ + + AccessKey ID + + + +
+
+ + + Access Key Secret + + + +
+
+
+ +
+
+
+ {getNumber(8, [useCDN === "true"])} +
+
+
+ + {t("nameThePolicyFirst")} + +
+ + + {t("policyName")} + + + +
+
+
+ +
+ +
+ + )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + setMagicVar("file")} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + { + if ( + policy.IsPrivate === "true" && + e.target.value === "true" + ) { + ToggleSnackbar( + "top", + "right", + t( + "cannotEnableForPrivateBucket" + ), + "warning" + ); + } + handleChange("IsOriginLinkEnable")( + e + ); + }} + row + > + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
+
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(3, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("chunkSizeLabelOSS")} +
+ {t("chunkSizeDes")} +
+
+ +
+
+
+ +
+
+
+ {getNumber(4, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("createPlaceholderDes")} + +
+ + + + } + label={t("createPlaceholder")} + /> + + } + label={t("notCreatePlaceholder")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 4 && ( +
+
+
+
+ + {t("ossCORSDes")} + +
+ +
+
+
+
+ {" "} +
+ + )} + + {activeStep === 5 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/OneDriveGuide.js b/src/component/Admin/Policy/Guid/OneDriveGuide.js new file mode 100644 index 0000000..55bf078 --- /dev/null +++ b/src/component/Admin/Policy/Guid/OneDriveGuide.js @@ -0,0 +1,1323 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useEffect, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import AlertDialog from "../../Dialogs/Alert"; +import { getNumber } from "../../../../utils"; +import DomainInput from "../../Common/DomainInput"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + }, +})); + +const steps = [ + { + title: "applicationRegistration", + optional: false, + }, + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "grantAccess", + optional: false, + }, + { + title: "finishStep", + optional: false, + }, +]; + +export default function OneDriveGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [skipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [useCDN, setUseCDN] = useState( + props.policy && props.policy.OptionsSerialized.od_proxy + ? props.policy.OptionsSerialized.od_proxy !== "" + : false + ); + const [useSharePoint, setUseSharePoint] = useState( + props.policy && props.policy.OptionsSerialized.od_driver + ? props.policy.OptionsSerialized.od_driver !== "" + : false + ); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "onedrive", + Name: "", + BucketName: "", + SecretKey: "", + AccessKey: "", + BaseURL: "", + Server: "https://graph.microsoft.com/v1.0", + IsPrivate: "true", + DirNameRule: "uploads/{year}/{month}/{day}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + od_redirect: "", + od_proxy: "", + od_driver: "", + chunk_size: 50 << 20, + placeholder_with_size: "false", + tps_limit: "0", + tps_limit_burst: "0", + }, + } + ); + const [policyID, setPolicyID] = useState( + props.policy ? props.policy.ID : 0 + ); + const [httpsAlert, setHttpsAlert] = useState(false); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/setting", { + keys: ["siteURL"], + }) + .then((response) => { + if (!response.data.siteURL.startsWith("https://")) { + setHttpsAlert(true); + } + if (policy.OptionsSerialized.od_redirect === "") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + od_redirect: new URL( + "/api/v3/callback/onedrive/auth", + response.data.siteURL + ).toString(), + }, + }); + } + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, []); + + const statOAuth = () => { + setLoading(true); + API.get("/admin/policy/" + policyID + "/oauth/onedrive") + .then((response) => { + window.location.href = response.data; + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + setLoading(false); + }); + }; + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + // baseURL处理 + if (policyCopy.Server === "https://graph.microsoft.com/v1.0") { + policyCopy.BaseURL = + "https://login.microsoftonline.com/common/oauth2/v2.0"; + } else { + policyCopy.BaseURL = "https://login.chinacloudapi.cn/common/oauth2"; + } + + if (!useCDN) { + policyCopy.OptionsSerialized.od_proxy = ""; + } + + if (!useSharePoint) { + policyCopy.OptionsSerialized.od_driver = ""; + } + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then((response) => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(4); + setPolicyID(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + return ( +
+ setHttpsAlert(false)} + title={t("warning")} + msg={t("odHttpsWarning")} + /> + + {props.policy + ? t("editOdStoragePolicy") + : t("addOdStoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {t("optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + + {activeStep === 0 && ( +
{ + e.preventDefault(); + setActiveStep(1); + }} + > +
+
+
1
+
+
+ + , + , + , + ]} + /> + +
+
+ +
+
+
2
+
+
+ + , + , + ]} + /> + +
+
+ +
+
+
3
+
+
+ + , + , + , + , + , + ]} + /> + +
+
+ +
+
+
4
+
+
+ + , + , + ]} + /> + +
+ + + {t("aadAppID")} + + + +
+
+
+ +
+
+
5
+
+
+ + , + , + , + , + ]} + /> + +
+ + + {t("aadAppSecret")} + + + +
+
+
+ +
+
+
6
+
+
+ + {t("aadAccountCloudDes")} + +
+ + + + } + label={t("multiTenant")} + /> + + } + label={t("gallatin")} + /> + + +
+
+
+ +
+
+
7
+
+
+ + {t("sharePointDes")} + +
+ + { + setUseSharePoint( + e.target.value === "true" + ); + }} + row + > + + } + label={t("saveToSharePoint")} + /> + + } + label={t("saveToOneDrive")} + /> + + +
+ +
+ + + {t("spSiteURL")} + + + +
+
+
+
+ +
+
+
8
+
+
+ + {t("odReverseProxyURLDes")} + +
+ + { + setUseCDN( + e.target.value === "true" + ); + }} + row + > + + } + label={t("use")} + /> + + } + label={t("notUse")} + /> + + +
+ +
+ + + +
+
+
+
+ +
+
+
9
+
+
+ + {t("nameThePolicyFirst")} + +
+ + + {t("policyName")} + + + +
+
+
+ +
+ +
+ + )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + setMagicVar("file")} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + { + if ( + policy.IsPrivate === "true" && + e.target.value === "true" + ) { + ToggleSnackbar( + "top", + "right", + t( + "cannotEnableForPrivateBucket" + ), + "warning" + ); + } + handleChange("IsOriginLinkEnable")( + e + ); + }} + row + > + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
+
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(3, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("chunkSizeLabelOd")} +
+ {t("chunkSizeDes")} +
+
+ +
+
+
+ +
+
+
+ {getNumber(4, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("createPlaceholderDes")} + +
+ + + + } + label={t("createPlaceholder")} + /> + + } + label={t("notCreatePlaceholder")} + /> + + +
+
+
+ +
+
+
+ {getNumber(5, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("limitOdTPSDes")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + tps_limit: "5.0", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + tps_limit: "0", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+ + +
+ + + {t("tps")} + + + + {t("tpsDes")} + + +
+
+ + + {t("tpsBurst")} + + + + {t("tpsBurstDes")} + + +
+
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 4 && ( +
+
+
+
+ + {props.policy + ? t("policySaved") + : t("policyAdded")} + + + {t("odOauthDes")} + +
+ +
+
+
+
+ + )} + + {activeStep === 5 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/QiniuGuide.js b/src/component/Admin/Policy/Guid/QiniuGuide.js new file mode 100644 index 0000000..a713c12 --- /dev/null +++ b/src/component/Admin/Policy/Guid/QiniuGuide.js @@ -0,0 +1,1051 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import DomainInput from "../../Common/DomainInput"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import { getNumber } from "../../../../utils"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, +})); + +const steps = [ + { + title: "storageBucket", + optional: false, + }, + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "finishStep", + optional: false, + }, +]; + +export default function RemoteGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [skipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "qiniu", + Name: "", + SecretKey: "", + AccessKey: "", + BaseURL: "", + IsPrivate: "true", + DirNameRule: "uploads/{year}/{month}/{day}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + mimetype: "", + chunk_size: 25 << 20, + placeholder_with_size: "false", + }, + } + ); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(5); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + return ( +
+ + {props.policy + ? t("editQiniuStoragePolicy") + : t("addQiniuStoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {t("optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + + {activeStep === 0 && ( +
{ + e.preventDefault(); + setActiveStep(1); + }} + > +
+
+
0
+
+
+ + ]} + /> + +
+
+ +
+
+
1
+
+
+ + , + ]} + /> + +
+
+ +
+
+
2
+
+
+ + {t("enterQiniuBucket")} + +
+ + + {t("qiniuBucketName")} + + + +
+
+
+ +
+
+
3
+
+
+ + {t("bucketTypeDes")} + +
+ + + + } + label={t("privateBucket")} + /> + + } + label={t("publicBucket")} + /> + + +
+
+
+ +
+
+
4
+
+
+ + {t("bucketCDNDes")} + +
+ +
+
+
+ +
+
+
5
+
+
+ + {t("qiniuCredentialDes")} + +
+ + + {t("ak")} + + + +
+
+ + + {t("sk")} + + + +
+
+
+ +
+ +
+
+ )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + setMagicVar("file")} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + { + if ( + policy.IsPrivate === "true" && + e.target.value === "true" + ) { + ToggleSnackbar( + "top", + "right", + t( + "cannotEnableForPrivateBucket" + ), + "warning" + ); + } + handleChange("IsOriginLinkEnable")( + e + ); + }} + row + > + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
{ + e.preventDefault(); + setActiveStep(4); + }} + > +
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(3, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("limitMimeType")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + mimetype: "image/*", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + mimetype: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {getNumber(4, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== + "", + ])} +
+
+
+ + {t("mimeTypeDes")} + +
+ + + {t("mimeTypeList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(4, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + policy.OptionsSerialized.mimetype !== "", + ])} +
+
+
+ + {t("chunkSizeLabelQiniu")} +
+ {t("chunkSizeDes")} +
+
+ +
+
+
+ +
+
+
+ {getNumber(5, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + policy.OptionsSerialized.mimetype !== "", + ])} +
+
+
+ + {t("createPlaceholderDes")} + +
+ + + + } + label={t("createPlaceholder")} + /> + + } + label={t("notCreatePlaceholder")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 4 && ( +
+
+
+
+ + {t("nameThePolicy")} + +
+ + + {t("policyName")} + + + +
+
+
+
+ {" "} + +
+ + )} + + {activeStep === 5 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/RemoteGuide.js b/src/component/Admin/Policy/Guid/RemoteGuide.js new file mode 100644 index 0000000..c41526e --- /dev/null +++ b/src/component/Admin/Policy/Guid/RemoteGuide.js @@ -0,0 +1,1027 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import DomainInput from "../../Common/DomainInput"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import Alert from "@material-ui/lab/Alert"; +import { getNumber, randomStr } from "../../../../utils"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontSize: "14px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + pre: { + margin: "24px 0", + padding: "12px 18px", + overflow: "auto", + direction: "ltr", + borderRadius: "4px", + backgroundColor: "#272c34", + color: "#fff", + }, + }, +})); + +const steps = [ + { + title: "storageNode", + optional: false, + }, + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "finishStep", + optional: false, + }, +]; + +export default function RemoteGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [skipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [useCDN, setUseCDN] = useState("false"); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "remote", + Name: "", + Server: "https://example.com:5212", + SecretKey: randomStr(64), + DirNameRule: "uploads/{year}/{month}/{day}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + BaseURL: "", + IsPrivate: "true", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + chunk_size: 25 << 20, + }, + } + ); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const testSlave = () => { + setLoading(true); + + // 测试路径是否可用 + API.post("/admin/policy/test/slave", { + server: policy.Server, + secret: policy.SecretKey, + }) + .then(() => { + ToggleSnackbar("top", "right", t("communicationOK"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + // 处理存储策略 + if (useCDN === "false" || policy.IsOriginLinkEnable === "false") { + policyCopy.BaseURL = ""; + } + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(5); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + return ( +
+ + {props.policy + ? t("editRemoteStoragePolicy") + : t("addRemoteStoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {t("optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + + {activeStep === 0 && ( +
{ + e.preventDefault(); + setActiveStep(1); + }} + > + + {t("remoteDescription")} + + +
+
+
1
+
+
+ + {t("remoteCopyBinaryDescription")} + +
+
+ +
+
+
2
+
+
+ + {t("remoteSecretDescription")} + +
+ + + {t("remoteSecret")} + + + +
+
+
+ +
+
+
3
+
+
+ + {t("modifyRemoteConfig")} +
+ ]} + /> +
+
+                                [System]
+                                
+ Mode = slave +
+ Listen = :5212 +
+
+ [Slave] +
+ Secret = {policy.SecretKey} +
+
+ [CORS] +
+ AllowOrigins = *
+ AllowMethods = OPTIONS,GET,POST +
+ AllowHeaders = *
+
+ + {t("remoteConfigDifference")} +
    +
  • + , + , + , + ]} + /> +
  • +
  • + , + , + ]} + /> +
  • +
  • + ]} + /> +
  • +
+
+
+
+ +
+
+
4
+
+
+ + {t("inputRemoteAddress")} +
+ {t("inputRemoteAddressDes")} +
+
+ + + {t("remoteAddress")} + + + +
+
+
+ +
+
+
5
+
+
+ + {t("testCommunicationDes")} + +
+ +
+
+
+ +
+ +
+ + )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + { + e.preventDefault(); + setMagicVar("file"); + }} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("useCDN")} +
+ {t("useCDNDes")} +
+ +
+ + { + if ( + e.target.value === "false" + ) { + setPolicy({ + ...policy, + BaseURL: "", + }); + } + setUseCDN(e.target.value); + }} + row + > + + } + label={t("use")} + /> + + } + label={t("notUse")} + /> + + +
+
+
+ + +
+
+
3
+
+
+ + {t("cdnDomain")} + + +
+ +
+
+
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
{ + e.preventDefault(); + setActiveStep(4); + }} + > +
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(3, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("chunkSizeLabel")} +
+ {t("chunkSizeDes")} +
+
+ +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 4 && ( +
+
+
+
+ + {t("nameThePolicy")} + +
+ + + {t("policyName")} + + + +
+
+
+
+ {" "} + +
+
+ )} + + {activeStep === 5 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/S3Guide.js b/src/component/Admin/Policy/Guid/S3Guide.js new file mode 100644 index 0000000..7a69568 --- /dev/null +++ b/src/component/Admin/Policy/Guid/S3Guide.js @@ -0,0 +1,1183 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import DomainInput from "../../Common/DomainInput"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import { getNumber } from "../../../../utils"; +import Autocomplete from "@material-ui/lab/Autocomplete"; +import TextField from "@material-ui/core/TextField"; +import AlertDialog from "../../Dialogs/Alert"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, + viewButtonLabel: { textTransform: "none" }, + "@global": { + code: { + color: "rgba(0, 0, 0, 0.87)", + display: "inline-block", + padding: "2px 6px", + fontFamily: + ' Consolas, "Liberation Mono", Menlo, Courier, monospace', + borderRadius: "2px", + backgroundColor: "rgba(255,229,100,0.1)", + }, + pre: { + margin: "24px 0", + padding: "12px 18px", + overflow: "auto", + direction: "ltr", + borderRadius: "4px", + backgroundColor: "#272c34", + color: "#fff", + }, + }, +})); + +const steps = [ + { + title: "storageBucket", + optional: false, + }, + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "corsSettingStep", + optional: true, + }, + { + title: "finishStep", + optional: false, + }, +]; + +const regions = { + "us-east-2": "US East (Ohio)", + "us-east-1": "US East (N. Virginia)", + "us-west-1": "US West (N. California)", + "us-west-2": "US West (Oregon)", + "af-south-1": "Africa (Cape Town)", + "ap-east-1": "Asia Pacific (Hong Kong)", + "ap-south-1": "Asia Pacific (Mumbai)", + "ap-northeast-3": "Asia Pacific (Osaka-Local)", + "ap-northeast-2": "Asia Pacific (Seoul)", + "ap-southeast-1": "Asia Pacific (Singapore)", + "ap-southeast-2": "Asia Pacific (Sydney)", + "ap-northeast-1": "Asia Pacific (Tokyo)", + "ca-central-1": "Canada (Central)", + "cn-north-1": "China (Beijing)", + "cn-northwest-1": "China (Ningxia)", + "eu-central-1": "Europe (Frankfurt)", + "eu-west-1": "Europe (Ireland)", + "eu-west-2": "Europe (London)", + "eu-south-1": "Europe (Milan)", + "eu-west-3": "Europe (Paris)", + "eu-north-1": "Europe (Stockholm)", + "me-south-1": "Middle East (Bahrain)", + "sa-east-1": "South America (São Paulo)", +}; + +export default function S3Guide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [alertOpen, setAlertOpen] = useState(true); + const [skipped, setSkipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [useCDN, setUseCDN] = useState("false"); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "s3", + Name: "", + SecretKey: "", + AccessKey: "", + BaseURL: "", + Server: "", + IsPrivate: "true", + DirNameRule: "uploads/{year}/{month}/{day}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + region: "us-east-2", + chunk_size: 25 << 20, + placeholder_with_size: "false", + s3_path_style: "true", + }, + } + ); + const [policyID, setPolicyID] = useState( + props.policy ? props.policy.ID : 0 + ); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + if (useCDN === "false") { + policyCopy.BaseURL = ""; + } + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then((response) => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(4); + setPolicyID(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + const createCORS = () => { + setLoading(true); + API.post("/admin/policy/cors", { + id: policyID, + }) + .then(() => { + ToggleSnackbar("top", "right", t("corsPolicyAdded"), "success"); + setActiveStep(5); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+ setAlertOpen(false)} + title={t("warning")} + msg={t("s3SelfHostWarning")} + /> + + {props.policy + ? t("editS3StoragePolicy") + : t("addS3StoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + + {t("optional")} + + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + + {activeStep === 0 && ( +
{ + e.preventDefault(); + setActiveStep(1); + }} + > +
+
+
1
+
+
+ + ]} + /> + +
+ + + {t("bucketName")} + + + +
+
+
+ +
+
+
2
+
+
+ + {t("bucketTypeDes")} + +
+ + + + } + label={t("publicAccessDisabled")} + /> + + } + label={t("publicAccessEnabled")} + /> + + +
+
+
+ +
+
+
3
+
+
+ + ]} + /> + +
+ + + {t("endpoint")} + + + +
+
+
+ +
+
+
4
+
+
+ + ]} + /> + +
+ + + + } + label={t("usePathEndpoint")} + /> + + } + label={t("useHostnameEndpoint")} + /> + + +
+
+
+ +
+
+
5
+
+
+ + {t("selectRegionDes")} + +
+ + + handleOptionChange("region")({ + target: { value: value }, + }) + } + renderOption={(option) => ( + + {regions[option]} + + )} + renderInput={(params) => ( + + )} + /> + +
+
+
+ +
+
+
6
+
+
+ + {t("useCDN")} + +
+ + { + setUseCDN(e.target.value); + }} + row + > + + } + label={t("use")} + /> + + } + label={t("notUse")} + /> + + +
+
+
+ + +
+
+
7
+
+
+ + {t("bucketCDNDomain")} + +
+ +
+
+
+
+ +
+
+
+ {getNumber(7, [useCDN === "true"])} +
+
+
+ + {t("enterAccessCredentials")} + +
+ + + {t("accessKey")} + + + +
+
+ + + {t("secretKey")} + + + +
+
+
+ +
+
+
+ {getNumber(8, [useCDN === "true"])} +
+
+
+ + {t("nameThePolicyFirst")} + +
+ + + {t("policyName")} + + + +
+
+
+ +
+ +
+
+ )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + setMagicVar("file")} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + { + if ( + policy.IsPrivate === "true" && + e.target.value === "true" + ) { + ToggleSnackbar( + "top", + "right", + t( + "cannotEnableForPrivateBucket" + ), + "warning" + ); + } + handleChange("IsOriginLinkEnable")( + e + ); + }} + row + > + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
+
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+
+
+ {getNumber(3, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("chunkSizeLabelS3")} +
+ {t("chunkSizeDes")} +
+
+ +
+
+
+ +
+
+
+ {getNumber(4, [ + policy.MaxSize !== "0", + policy.OptionsSerialized.file_type !== "", + ])} +
+
+
+ + {t("createPlaceholderDes")} + +
+ + + + } + label={t("createPlaceholder")} + /> + + } + label={t("notCreatePlaceholder")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 4 && ( +
+
+
+
+ + {t("ossCORSDes")} + +
+ +
+
+
+
+ {" "} +
+ + )} + + {activeStep === 5 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Guid/UpyunGuide.js b/src/component/Admin/Policy/Guid/UpyunGuide.js new file mode 100644 index 0000000..be64d9d --- /dev/null +++ b/src/component/Admin/Policy/Guid/UpyunGuide.js @@ -0,0 +1,903 @@ +import { lighten, makeStyles } from "@material-ui/core/styles"; +import React, { useCallback, useState } from "react"; +import Stepper from "@material-ui/core/Stepper"; +import StepLabel from "@material-ui/core/StepLabel"; +import Step from "@material-ui/core/Step"; +import Typography from "@material-ui/core/Typography"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Input from "@material-ui/core/Input"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import Collapse from "@material-ui/core/Collapse"; +import Button from "@material-ui/core/Button"; +import API from "../../../../middleware/Api"; +import MagicVar from "../../Dialogs/MagicVar"; +import DomainInput from "../../Common/DomainInput"; +import SizeInput from "../../Common/SizeInput"; +import { useHistory } from "react-router"; +import { toggleSnackbar } from "../../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { transformPolicyRequest } from "../utils"; + +const useStyles = makeStyles((theme) => ({ + stepContent: { + padding: "16px 32px 16px 32px", + }, + form: { + maxWidth: 400, + marginTop: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + subStepContainer: { + display: "flex", + marginBottom: 20, + padding: 10, + transition: theme.transitions.create("background-color", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + "&:focus-within": { + backgroundColor: theme.palette.background.default, + }, + }, + stepNumber: { + width: 20, + height: 20, + backgroundColor: lighten(theme.palette.secondary.light, 0.2), + color: theme.palette.secondary.contrastText, + textAlign: "center", + borderRadius: " 50%", + }, + stepNumberContainer: { + marginRight: 10, + }, + stepFooter: { + marginTop: 32, + }, + button: { + marginRight: theme.spacing(1), + }, +})); + +const steps = [ + { + title: "storageBucket", + optional: false, + }, + { + title: "storagePathStep", + optional: false, + }, + { + title: "sourceLinkStep", + optional: false, + }, + { + title: "uploadSettingStep", + optional: false, + }, + { + title: "finishStep", + optional: false, + }, +]; + +export default function UpyunGuide(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const history = useHistory(); + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [skipped] = React.useState(new Set()); + const [magicVar, setMagicVar] = useState(""); + const [policy, setPolicy] = useState( + props.policy + ? props.policy + : { + Type: "upyun", + Name: "", + SecretKey: "", + AccessKey: "", + BaseURL: "", + IsPrivate: "false", + DirNameRule: "uploads/{year}/{month}/{day}", + AutoRename: "true", + FileNameRule: "{randomkey8}_{originname}", + IsOriginLinkEnable: "false", + MaxSize: "0", + OptionsSerialized: { + file_type: "", + token: "", + }, + } + ); + + const handleChange = (name) => (event) => { + setPolicy({ + ...policy, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + [name]: event.target.value, + }, + }); + }; + + const isStepSkipped = (step) => { + return skipped.has(step); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitPolicy = (e) => { + e.preventDefault(); + setLoading(true); + + let policyCopy = { ...policy }; + policyCopy.OptionsSerialized = { ...policyCopy.OptionsSerialized }; + + // 类型转换 + policyCopy = transformPolicyRequest(policyCopy); + + API.post("/admin/policy", { + policy: policyCopy, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + props.policy ? t("policySaved") : t("policyAdded"), + "success" + ); + setActiveStep(5); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + + setLoading(false); + }; + + return ( +
+ + {props.policy + ? t("editUpyunStoragePolicy") + : t("addUpyunStoragePolicy")} + + + {steps.map((label, index) => { + const stepProps = {}; + const labelProps = {}; + if (label.optional) { + labelProps.optional = ( + 可选 + ); + } + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + + {t(label.title)} + + + ); + })} + + + {activeStep === 0 && ( +
{ + e.preventDefault(); + setActiveStep(1); + }} + > +
+
+
0
+
+
+ + ]} + /> + +
+
+ +
+
+
1
+
+
+ + , + ]} + /> + +
+
+ +
+
+
2
+
+
+ + {t("storageServiceNameDes")} + +
+ + + {t("storageServiceName")} + + + +
+
+
+ +
+
+
3
+
+
+ + {t("operatorNameDes")} + +
+ + + {t("operatorName")} + + + +
+
+ + + {t("operatorPassword")} + + + +
+
+
+ +
+
+
4
+
+
+ + {t("upyunCDNDes")} + +
+ +
+
+
+ +
+
+
5
+
+
+ + {t("upyunOptionalDes")} +
+ {t("upyunTokenDes")} +
+
+ + + + } + label={t("tokenEnabled")} + /> + + } + label={t("tokenDisabled")} + /> + + +
+
+
+ + +
+
+
6
+
+
+ + {t("upyunTokenSecretDes")} + +
+ + + {t("upyunTokenSecret")} + + + +
+
+
+
+ +
+ +
+
+ )} + + {activeStep === 1 && ( +
{ + e.preventDefault(); + setActiveStep(2); + }} + > +
+
+
1
+
+
+ + setMagicVar("path")} + />, + ]} + /> + +
+ + + {t("pathOfFolderToStoreFiles")} + + + +
+
+
+ +
+
+
2
+
+
+ + setMagicVar("file")} + />, + ]} + /> + +
+ + + + } + label={t("autoRenameStoredFile")} + /> + + } + label={t("keepOriginalFileName")} + /> + + +
+ + +
+ + + {t("renameRule")} + + + +
+
+
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
{ + e.preventDefault(); + setActiveStep(3); + }} + > +
+
+
1
+
+
+ + {t("enableGettingPermanentSourceLink")} +
+ {t("enableGettingPermanentSourceLinkDes")} +
+ +
+ + { + if ( + policy.IsPrivate === "true" && + e.target.value === "true" + ) { + ToggleSnackbar( + "top", + "right", + t( + "cannotEnableForTokenProtectedBucket" + ), + "warning" + ); + } + handleChange("IsOriginLinkEnable")( + e + ); + }} + row + > + + } + label={t("allowed")} + /> + + } + label={t("forbidden")} + /> + + +
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 3 && ( +
{ + e.preventDefault(); + setActiveStep(4); + }} + > +
+
+
1
+
+
+ + {t("limitFileSize")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + MaxSize: "10485760", + }); + } else { + setPolicy({ + ...policy, + MaxSize: "0", + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
2
+
+
+ + {t("enterSizeLimit")} + +
+ +
+
+
+
+ +
+
+
+ {policy.MaxSize !== "0" ? "3" : "2"} +
+
+
+ + {t("limitFileExt")} + + +
+ + { + if (e.target.value === "true") { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: + "jpg,png,mp4,zip,rar", + }, + }); + } else { + setPolicy({ + ...policy, + OptionsSerialized: { + ...policy.OptionsSerialized, + file_type: "", + }, + }); + } + }} + row + > + + } + label={t("limit")} + /> + + } + label={t("notLimit")} + /> + + +
+
+
+ + +
+
+
+ {policy.MaxSize !== "0" ? "4" : "3"} +
+
+
+ + {t("enterFileExt")} + +
+ + + {t("extList")} + + + +
+
+
+
+ +
+ {" "} + +
+
+ )} + + {activeStep === 4 && ( +
+
+
+
+ + {t("nameThePolicy")} + +
+ + + {t("policyName")} + + + +
+
+
+
+ {" "} + +
+
+ )} + + {activeStep === 5 && ( + <> +
+ + {props.policy ? t("policySaved") : t("policyAdded")} + + + {t("furtherActions")} + +
+
+ +
+ + )} + + setMagicVar("")} + /> + setMagicVar("")} + /> +
+ ); +} diff --git a/src/component/Admin/Policy/Policy.js b/src/component/Admin/Policy/Policy.js new file mode 100644 index 0000000..a5ae4b6 --- /dev/null +++ b/src/component/Admin/Policy/Policy.js @@ -0,0 +1,297 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import { sizeToString } from "../../../utils"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import AddPolicy from "../Dialogs/AddPolicy"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import { useHistory, useLocation } from "react-router"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete, Edit } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Menu from "@material-ui/core/Menu"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, +})); + +const columns = [ + { id: "#", label: "sharp", minWidth: 50 }, + { id: "name", label: "name", minWidth: 170 }, + { id: "type", label: "type", minWidth: 170 }, + { + id: "count", + label: "childFiles", + minWidth: 50, + align: "right", + }, + { + id: "size", + label: "totalSize", + minWidth: 100, + align: "right", + }, + { + id: "action", + label: "actions", + minWidth: 170, + align: "right", + }, +]; + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +export default function Policy() { + const { t } = useTranslation("dashboard", { keyPrefix: "policy" }); + const classes = useStyles(); + const [policies, setPolicies] = useState([]); + const [statics, setStatics] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [addDialog, setAddDialog] = useState(false); + const [filter, setFilter] = useState("all"); + const [anchorEl, setAnchorEl] = React.useState(null); + const [editID, setEditID] = React.useState(0); + + const location = useLocation(); + const history = useHistory(); + const query = useQuery(); + + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + if (query.get("code") === "0") { + ToggleSnackbar("top", "right", t("authSuccess"), "success"); + } else if (query.get("msg") && query.get("msg") !== "") { + ToggleSnackbar( + "top", + "right", + query.get("msg") + ", " + query.get("err"), + "warning" + ); + } + }, [location]); + + const loadList = () => { + API.post("/admin/policy/list", { + page: page, + page_size: pageSize, + order_by: "id desc", + conditions: filter === "all" ? {} : { type: filter }, + }) + .then((response) => { + setPolicies(response.data.items); + setStatics(response.data.statics); + setTotal(response.data.total); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, filter]); + + const deletePolicy = (id) => { + API.delete("/admin/policy/" + id) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("policyDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const open = Boolean(anchorEl); + + return ( +
+ setAddDialog(false)} /> +
+ +
+ + +
+
+ + + + + + + {columns.map((column) => ( + + {t(column.label)} + + ))} + + + + {policies.map((row) => ( + + {row.ID} + {row.Name} + {t(row.Type)} + + {statics[row.ID] !== undefined && + statics[row.ID][0].toLocaleString()} + + + {statics[row.ID] !== undefined && + sizeToString(statics[row.ID][1])} + + + + + deletePolicy(row.ID) + } + size={"small"} + > + + + + + { + setEditID(row.ID); + handleClick(e); + }} + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+ + { + handleClose(e); + history.push("/admin/policy/edit/pro/" + editID); + }} + > + {t("editInProMode")} + + { + handleClose(e); + history.push("/admin/policy/edit/guide/" + editID); + }} + > + {t("editInWizardMode")} + + +
+ ); +} diff --git a/src/component/Admin/Policy/utils.js b/src/component/Admin/Policy/utils.js new file mode 100644 index 0000000..23934a6 --- /dev/null +++ b/src/component/Admin/Policy/utils.js @@ -0,0 +1,76 @@ +const boolFields = ["IsOriginLinkEnable", "AutoRename", "IsPrivate"]; + +const numberFields = ["MaxSize"]; + +const boolFieldsInOptions = ["placeholder_with_size", "s3_path_style"]; + +const numberFieldsInOptions = ["chunk_size", "tps_limit", "tps_limit_burst"]; +const listJsonFieldsInOptions = ["file_type", "thumb_exts"]; + +export const transformResponse = (response) => { + boolFields.forEach( + (field) => + (response.data[field] = response.data[field] ? "true" : "false") + ); + numberFields.forEach( + (field) => (response.data[field] = response.data[field].toString()) + ); + boolFieldsInOptions.forEach( + (field) => + (response.data.OptionsSerialized[field] = response.data + .OptionsSerialized[field] + ? "true" + : "false") + ); + numberFieldsInOptions.forEach( + (field) => + (response.data.OptionsSerialized[field] = response.data + .OptionsSerialized[field] + ? response.data.OptionsSerialized[field].toString() + : 0) + ); + + listJsonFieldsInOptions.forEach((field) => { + response.data.OptionsSerialized[field] = response.data + .OptionsSerialized[field] + ? response.data.OptionsSerialized[field].join(",") + : ""; + }); + return response; +}; + +export const transformPolicyRequest = (policyCopy) => { + boolFields.forEach( + (field) => (policyCopy[field] = policyCopy[field] === "true") + ); + numberFields.forEach( + (field) => (policyCopy[field] = parseInt(policyCopy[field])) + ); + boolFieldsInOptions.forEach( + (field) => + (policyCopy.OptionsSerialized[field] = + policyCopy.OptionsSerialized[field] === "true") + ); + numberFieldsInOptions.forEach( + (field) => + (policyCopy.OptionsSerialized[field] = parseInt( + policyCopy.OptionsSerialized[field] + )) + ); + + listJsonFieldsInOptions.forEach((field) => { + policyCopy.OptionsSerialized[field] = policyCopy.OptionsSerialized[ + field + ] + ? policyCopy.OptionsSerialized[field].split(",") + : []; + if ( + policyCopy.OptionsSerialized[field].length === 1 && + policyCopy.OptionsSerialized[field][0] === "" + ) { + policyCopy.OptionsSerialized[field] = []; + } + }); + + return policyCopy; +}; diff --git a/src/component/Admin/Report/ReportList.js b/src/component/Admin/Report/ReportList.js new file mode 100644 index 0000000..e125102 --- /dev/null +++ b/src/component/Admin/Report/ReportList.js @@ -0,0 +1,422 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import IconButton from "@material-ui/core/IconButton"; +import Tooltip from "@material-ui/core/Tooltip"; +import Checkbox from "@material-ui/core/Checkbox"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { lighten } from "@material-ui/core"; +import Link from "@material-ui/core/Link"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import { reportReasons } from "../../../config"; +import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline"; +import { Delete } from "@material-ui/icons"; +import { formatLocalTime } from "../../../utils/datetime"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, + highlight: + theme.palette.type === "light" + ? { + color: theme.palette.secondary.main, + backgroundColor: lighten(theme.palette.secondary.light, 0.85), + } + : { + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondary.dark, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, +})); + +export default function ReportList() { + const { t } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tApp } = useTranslation("application"); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [reports, setReports] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [users, setUsers] = useState({}); + const [orderBy, setOrderBy] = useState(["id", "desc"]); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + const [ids, setIds] = useState({}); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/report/list", { + page: page, + page_size: pageSize, + order_by: orderBy.join(" "), + }) + .then((response) => { + setUsers(response.data.users); + setReports(response.data.items); + setTotal(response.data.total); + setIds(response.data.ids); + setSelected([]); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, orderBy]); + + const deleteReport = (id) => { + setLoading(true); + API.post("/admin/report/delete", { id: [id] }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("markSuccessful"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteShare = (id) => { + setLoading(true); + API.post("/admin/share/delete", { id: [id] }) + .then(() => { + loadList(); + ToggleSnackbar( + "top", + "right", + tDashboard("share.deleted"), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteBatch = () => { + setLoading(true); + API.post("/admin/report/delete", { id: selected }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("markSuccessful"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = reports.map((n) => n.ID); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleClick = (event, name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + return ( +
+
+
+ +
+
+ + + {selected.length > 0 && ( + + + {tDashboard("user.selectedObjects", { + num: selected.length, + })} + + + + + + + + )} + + + + + + 0 && + selected.length < reports.length + } + checked={ + reports.length > 0 && + selected.length === reports.length + } + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + + + setOrderBy([ + "id", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + # + {orderBy[0] === "id" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("reportedContent")} + + + {tDashboard("policy.type")} + + + {t("reason")} + + + {t("description")} + + + {tDashboard("vas.shareLink")} + + + {t("reportTime")} + + + {tDashboard("policy.actions")} + + + + + {reports.map((row) => ( + + + + handleClick(event, row.ID) + } + checked={isSelected(row.ID)} + /> + + {row.ID} + + {row.Share.ID === 0 && t("invalid")} + {row.Share.ID > 0 && ( + + + {tApp(reportReasons[row.Reason])} + + + {row.Description} + + + + {users[row.Share.UserID] + ? users[row.Share.UserID].Nick + : tDashboard( + "file.unknownUploader" + )} + + + + {formatLocalTime(row.CreatedAt)} + + + + + deleteReport(row.ID) + } + size={"small"} + > + + + + {row.Share.ID > 0 && ( + + + deleteShare( + row.Share.ID + ) + } + size={"small"} + > + + + + )} + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/Setting/Access.js b/src/component/Admin/Setting/Access.js new file mode 100644 index 0000000..44ee85c --- /dev/null +++ b/src/component/Admin/Setting/Access.js @@ -0,0 +1,502 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import Switch from "@material-ui/core/Switch"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import AlertDialog from "../Dialogs/Alert"; +import Alert from "@material-ui/lab/Alert"; +import FileSelector from "../Common/FileSelector"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, +})); + +export default function Access() { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const { t: tVas } = useTranslation("dashboard", { keyPrefix: "vas" }); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [initCompleted, setInitComplete] = useState(false); + const [options, setOptions] = useState({ + register_enabled: "1", + default_group: "1", + email_active: "0", + login_captcha: "0", + reg_captcha: "0", + forget_captcha: "0", + qq_login: "0", + qq_direct_login: "0", + qq_login_id: "", + qq_login_key: "", + authn_enabled: "0", + mail_domain_filter: "0", + mail_domain_filter_list: "", + initial_files: "[]", + }); + const [siteURL, setSiteURL] = useState(""); + const [groups, setGroups] = useState([]); + const [httpAlert, setHttpAlert] = useState(false); + + const handleChange = (name) => (event) => { + let value = event.target.value; + if (event.target.checked !== undefined) { + value = event.target.checked ? "1" : "0"; + } + setOptions({ + ...options, + [name]: value, + }); + }; + + const handleInputChange = (name) => (event) => { + const value = event.target.value; + setOptions({ + ...options, + [name]: value, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/setting", { + keys: [...Object.keys(options), "siteURL"], + }) + .then((response) => { + setSiteURL(response.data.siteURL); + delete response.data.siteURL; + setOptions(response.data); + setInitComplete(true); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + + API.get("/admin/groups") + .then((response) => { + setGroups(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", t("saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+ setHttpAlert(false)} + open={httpAlert} + /> +
+
+ + {t("accountManagement")} + +
+
+ + + } + label={t("allowNewRegistrations")} + /> + + {t("allowNewRegistrationsDes")} + + +
+ +
+ + + } + label={t("emailActivation")} + /> + + {t("emailActivationDes")} + + +
+ +
+ + + } + label={t("captchaForSignup")} + /> + + {t("captchaForSignupDes")} + + +
+ +
+ + + } + label={t("captchaForLogin")} + /> + + {t("captchaForLoginDes")} + + +
+ +
+ + + } + label={t("captchaForReset")} + /> + + {t("captchaForResetDes")} + + +
+ +
+ + { + if ( + !siteURL.startsWith( + "https://" + ) + ) { + setHttpAlert(true); + return; + } + handleChange("authn_enabled")( + e + ); + }} + /> + } + label={t("webauthn")} + /> + + {t("webauthnDes")} + + +
+ +
+ + + {t("defaultGroup")} + + + + {t("defaultGroupDes")} + + +
+ +
+ + {initCompleted && ( + + handleInputChange("initial_files")({ + target: { value: v }, + }) + } + /> + )} + + {tVas("initialFilesDes")} + + +
+ +
+ + + {tVas("filterEmailProvider")} + + + + {tVas("filterEmailProviderDes")} + + +
+ + {options.mail_domain_filter !== "0" && ( +
+ + + {tVas("filterEmailProviderRule")} + + + + {tVas("filterEmailProviderRuleDes")} + + +
+ )} +
+
+ +
+ + {tVas("qqConnect")} + +
+
+ + {tVas("qqConnectHint", { + url: siteURL.endsWith("/") + ? siteURL + "login/qq" + : siteURL + "/login/qq", + })} + +
+
+ + + } + label={tVas("enableQQConnect")} + /> + + {tVas("enableQQConnectDes")} + + +
+ + {options.qq_login === "1" && ( + <> +
+ + + } + label={tVas("loginWithoutBinding")} + /> + + {tVas("loginWithoutBindingDes")} + + +
+ +
+ + + {tVas("appid")} + + + + {tVas("appidDes")} + + +
+ +
+ + + {tVas("appKey")} + + + + {tVas("appKeyDes")} + + +
+ + )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Setting/Captcha.js b/src/component/Admin/Setting/Captcha.js new file mode 100644 index 0000000..0634fa2 --- /dev/null +++ b/src/component/Admin/Setting/Captcha.js @@ -0,0 +1,537 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import Input from "@material-ui/core/Input"; +import Link from "@material-ui/core/Link"; +import { toggleSnackbar } from "../../../redux/explorer"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, +})); + +export default function Captcha() { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState({ + captcha_type: "normal", + captcha_height: "1", + captcha_width: "1", + captcha_mode: "3", + captcha_CaptchaLen: "6", + captcha_ComplexOfNoiseText: "0", + captcha_ComplexOfNoiseDot: "0", + captcha_IsShowHollowLine: "0", + captcha_IsShowNoiseDot: "0", + captcha_IsShowNoiseText: "0", + captcha_IsShowSlimeLine: "0", + captcha_IsShowSineLine: "0", + captcha_ReCaptchaKey: "", + captcha_ReCaptchaSecret: "", + captcha_TCaptcha_CaptchaAppId: "", + captcha_TCaptcha_AppSecretKey: "", + captcha_TCaptcha_SecretId: "", + captcha_TCaptcha_SecretKey: "", + }); + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/setting", { + keys: Object.keys(options), + }) + .then((response) => { + setOptions(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", t("saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleCheckChange = (name) => (event) => { + const value = event.target.checked ? "1" : "0"; + setOptions({ + ...options, + [name]: value, + }); + }; + + return ( +
+
+
+ + {t("captcha")} + +
+
+ + + {t("captchaType")} + + + + {t("captchaProvider")} + + +
+
+
+ + {options.captcha_type === "normal" && ( +
+ + {t("plainCaptchaTitle")} + +
+
+ + + {t("captchaWidth")} + + + +
+ +
+ + + {t("captchaHeight")} + + + +
+ +
+ + + {t("captchaLength")} + + + +
+
+ + + {t("captchaMode")} + + + + {t("captchaElement")} + + +
+ {[ + { + name: "complexOfNoiseText", + field: "captcha_ComplexOfNoiseText", + }, + { + name: "complexOfNoiseDot", + field: "captcha_ComplexOfNoiseDot", + }, + { + name: "showHollowLine", + field: "captcha_IsShowHollowLine", + }, + { + name: "showNoiseDot", + field: "captcha_IsShowNoiseDot", + }, + { + name: "showNoiseText", + field: "captcha_IsShowNoiseText", + }, + { + name: "showSlimeLine", + field: "captcha_IsShowSlimeLine", + }, + { + name: "showSineLine", + field: "captcha_IsShowSineLine", + }, + ].map((input) => ( +
+ + + } + label={t(input.name)} + /> + +
+ ))} +
+
+ )} + + {options.captcha_type === "recaptcha" && ( +
+ + {t("reCaptchaV2")} + +
+
+
+ + + {t("siteKey")} + + + + , + ]} + /> + + +
+ +
+ + + {t("siteSecret")} + + + + , + ]} + /> + + +
+
+
+
+ )} + + {options.captcha_type === "tcaptcha" && ( +
+ + {t("tencentCloudCaptcha")} + +
+
+
+ + + {t("secretID")} + + + + , + ]} + /> + + +
+ +
+ + + {t("secretKey")} + + + + , + ]} + /> + + +
+ +
+ + + {t("tCaptchaAppID")} + + + + , + ]} + /> + + +
+ +
+ + + {t("tCaptchaSecretKey")} + + + + , + ]} + /> + + +
+
+
+
+ )} + +
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Setting/Image.js b/src/component/Admin/Setting/Image.js new file mode 100644 index 0000000..daea4bf --- /dev/null +++ b/src/component/Admin/Setting/Image.js @@ -0,0 +1,664 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import SizeInput from "../Common/SizeInput"; +import { toggleSnackbar } from "../../../redux/explorer"; +import Alert from "@material-ui/lab/Alert"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; +import { Trans, useTranslation } from "react-i18next"; +import Link from "@material-ui/core/Link"; +import ThumbGenerators from "./ThumbGenerators"; +import PolicySelector from "../Common/PolicySelector"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, +})); + +export default function ImageSetting() { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState({ + gravatar_server: "", + avatar_path: "", + avatar_size: "", + avatar_size_l: "", + avatar_size_m: "", + avatar_size_s: "", + thumb_width: "", + thumb_height: "", + office_preview_service: "", + thumb_file_suffix: "", + thumb_max_task_count: "", + thumb_encode_method: "", + thumb_gc_after_gen: "0", + thumb_encode_quality: "", + maxEditSize: "", + wopi_enabled: "0", + wopi_endpoint: "", + wopi_session_timeout: "0", + thumb_builtin_enabled: "0", + thumb_vips_enabled: "0", + thumb_vips_exts: "", + thumb_ffmpeg_enabled: "0", + thumb_vips_path: "", + thumb_ffmpeg_path: "", + thumb_ffmpeg_exts: "", + thumb_ffmpeg_seek: "", + thumb_libreoffice_path: "", + thumb_libreoffice_enabled: "0", + thumb_libreoffice_exts: "", + thumb_proxy_enabled: "0", + thumb_proxy_policy: [], + thumb_max_src_size: "", + }); + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/setting", { + keys: Object.keys(options), + }) + .then((response) => { + response.data.thumb_proxy_policy = JSON.parse( + response.data.thumb_proxy_policy + ).map((v) => { + return v.toString(); + }); + setOptions(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const reload = () => { + API.get("/admin/reload/wopi") + // eslint-disable-next-line @typescript-eslint/no-empty-function + .then(() => {}) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .then(() => {}); + }; + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + const option = []; + Object.keys(options).forEach((k) => { + let value = options[k]; + if (k === "thumb_proxy_policy") { + value = JSON.stringify(value.map((v) => parseInt(v))); + } + + option.push({ + key: k, + value, + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", t("saved"), "success"); + reload(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleCheckChange = (name) => (event) => { + const value = event.target.checked ? "1" : "0"; + setOptions({ + ...options, + [name]: value, + }); + }; + + return ( +
+
+
+ + {t("avatar")} + +
+
+ + + {t("gravatarServer")} + + + + {t("gravatarServerDes")} + + +
+ +
+ + + {t("avatarFilePath")} + + + + {t("avatarFilePathDes")} + + +
+ +
+ + {options.avatar_size !== "" && ( + + )} + + {t("avatarSizeDes")} + + +
+ +
+ + + {t("smallAvatarSize")} + + + +
+ +
+ + + {t("mediumAvatarSize")} + + + +
+ +
+ + + {t("largeAvatarSize")} + + + +
+
+
+ +
+ + {t("filePreview")} + + +
+
+ + + {t("officePreviewService")} + + + + {t("officePreviewServiceDes")} +
+ {"{$src}"} -{" "} + {t("officePreviewServiceSrcDes")} +
+ {"{$srcB64}"} -{" "} + {t("officePreviewServiceSrcB64Des")} +
+ {"{$name}"} -{" "} + {t("officePreviewServiceName")} +
+
+
+ +
+ + {options.maxEditSize !== "" && ( + + )} + + + {t("textEditMaxSizeDes")} + + +
+
+
+ +
+ + {t("wopiClient")} + + +
+
+ + , + ]} + /> + +
+ +
+ + + } + label={t("enableWopi")} + /> + +
+ + {options.wopi_enabled === "1" && ( + <> +
+ + + {t("wopiEndpoint")} + + + + {t("wopiEndpointDes")} + + +
+ +
+ + + {t("wopiSessionTtl")} + + + + {t("wopiSessionTtlDes")} + + +
+ + )} +
+
+ +
+ + {t("thumbnails")} + +
+ + , + ]} + /> + +
+ + {t("thumbnailBasic")} + + +
+
+ + + {t("thumbWidth")} + + + +
+ +
+ + + {t("thumbHeight")} + + + +
+ +
+ + + {t("thumbSuffix")} + + + +
+ +
+ + + {t("thumbConcurrent")} + + + + {t("thumbConcurrentDes")} + + +
+ +
+ + + {t("thumbFormat")} + + + + {t("thumbFormatDes")} + + +
+ +
+ + + {t("thumbQuality")} + + + + {t("thumbQualityDes")} + + +
+ +
+ + {options.thumb_max_src_size !== "" && ( + + )} + + {t("thumbMaxSizeDes")} + + +
+ +
+ + + } + label={t("thumbGC")} + /> + +
+
+ + + {t("generators")} + +
+
+ +
+
+ + + {t("generatorProxy")} + +
+
+ + {t("generatorProxyWarning")} + +
+ +
+ + + } + label={t("enableThumbProxy")} + /> + +
+ {options.thumb_proxy_enabled === "1" && ( + <> +
+ t.Type !== "local"} + label={t("proxyPolicyList")} + helperText={t("proxyPolicyListDes")} + /> +
+ + )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Setting/Mail.js b/src/component/Admin/Setting/Mail.js new file mode 100644 index 0000000..4b1159c --- /dev/null +++ b/src/component/Admin/Setting/Mail.js @@ -0,0 +1,450 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; +import { useDispatch } from "react-redux"; +import Dialog from "@material-ui/core/Dialog"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import TextField from "@material-ui/core/TextField"; +import DialogActions from "@material-ui/core/DialogActions"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + buttonMargin: { + marginLeft: 8, + }, +})); + +export default function Mail() { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const { t: tVas } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tGlobal } = useTranslation("common"); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [test, setTest] = useState(false); + const [tesInput, setTestInput] = useState(""); + const [options, setOptions] = useState({ + fromName: "", + fromAdress: "", + smtpHost: "", + smtpPort: "", + replyTo: "", + smtpUser: "", + smtpPass: "", + smtpEncryption: "", + mail_keepalive: "30", + over_used_template: "", + mail_activation_template: "", + mail_reset_pwd_template: "", + }); + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const handleCheckChange = (name) => (event) => { + let value = event.target.value; + if (event.target.checked !== undefined) { + value = event.target.checked ? "1" : "0"; + } + setOptions({ + ...options, + [name]: value, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/setting", { + keys: Object.keys(options), + }) + .then((response) => { + setOptions(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const sendTestMail = () => { + setLoading(true); + API.post("/admin/test/mail", { + to: tesInput, + }) + .then(() => { + ToggleSnackbar("top", "right", t("testMailSent"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const reload = () => { + API.get("/admin/reload/email") + // eslint-disable-next-line @typescript-eslint/no-empty-function + .then(() => {}) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .then(() => {}); + }; + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", t("saved"), "success"); + reload(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+ setTest(false)} + aria-labelledby="form-dialog-title" + > + + {t("testSMTPSettings")} + + + + {t("testSMTPTooltip")} + + setTestInput(e.target.value)} + type="email" + fullWidth + /> + + + + + + + +
+
+ + {t("smtp")} + + +
+
+ + + {t("senderName")} + + + + {t("senderNameDes")} + + +
+ +
+ + + {t("senderAddress")} + + + + {t("senderAddressDes")} + + +
+ +
+ + + {t("smtpServer")} + + + + {t("smtpServerDes")} + + +
+ +
+ + + {t("smtpPort")} + + + + {t("smtpPortDes")} + + +
+ +
+ + + {t("smtpUsername")} + + + + {t("smtpUsernameDes")} + + +
+ +
+ + + {t("smtpPassword")} + + + + {t("smtpPasswordDes")} + + +
+ +
+ + + {t("replyToAddress")} + + + + {t("replyToAddressDes")} + + +
+ +
+ + + } + label={t("enforceSSL")} + /> + + {t("enforceSSLDes")} + + +
+ +
+ + + {t("smtpTTL")} + + + + {t("smtpTTLDes")} + + +
+
+
+ +
+ + {t("emailTemplates")} + + +
+
+ + + {t("activateNewUser")} + + + + {t("activateNewUserDes")} + + +
+ +
+ + + {tVas("overuseReminder")} + + + + {tVas("overuseReminderDes")} + + +
+ +
+ + + {t("resetPassword")} + + + + {t("resetPasswordDes")} + + +
+
+
+ +
+ + {" "} + +
+
+
+ ); +} diff --git a/src/component/Admin/Setting/SiteInformation.js b/src/component/Admin/Setting/SiteInformation.js new file mode 100644 index 0000000..dd56d50 --- /dev/null +++ b/src/component/Admin/Setting/SiteInformation.js @@ -0,0 +1,517 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import { green } from "@material-ui/core/colors"; +import { Cancel, CheckCircle, Sync } from "@material-ui/icons"; +import IconButton from "@material-ui/core/IconButton"; +import { Tooltip } from "@material-ui/core"; +import Alert from "@material-ui/lab/Alert"; +import Link from "@material-ui/core/Link"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, +})); + +export default function SiteInformation() { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const { t: tVas } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tGlobal } = useTranslation("dashboard"); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState({ + siteURL: "", + siteName: "", + siteTitle: "", + siteKeywords: "", + siteDes: "", + siteScript: "", + siteNotice: "", + pwa_small_icon: "", + pwa_medium_icon: "", + pwa_large_icon: "", + pwa_display: "", + pwa_theme_color: "", + pwa_background_color: "", + vol_content: "", + show_app_promotion: "0", + app_feedback_link: "", + app_forum_link: "", + }); + + const vol = useMemo(() => { + if (options.vol_content) { + const volJson = atob(options.vol_content); + return JSON.parse(volJson); + } + }, [options]); + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const handleOptionChange = (name) => (event) => { + let value = event.target.value; + if (event.target.checked !== undefined) { + value = event.target.checked ? "1" : "0"; + } + setOptions({ + ...options, + [name]: value, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const refresh = () => + API.post("/admin/setting", { + keys: Object.keys(options), + }) + .then((response) => { + setOptions(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + + useEffect(() => { + refresh(); + // eslint-disable-next-line + }, []); + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", t("saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const syncVol = () => { + setLoading(true); + API.get("/admin/vol/sync") + .then(() => { + refresh(); + ToggleSnackbar("top", "right", tVas("volSynced"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+
+
+ + {t("basicInformation")} + +
+
+ + + {t("mainTitle")} + + + + {t("mainTitleDes")} + + +
+
+ + + {t("subTitle")} + + + + {t("subTitleDes")} + + +
+
+ + + {t("siteKeywords")} + + + + {t("siteKeywordsDes")} + + +
+
+ + + {t("siteDescription")} + + + + {t("siteDescriptionDes")} + + +
+
+ + + {t("siteURL")} + + + + {t("siteURLDes")} + + +
+
+ + + {t("customFooterHTML")} + + + + {t("customFooterHTMLDes")} + + +
+
+ + + {t("announcement")} + + + + {t("announcementDes")} + + +
+
+
+ +
+ + {tVas("mobileApp")} + +
+
+ + + , + , + ]} + /> + + +
+
+ + + {tVas("iosVol")} + + + {vol ? ( + + ) : ( + + )} + + } + endAdornment={ + + + syncVol()} + aria-label="toggle password visibility" + > + + + + + } + readOnly + value={ + vol ? vol.domain : tGlobal("share.none") + } + /> + +
+
+ + + } + label={tVas("showAppPromotion")} + /> + + {tVas("showAppPromotionDes")} + + +
+
+ + + {tVas("appFeedback")} + + + + {tVas("appLinkDes")} + + +
+
+ + + {tVas("appForum")} + + + + {tVas("appLinkDes")} + + +
+
+
+ +
+ + {t("pwa")} + +
+
+ + + {t("smallIcon")} + + + + {t("smallIconDes")} + + +
+
+ + + {t("mediumIcon")} + + + + {t("mediumIconDes")} + + +
+
+ + + {t("largeIcon")} + + + + {t("largeIconDes")} + + +
+
+ + + {t("displayMode")} + + + + {t("displayModeDes")} + + +
+
+ + + {t("themeColor")} + + + + {t("themeColorDes")} + + +
+
+
+
+ + + {t("backgroundColor")} + + + + {t("backgroundColorDes")} + + +
+
+
+
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Setting/Theme.js b/src/component/Admin/Setting/Theme.js new file mode 100644 index 0000000..4372dfe --- /dev/null +++ b/src/component/Admin/Setting/Theme.js @@ -0,0 +1,465 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import TableHead from "@material-ui/core/TableHead"; +import Table from "@material-ui/core/Table"; +import TableCell from "@material-ui/core/TableCell"; +import TableRow from "@material-ui/core/TableRow"; +import TableBody from "@material-ui/core/TableBody"; +import { Delete } from "@material-ui/icons"; +import IconButton from "@material-ui/core/IconButton"; +import TextField from "@material-ui/core/TextField"; +import CreateTheme from "../Dialogs/CreateTheme"; +import Alert from "@material-ui/lab/Alert"; +import Link from "@material-ui/core/Link"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 500, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + colorContainer: { + display: "flex", + }, + colorDot: { + width: 20, + height: 20, + borderRadius: "50%", + marginLeft: 6, + }, +})); + +export default function Theme() { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const { t: tApp } = useTranslation(); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [theme, setTheme] = useState({}); + const [options, setOptions] = useState({ + themes: "{}", + defaultTheme: "", + home_view_method: "icon", + share_view_method: "list", + }); + const [themeConfig, setThemeConfig] = useState({}); + const [themeConfigError, setThemeConfigError] = useState({}); + const [create, setCreate] = useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const deleteTheme = (color) => { + if (color === options.defaultTheme) { + ToggleSnackbar( + "top", + "right", + t("cannotDeleteDefaultTheme"), + "warning" + ); + return; + } + if (Object.keys(theme).length <= 1) { + ToggleSnackbar("top", "right", t("keepAtLeastOneTheme"), "warning"); + return; + } + const themeCopy = { ...theme }; + delete themeCopy[color]; + const resStr = JSON.stringify(themeCopy); + setOptions({ + ...options, + themes: resStr, + }); + }; + + const addTheme = (newTheme) => { + setCreate(false); + if (theme[newTheme.palette.primary.main] !== undefined) { + ToggleSnackbar( + "top", + "right", + t("duplicatedThemePrimaryColor"), + "warning" + ); + return; + } + const res = { + ...theme, + [newTheme.palette.primary.main]: newTheme, + }; + const resStr = JSON.stringify(res); + setOptions({ + ...options, + themes: resStr, + }); + }; + + useEffect(() => { + const res = JSON.parse(options.themes); + const themeString = {}; + + Object.keys(res).map((k) => { + themeString[k] = JSON.stringify(res[k]); + }); + + setTheme(res); + setThemeConfig(themeString); + }, [options.themes]); + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + useEffect(() => { + API.post("/admin/setting", { + keys: Object.keys(options), + }) + .then((response) => { + setOptions(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", t("saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+
+
+ + {t("themes")} + +
+
+ + + + {t("colors")} + + {t("themeConfig")} + + {t("actions")} + + + + {Object.keys(theme).map((k) => ( + + +
+
+
+
+ + + { + setThemeConfig({ + ...themeConfig, + [k]: e.target.value, + }); + }} + onBlur={(e) => { + try { + const res = JSON.parse( + e.target.value + ); + if ( + !( + "palette" in + res + ) || + !( + "primary" in + res.palette + ) || + !( + "main" in + res.palette + .primary + ) || + !( + "secondary" in + res.palette + ) || + !( + "main" in + res.palette + .secondary + ) + ) { + throw "error"; + } + setTheme({ + ...theme, + [k]: res, + }); + } catch (e) { + setThemeConfigError( + { + ...themeConfigError, + [k]: true, + } + ); + return; + } + setThemeConfigError({ + ...themeConfigError, + [k]: false, + }); + }} + value={themeConfig[k]} + /> + + + + deleteTheme(k) + } + > + + + + + ))} + +
+
+ +
+ + + , + ]} + /> + + +
+ +
+ + + {t("defaultTheme")} + + + + {t("defaultThemeDes")} + + +
+
+
+ +
+ + {t("appearance")} + + +
+
+ + + {t("personalFileListView")} + + + + {t("personalFileListViewDes")} + + +
+
+ +
+
+ + + {t("sharedFileListView")} + + + + {t("sharedFileListViewDes")} + + +
+
+
+ +
+ +
+
+ + setCreate(false)} + /> +
+ ); +} diff --git a/src/component/Admin/Setting/ThumbGenerators.js b/src/component/Admin/Setting/ThumbGenerators.js new file mode 100644 index 0000000..d0a72a0 --- /dev/null +++ b/src/component/Admin/Setting/ThumbGenerators.js @@ -0,0 +1,248 @@ +import React, { useCallback, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Accordion from "@material-ui/core/Accordion"; +import AccordionSummary from "@material-ui/core/AccordionSummary"; +import AccordionDetails from "@material-ui/core/AccordionDetails"; +import Checkbox from "@material-ui/core/Checkbox"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Typography from "@material-ui/core/Typography"; +import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; +import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../../redux/explorer"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import FormControl from "@material-ui/core/FormControl"; +import { Button, TextField } from "@material-ui/core"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import API from "../../../middleware/Api"; + +const useStyles = makeStyles((theme) => ({ + root: { + width: "100%", + }, + secondaryHeading: { + fontSize: theme.typography.pxToRem(15), + color: theme.palette.text.secondary, + }, + column: { + flexBasis: "33.33%", + }, + details: { + display: "block", + }, +})); + +const generators = [ + { + name: "policyBuiltin", + des: "policyBuiltinDes", + readOnly: true, + }, + { + name: "libreOffice", + des: "libreOfficeDes", + enableFlag: "thumb_libreoffice_enabled", + executableSetting: "thumb_libreoffice_path", + inputs: [ + { + name: "thumb_libreoffice_exts", + label: "generatorExts", + des: "generatorExtsDes", + }, + ], + }, + { + name: "vips", + des: "vipsDes", + enableFlag: "thumb_vips_enabled", + executableSetting: "thumb_vips_path", + inputs: [ + { + name: "thumb_vips_exts", + label: "generatorExts", + des: "generatorExtsDes", + }, + ], + }, + { + name: "ffmpeg", + des: "ffmpegDes", + enableFlag: "thumb_ffmpeg_enabled", + executableSetting: "thumb_ffmpeg_path", + inputs: [ + { + name: "thumb_ffmpeg_exts", + label: "generatorExts", + des: "generatorExtsDes", + }, + { + name: "thumb_ffmpeg_seek", + label: "ffmpegSeek", + des: "ffmpegSeekDes", + required: true, + }, + ], + }, + { + name: "cloudreveBuiltin", + des: "cloudreveBuiltinDes", + enableFlag: "thumb_builtin_enabled", + }, +]; + +export default function ThumbGenerators({ options, setOptions }) { + const classes = useStyles(); + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const [loading, setLoading] = useState(false); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const testExecutable = (name, executable) => { + setLoading(true); + API.post("/admin/test/thumb", { + name, + executable, + }) + .then((response) => { + ToggleSnackbar( + "top", + "right", + t("executableTestSuccess", { version: response.data }), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleEnableChange = (name) => (event) => { + const newOpts = { + ...options, + [name]: event.target.checked ? "1" : "0", + }; + setOptions(newOpts); + + if ( + newOpts["thumb_libreoffice_enabled"] === "1" && + newOpts["thumb_builtin_enabled"] === "0" && + newOpts["thumb_vips_enabled"] === "0" + ) { + ToggleSnackbar( + "top", + "center", + t("thumbDependencyWarning"), + "warning" + ); + } + }; + + return ( +
+ {generators.map((generator) => ( + + } + aria-label="Expand" + aria-controls="additional-actions1-content" + id="additional-actions1-header" + > + event.stopPropagation()} + onFocus={(event) => event.stopPropagation()} + control={ + + } + label={t(generator.name)} + disabled={generator.readOnly} + /> + + + + {t(generator.des)} + + {generator.executableSetting && ( + + + + + ), + }} + required + /> + + {t("executableDes")} + + + )} + {generator.inputs && + generator.inputs.map((input) => ( + + + + {t(input.des)} + + + ))} + + + ))} +
+ ); +} diff --git a/src/component/Admin/Setting/UploadDownload.js b/src/component/Admin/Setting/UploadDownload.js new file mode 100644 index 0000000..c8d5af9 --- /dev/null +++ b/src/component/Admin/Setting/UploadDownload.js @@ -0,0 +1,407 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import SizeInput from "../Common/SizeInput"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, +})); + +export default function UploadDownload() { + const { t } = useTranslation("dashboard", { keyPrefix: "settings" }); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState({ + max_worker_num: "1", + max_parallel_transfer: "1", + temp_path: "", + chunk_retries: "0", + archive_timeout: "0", + download_timeout: "0", + preview_timeout: "0", + doc_preview_timeout: "0", + upload_credential_timeout: "0", + upload_session_timeout: "0", + slave_api_timeout: "0", + onedrive_monitor_timeout: "0", + share_download_session_timeout: "0", + onedrive_callback_check: "0", + reset_after_upload_failed: "0", + onedrive_source_timeout: "0", + slave_node_retry: "0", + slave_ping_interval: "0", + slave_recover_interval: "0", + slave_transfer_timeout: "0", + use_temp_chunk_buffer: "1", + public_resource_maxage: "0", + }); + + const handleCheckChange = (name) => (event) => { + const value = event.target.checked ? "1" : "0"; + setOptions({ + ...options, + [name]: value, + }); + }; + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.post("/admin/setting", { + keys: Object.keys(options), + }) + .then((response) => { + setOptions(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", t("saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + return ( +
+
+
+ + {t("transportation")} + +
+
+ + + {t("workerNum")} + + + + {t("workerNumDes")} + + +
+ +
+ + + {t("transitParallelNum")} + + + + {t("transitParallelNumDes")} + + +
+ +
+ + + {t("tempFolder")} + + + + {t("tempFolderDes")} + + +
+ +
+ + + {t("failedChunkRetry")} + + + + {t("failedChunkRetryDes")} + + +
+ +
+ + + } + label={t("cacheChunks")} + /> + + {t("cacheChunksDes")} + + +
+ +
+ + + } + label={t("resetConnection")} + /> + + {t("resetConnectionDes")} + + +
+
+
+ +
+ + {t("expirationDuration")} + +
+ {[ + { + name: "batchDownload", + field: "archive_timeout", + }, + { + name: "downloadSession", + field: "download_timeout", + }, + { + name: "previewURL", + field: "preview_timeout", + }, + { + name: "docPreviewURL", + field: "doc_preview_timeout", + }, + { + name: "staticResourceCache", + field: "public_resource_maxage", + des: "staticResourceCacheDes", + }, + { + name: "uploadSession", + field: "upload_session_timeout", + des: "uploadSessionDes", + }, + { + name: "downloadSessionForShared", + field: "share_download_session_timeout", + des: "downloadSessionForSharedDes", + }, + { + name: "onedriveMonitorInterval", + field: "onedrive_monitor_timeout", + des: "onedriveMonitorIntervalDes", + }, + { + name: "onedriveCallbackTolerance", + field: "onedrive_callback_check", + des: "onedriveCallbackToleranceDes", + }, + { + name: "onedriveDownloadURLCache", + field: "onedrive_source_timeout", + des: "onedriveDownloadURLCacheDes", + }, + ].map((input) => ( +
+ + + {t(input.name)} + + + {input.des && ( + + {t(input.des)} + + )} + +
+ ))} +
+
+ +
+ + {t("nodesCommunication")} + +
+ {[ + { + name: "slaveAPIExpiration", + field: "slave_api_timeout", + des: "slaveAPIExpirationDes", + }, + { + name: "heartbeatInterval", + field: "slave_ping_interval", + des: "heartbeatIntervalDes", + }, + { + name: "heartbeatFailThreshold", + field: "slave_node_retry", + des: "heartbeatFailThresholdDes", + }, + { + name: "heartbeatRecoverModeInterval", + field: "slave_recover_interval", + des: "heartbeatRecoverModeIntervalDes", + }, + { + name: "slaveTransitExpiration", + field: "slave_transfer_timeout", + des: "slaveTransitExpirationDes", + }, + ].map((input) => ( +
+ + + {t(input.name)} + + + + {t(input.des)} + + +
+ ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/component/Admin/Setting/VAS.js b/src/component/Admin/Setting/VAS.js new file mode 100644 index 0000000..15f78ff --- /dev/null +++ b/src/component/Admin/Setting/VAS.js @@ -0,0 +1,1222 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Tab from "@material-ui/core/Tab"; +import Paper from "@material-ui/core/Paper"; +import Tabs from "@material-ui/core/Tabs"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; +import Link from "@material-ui/core/Link"; +import Alert from "@material-ui/lab/Alert"; +import AddPack from "../Dialogs/AddPack"; +import TableHead from "@material-ui/core/TableHead"; +import Table from "@material-ui/core/Table"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete, Edit } from "@material-ui/icons"; +import AddGroup from "../Dialogs/AddGroupk"; +import AddRedeem from "../Dialogs/AddRedeem"; +import AlertDialog from "../Dialogs/Alert"; +import Box from "@material-ui/core/Box"; +import Pagination from "@material-ui/lab/Pagination"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { sizeToString } from "../../../utils"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, + tabForm: { + marginTop: 20, + }, + content: { + padding: theme.spacing(2), + }, + tableContainer: { + overflowX: "auto", + marginTop: theme.spacing(2), + }, + navigator: { + marginTop: 10, + }, +})); + +const product = {}; + +export default function VAS() { + const { t: tSetting } = useTranslation("dashboard", { + keyPrefix: "settings", + }); + const { t } = useTranslation("dashboard", { keyPrefix: "vas" }); + const { t: tApp } = useTranslation(); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [tab, setTab] = useState(0); + const [options, setOptions] = useState({ + alipay_enabled: "0", + payjs_enabled: "0", + wechat_enabled: "0", + custom_payment_enabled: "0", + payjs_id: "", + appid: "", + appkey: "", + shopid: "", + payjs_secret: "", + wechat_appid: "", + wechat_mchid: "", + wechat_serial_no: "", + wechat_api_key: "", + wechat_pk_content: "", + score_enabled: "0", + share_score_rate: "0", + score_price: "0", + ban_time: "0", + group_sell_data: "[]", + pack_data: "[]", + report_enabled: "0", + custom_payment_endpoint: "", + custom_payment_secret: "", + custom_payment_name: "", + }); + const [groups, setGroups] = useState([]); + const [packs, setPacks] = useState([]); + const [addPack, setAddPack] = useState(false); + const [addGroup, setAddGroup] = useState(false); + const [packEdit, setPackEdit] = useState(null); + const [groupEdit, setGroupEdit] = useState(null); + const [addRedeem, setAddRedeem] = useState(false); + const [redeems, setRedeems] = useState([]); + const [redeemsRes, setRedeemsRes] = useState([]); + const [redeemsResOpen, setRedeemsResOpen] = useState(false); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + const [total, setTotal] = useState(0); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadRedeemList = () => { + API.post("/admin/redeem/list", { + page: page, + page_size: pageSize, + order_by: "id desc", + }) + .then((response) => { + setRedeems(response.data.items); + setTotal(response.data.total); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + const res = JSON.parse(options.group_sell_data); + res.forEach((k) => { + product[k.id] = k.name; + }); + setGroups(res); + }, [options.group_sell_data]); + + useEffect(() => { + const res = JSON.parse(options.pack_data); + res.forEach((k) => { + product[k.id] = k.name; + }); + setPacks(res); + }, [options.pack_data]); + + useEffect(() => { + if (tab === 3) { + loadRedeemList(); + } + }, [tab, page, pageSize]); + + const deleteRedeem = (id) => { + API.delete("/admin/redeem/" + id) + .then(() => { + loadRedeemList(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const redeemGenerated = (codes) => { + setRedeemsRes(codes); + setRedeemsResOpen(true); + loadRedeemList(); + }; + + const handleChange = (name) => (event) => { + setOptions({ + ...options, + [name]: event.target.value, + }); + }; + + const handleCheckChange = (name) => (event) => { + let value = event.target.value; + if (event.target.checked !== undefined) { + value = event.target.checked ? "1" : "0"; + } + setOptions({ + ...options, + [name]: value, + }); + }; + + useEffect(() => { + API.post("/admin/setting", { + keys: Object.keys(options), + }) + .then((response) => { + setOptions(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + const updateOption = () => { + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", tSetting("saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + updateOption(); + }; + + const updatePackOption = (name, pack) => { + const option = []; + Object.keys(options).forEach((k) => { + option.push({ + key: k, + value: k === name ? pack : options[k], + }); + }); + API.patch("/admin/setting", { + options: option, + }) + .then(() => { + ToggleSnackbar("top", "right", tSetting("saved"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleAddPack = (pack, isEdit) => { + setAddPack(false); + setPackEdit(null); + let newPacks; + if (isEdit) { + newPacks = packs.map((v) => { + if (v.id === pack.id) { + return pack; + } + return v; + }); + } else { + newPacks = [...packs, pack]; + } + + setPacks(newPacks); + const newPackData = JSON.stringify(newPacks); + setOptions({ ...options, pack_data: newPackData }); + updatePackOption("pack_data", newPackData); + }; + + const handleAddGroup = (group, isEdit) => { + setAddGroup(false); + setGroupEdit(null); + let newGroup; + if (isEdit) { + newGroup = groups.map((v) => { + if (v.id === group.id) { + return group; + } + return v; + }); + } else { + newGroup = [...groups, group]; + } + + setGroups(newGroup); + const newGroupData = JSON.stringify(newGroup); + setOptions({ ...options, group_sell_data: newGroupData }); + updatePackOption("group_sell_data", newGroupData); + }; + + const deletePack = (id) => { + let newPacks = [...packs]; + newPacks = newPacks.filter((v) => { + return v.id !== id; + }); + setPacks(newPacks); + const newPackData = JSON.stringify(newPacks); + setOptions({ ...options, pack_data: newPackData }); + updatePackOption("pack_data", newPackData); + }; + + const deleteGroup = (id) => { + let newGroups = [...groups]; + newGroups = newGroups.filter((v) => { + return v.id !== id; + }); + setGroups(newGroups); + const newPackData = JSON.stringify(newGroups); + setOptions({ ...options, group_sell_data: newPackData }); + updatePackOption("group_sell_data", newPackData); + }; + + return ( +
+ + setTab(v)} + scrollButtons="auto" + > + + + + + +
+ {tab === 0 && ( +
+
+ + {t("alipay")} + +
+
+ + + } + label={t("enable")} + /> + +
+ + {options.alipay_enabled === "1" && ( + <> +
+ + + {t("appID")} + + + + {t("appIDDes")} + + +
+ +
+ + + {t("rsaPrivate")} + + + + , + ]} + /> + + +
+ +
+ + + {t("alipayPublicKey")} + + + + {t( + "alipayPublicKeyDes" + )} + + +
+ + )} +
+
+ +
+ + {t("wechatPay")} + +
+
+ + + } + label={t("enable")} + /> + +
+ + {options.wechat_enabled === "1" && ( + <> +
+ + + {t("applicationID")} + + + + {t("applicationIDDes")} + + +
+ +
+ + + {t("merchantID")} + + + + {t("merchantIDDes")} + + +
+ +
+ + + {t("apiV3Secret")} + + + + {t("apiV3SecretDes")} + + +
+ +
+ + + {t( + "mcCertificateSerial" + )} + + + + {t( + "mcCertificateSerialDes" + )} + + +
+ +
+ + + {t("mcAPISecret")} + + + + {t("mcAPISecretDes")} + + +
+ + )} +
+
+ +
+ + {t("payjs")} + + +
+
+ + + , + ]} + /> + + +
+
+ + + } + label={t("enable")} + /> + +
+ + {options.payjs_enabled === "1" && ( + <> +
+ + + {t("mcNumber")} + + + + {t("mcNumberDes")} + + +
+ +
+ + + {t( + "communicationSecret" + )} + + + + {t("mcNumberDes")} + + +
+ + )} +
+
+ +
+ + {t("customPayment")} + + +
+
+ + + , + ]} + /> + + +
+
+ + + } + label={t("enable")} + /> + +
+ + {options.custom_payment_enabled === "1" && ( + <> +
+ + + {t("customPaymentName")} + + + + {t( + "customPaymentNameDes" + )} + + +
+ +
+ + + {t( + "customPaymentEndpoint" + )} + + + + {t( + "customPaymentEndpointDes" + )} + + +
+ +
+ + + {t( + "communicationSecret" + )} + + + + {t( + "customPaymentSecretDes" + )} + + +
+ + )} +
+
+ +
+ + {t("otherSettings")} + +
+
+ + + {t("banBufferPeriod")} + + + + {t("banBufferPeriodDes")} + + +
+ +
+ + + } + label={t("allowSellShares")} + /> + + {t("allowSellSharesDes")} + + +
+ + {options.score_enabled === "1" && ( +
+ + + {t("creditPriceRatio")} + + + + {t("creditPriceRatioDes")} + + +
+ )} + +
+ + + {t("creditPrice")} + + + + {t("creditPriceDes")} + + +
+ +
+ + + } + label={t("allowReportShare")} + /> + + {t("allowReportShareDes")} + + +
+
+
+ +
+ +
+
+ )} + + {tab === 1 && ( +
+ +
+ + + + {t("name")} + {t("price")} + + {t("duration")} + + {t("size")} + + {t("actions")} + + + + + {packs.map((row) => ( + + + {row.name} + + + ¥{row.price / 100} + {row.score !== 0 && + t("orCredits", { + num: row.score, + })} + + + {tApp("vas.days", { + num: Math.ceil( + row.time / 86400 + ), + })} + + + {sizeToString(row.size)} + + + { + setPackEdit(row); + setAddPack(true); + }} + size={"small"} + > + + + + deletePack(row.id) + } + size={"small"} + > + + + + + ))} + +
+
+
+ )} + + {tab === 2 && ( +
+ +
+ + + + {t("name")} + {t("price")} + + {t("duration")} + + + {t("highlight")} + + + {t("actions")} + + + + + {groups.map((row) => ( + + + {row.name} + + + ¥{row.price / 100} + {row.score !== 0 && + t("orCredits", { + num: row.score, + })} + + + {tApp("vas.days", { + num: Math.ceil( + row.time / 86400 + ), + })} + + + {row.highlight + ? t("yes") + : t("no")} + + + { + setGroupEdit(row); + setAddGroup(true); + }} + size={"small"} + > + + + + deleteGroup(row.id) + } + size={"small"} + > + + + + + ))} + +
+
+
+ )} + + {tab === 3 && ( +
+ +
+ + + + # + + {t("productName")} + + {t("qyt")} + {t("code")} + {t("status")} + {t("action")} + + + + {redeems.map((row) => ( + + + {row.ID} + + + {row.ProductID === 0 && + tApp("vas.credits")} + {product[row.ProductID] !== + undefined && ( + <> + { + product[ + row + .ProductID + ] + } + + )} + {row.ProductID !== 0 && + !product[ + row.ProductID + ] && + t("invalidProduct")} + + {row.Num} + + {row.Code} + + + {!row.Used ? ( + + {t("notUsed")} + + ) : ( + + {t("used")} + + )} + + + + deleteRedeem(row.ID) + } + size={"small"} + > + + + + + ))} + +
+
+
+ setPage(v)} + color="secondary" + /> +
+
+ )} + + { + setAddPack(false); + setPackEdit(null); + }} + /> + { + setAddGroup(false); + setGroupEdit(null); + }} + /> + setAddRedeem(false)} + /> + ( +
{v}
+ ))} + onClose={() => { + setRedeemsResOpen(false); + setRedeemsRes([]); + }} + /> +
+
+
+ ); +} diff --git a/src/component/Admin/Share/Share.js b/src/component/Admin/Share/Share.js new file mode 100644 index 0000000..9618da7 --- /dev/null +++ b/src/component/Admin/Share/Share.js @@ -0,0 +1,532 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete, FilterList } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Checkbox from "@material-ui/core/Checkbox"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { lighten } from "@material-ui/core"; +import Link from "@material-ui/core/Link"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import Badge from "@material-ui/core/Badge"; +import ShareFilter from "../Dialogs/ShareFilter"; +import { formatLocalTime } from "../../../utils/datetime"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, + highlight: + theme.palette.type === "light" + ? { + color: theme.palette.secondary.main, + backgroundColor: lighten(theme.palette.secondary.light, 0.85), + } + : { + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondary.dark, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, +})); + +export default function Share() { + const { t } = useTranslation("dashboard", { keyPrefix: "share" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [shares, setShares] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState({}); + const [users, setUsers] = useState({}); + const [ids, setIds] = useState({}); + const [search, setSearch] = useState({}); + const [orderBy, setOrderBy] = useState(["id", "desc"]); + const [filterDialog, setFilterDialog] = useState(false); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/share/list", { + page: page, + page_size: pageSize, + order_by: orderBy.join(" "), + conditions: filter, + searches: search, + }) + .then((response) => { + setUsers(response.data.users); + setIds(response.data.ids); + setShares(response.data.items); + setTotal(response.data.total); + setSelected([]); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, orderBy, filter, search]); + + const deletePolicy = (id) => { + setLoading(true); + API.post("/admin/share/delete", { id: [id] }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("deleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteBatch = () => { + setLoading(true); + API.post("/admin/share/delete", { id: selected }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("deleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = shares.map((n) => n.ID); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleClick = (event, name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + return ( +
+ setFilterDialog(false)} + setSearch={setSearch} + setFilter={setFilter} + /> +
+
+ + setFilterDialog(true)} + > + + + + + + +
+
+ + + {selected.length > 0 && ( + + + {tDashboard("user.selectedObjects", { + num: selected.length, + })} + + + + + + + + )} + + + + + + 0 && + selected.length < shares.length + } + checked={ + shares.length > 0 && + selected.length === shares.length + } + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + + + setOrderBy([ + "id", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + # + {orderBy[0] === "id" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + setOrderBy([ + "source_name", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("objectName")} + {orderBy[0] === "source_name" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {tDashboard("policy.type")} + + + + setOrderBy([ + "views", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("views")} + {orderBy[0] === "views" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + setOrderBy([ + "downloads", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("downloads")} + {orderBy[0] === "downloads" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + setOrderBy([ + "score", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("price")} + {orderBy[0] === "score" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("autoExpire")} + + + {t("owner")} + + + {t("createdAt")} + + + {tDashboard("policy.actions")} + + + + + {shares.map((row) => ( + + + + handleClick(event, row.ID) + } + checked={isSelected(row.ID)} + /> + + {row.ID} + + + + {row.Views} + + + {row.Downloads} + + + {row.Score} + + + {row.RemainDownloads > -1 && + t("afterNDownloads", { + num: row.RemainDownloads, + })} + {row.RemainDownloads === -1 && + t("none")} + + + + {users[row.UserID] + ? users[row.UserID].Nick + : tDashboard( + "file.unknownUploader" + )} + + + + {formatLocalTime(row.CreatedAt)} + + + + + deletePolicy(row.ID) + } + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/Task/Aria2Helper.js b/src/component/Admin/Task/Aria2Helper.js new file mode 100644 index 0000000..6c2d0c9 --- /dev/null +++ b/src/component/Admin/Task/Aria2Helper.js @@ -0,0 +1,83 @@ +import React from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Link, +} from "@material-ui/core"; +import { Link as RouterLink } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({})); + +export default function Aria2Helper(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "task" }); + const { t: tCommon } = useTranslation("common"); + const classes = useStyles(); + + return ( + + + {t("howToConfigAria2")} + + + + {t("aria2Des")} +
    +
  • + , + ]} + /> +
  • +
  • + , + ]} + /> +
  • +
+ , + ]} + /> +
+
+ + + +
+ ); +} diff --git a/src/component/Admin/Task/Download.js b/src/component/Admin/Task/Download.js new file mode 100644 index 0000000..76d54d1 --- /dev/null +++ b/src/component/Admin/Task/Download.js @@ -0,0 +1,444 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Checkbox from "@material-ui/core/Checkbox"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { lighten } from "@material-ui/core"; +import Link from "@material-ui/core/Link"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import ShareFilter from "../Dialogs/ShareFilter"; +import { sizeToString } from "../../../utils"; +import { formatLocalTime } from "../../../utils/datetime"; +import Aria2Helper from "./Aria2Helper"; +import HelpIcon from "@material-ui/icons/Help"; +import { Link as RouterLink } from "react-router-dom"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, + highlight: + theme.palette.type === "light" + ? { + color: theme.palette.secondary.main, + backgroundColor: lighten(theme.palette.secondary.light, 0.85), + } + : { + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondary.dark, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, +})); + +export default function Download() { + const { t } = useTranslation("dashboard", { keyPrefix: "task" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [downloads, setDownloads] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState({}); + const [users, setUsers] = useState({}); + const [search, setSearch] = useState({}); + const [orderBy, setOrderBy] = useState(["id", "desc"]); + const [filterDialog, setFilterDialog] = useState(false); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + const [helperOpen, setHelperOpen] = useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/download/list", { + page: page, + page_size: pageSize, + order_by: orderBy.join(" "), + conditions: filter, + searches: search, + }) + .then((response) => { + setUsers(response.data.users); + setDownloads(response.data.items); + setTotal(response.data.total); + setSelected([]); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, orderBy, filter, search]); + + const deletePolicy = (id) => { + setLoading(true); + API.post("/admin/download/delete", { id: [id] }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("taskDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteBatch = () => { + setLoading(true); + API.post("/admin/download/delete", { id: selected }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("taskDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = downloads.map((n) => n.ID); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleClick = (event, name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + return ( +
+ setHelperOpen(false)} + /> + setFilterDialog(false)} + setSearch={setSearch} + setFilter={setFilter} + /> +
+ +
+ +
+
+ + + {selected.length > 0 && ( + + + {tDashboard("user.selectedObjects", { + num: selected.length, + })} + + + + + + + + )} + + + + + + 0 && + selected.length < downloads.length + } + checked={ + downloads.length > 0 && + selected.length === downloads.length + } + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + + + setOrderBy([ + "id", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + # + {orderBy[0] === "id" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("srcURL")} + + + {tDashboard("user.status")} + + + {t("node")} + + + + setOrderBy([ + "total_size", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {tDashboard("file.size")} + {orderBy[0] === "total_size" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("createdBy")} + + + {tDashboard("file.createdAt")} + + + {tDashboard("policy.actions")} + + + + + {downloads.map((row) => ( + + + + handleClick(event, row.ID) + } + checked={isSelected(row.ID)} + /> + + {row.ID} + + {row.Source} + + + {row.Status === 0 && t("ready")} + {row.Status === 1 && t("downloading")} + {row.Status === 2 && t("paused")} + {row.Status === 3 && t("error")} + {row.Status === 4 && t("finished")} + {row.Status === 5 && t("canceled")} + {row.Status === 6 && t("unknown")} + {row.Status === 7 && t("seeding")} + + + {row.NodeID <= 1 && ( + + {tDashboard("node.master")} + + )} + {row.NodeID > 1 && ( + + {tDashboard("node.slave")}# + {row.NodeID} + + )} + + + {sizeToString(row.TotalSize)} + + + + {users[row.UserID] + ? users[row.UserID].Nick + : tDashboard( + "file.unknownUploader" + )} + + + + {formatLocalTime(row.CreatedAt)} + + + + + deletePolicy(row.ID) + } + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/Task/Task.js b/src/component/Admin/Task/Task.js new file mode 100644 index 0000000..edc10ee --- /dev/null +++ b/src/component/Admin/Task/Task.js @@ -0,0 +1,389 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import IconButton from "@material-ui/core/IconButton"; +import { Delete } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Checkbox from "@material-ui/core/Checkbox"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { lighten } from "@material-ui/core"; +import Link from "@material-ui/core/Link"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import ShareFilter from "../Dialogs/ShareFilter"; +import { getTaskProgress, getTaskStatus, getTaskType } from "../../../config"; +import { formatLocalTime } from "../../../utils/datetime"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, + highlight: + theme.palette.type === "light" + ? { + color: theme.palette.secondary.main, + backgroundColor: lighten(theme.palette.secondary.light, 0.85), + } + : { + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondary.dark, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, +})); + +export default function Task() { + const { t } = useTranslation("dashboard", { keyPrefix: "task" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [tasks, setTasks] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState({}); + const [users, setUsers] = useState({}); + const [search, setSearch] = useState({}); + const [orderBy, setOrderBy] = useState(["id", "desc"]); + const [filterDialog, setFilterDialog] = useState(false); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/task/list", { + page: page, + page_size: pageSize, + order_by: orderBy.join(" "), + conditions: filter, + searches: search, + }) + .then((response) => { + setUsers(response.data.users); + setTasks(response.data.items); + setTotal(response.data.total); + setSelected([]); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, orderBy, filter, search]); + + const deletePolicy = (id) => { + setLoading(true); + API.post("/admin/task/delete", { id: [id] }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("taskDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteBatch = () => { + setLoading(true); + API.post("/admin/task/delete", { id: selected }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("taskDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = tasks.map((n) => n.ID); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleClick = (event, name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + + const getError = (error) => { + if (error === "") { + return "-"; + } + try { + const res = JSON.parse(error); + return res.msg; + } catch (e) { + return t("unknown"); + } + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + return ( +
+ setFilterDialog(false)} + setSearch={setSearch} + setFilter={setFilter} + /> +
+
+ +
+
+ + + {selected.length > 0 && ( + + + {tDashboard("user.selectedObjects", { + num: selected.length, + })} + + + + + + + + )} + + + + + + 0 && + selected.length < tasks.length + } + checked={ + tasks.length > 0 && + selected.length === tasks.length + } + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + + + setOrderBy([ + "id", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + # + {orderBy[0] === "id" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {tDashboard("policy.type")} + + + {tDashboard("user.status")} + + + {t("lastProgress")} + + + {t("errorMsg")} + + + {t("createdBy")} + + + {tDashboard("file.createdAt")} + + + {tDashboard("policy.actions")} + + + + + {tasks.map((row) => ( + + + + handleClick(event, row.ID) + } + checked={isSelected(row.ID)} + /> + + {row.ID} + + {getTaskType(row.Type)} + + + {getTaskStatus(row.Status)} + + + {getTaskProgress( + row.Type, + row.Progress + )} + + + {getError(row.Error)} + + + + {users[row.UserID] + ? users[row.UserID].Nick + : t("unknown")} + + + + {formatLocalTime(row.CreatedAt)} + + + + + deletePolicy(row.ID) + } + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/User/EditUser.js b/src/component/Admin/User/EditUser.js new file mode 100644 index 0000000..a958f72 --- /dev/null +++ b/src/component/Admin/User/EditUser.js @@ -0,0 +1,36 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import UserForm from "./UserForm"; +import { toggleSnackbar } from "../../../redux/explorer"; + +export default function EditUserPreload() { + const [user, setUser] = useState({}); + + const { id } = useParams(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + setUser({}); + API.get("/admin/user/" + id) + .then((response) => { + // 整型转换 + ["Status", "GroupID", "Score"].forEach((v) => { + response.data[v] = response.data[v].toString(); + }); + setUser(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, [id]); + + return
{user.ID !== undefined && }
; +} diff --git a/src/component/Admin/User/User.js b/src/component/Admin/User/User.js new file mode 100644 index 0000000..37a9d1f --- /dev/null +++ b/src/component/Admin/User/User.js @@ -0,0 +1,541 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import { sizeToString } from "../../../utils"; +import TableBody from "@material-ui/core/TableBody"; +import TablePagination from "@material-ui/core/TablePagination"; +import { useHistory } from "react-router"; +import IconButton from "@material-ui/core/IconButton"; +import { Block, Delete, Edit, FilterList } from "@material-ui/icons"; +import Tooltip from "@material-ui/core/Tooltip"; +import Checkbox from "@material-ui/core/Checkbox"; +import Toolbar from "@material-ui/core/Toolbar"; +import Typography from "@material-ui/core/Typography"; +import { lighten } from "@material-ui/core"; +import Link from "@material-ui/core/Link"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import UserFilter from "../Dialogs/UserFilter"; +import Badge from "@material-ui/core/Badge"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + content: { + padding: theme.spacing(2), + }, + container: { + overflowX: "auto", + }, + tableContainer: { + marginTop: 16, + }, + header: { + display: "flex", + justifyContent: "space-between", + }, + headerRight: {}, + highlight: + theme.palette.type === "light" + ? { + color: theme.palette.secondary.main, + backgroundColor: lighten(theme.palette.secondary.light, 0.85), + } + : { + color: theme.palette.text.primary, + backgroundColor: theme.palette.secondary.dark, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, +})); + +export default function Group() { + const { t } = useTranslation("dashboard", { keyPrefix: "user" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [users, setUsers] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState({}); + const [search, setSearch] = useState({}); + const [orderBy, setOrderBy] = useState(["id", "desc"]); + const [filterDialog, setFilterDialog] = useState(false); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + + const history = useHistory(); + const theme = useTheme(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = () => { + API.post("/admin/user/list", { + page: page, + page_size: pageSize, + order_by: orderBy.join(" "), + conditions: filter, + searches: search, + }) + .then((response) => { + setUsers(response.data.items); + setTotal(response.data.total); + setSelected([]); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(); + }, [page, pageSize, orderBy, filter, search]); + + const deletePolicy = (id) => { + setLoading(true); + API.post("/admin/user/delete", { id: [id] }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", "用户已删除", "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const deleteBatch = () => { + setLoading(true); + API.post("/admin/user/delete", { id: selected }) + .then(() => { + loadList(); + ToggleSnackbar("top", "right", t("deleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const block = (id) => { + setLoading(true); + API.patch("/admin/user/ban/" + id) + .then((response) => { + setUsers( + users.map((v) => { + if (v.ID === id) { + const newUser = { ...v, Status: response.data }; + return newUser; + } + return v; + }) + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = users.map((n) => n.ID); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleClick = (event, name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + return ( +
+ setFilterDialog(false)} + setSearch={setSearch} + setFilter={setFilter} + /> +
+ +
+ + setFilterDialog(true)} + > + + + + + + +
+
+ + + {selected.length > 0 && ( + + + {t("selectedObjects", { num: selected.length })} + + + + + + + + )} + + + + + + 0 && + selected.length < users.length + } + checked={ + users.length > 0 && + selected.length === users.length + } + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + + + setOrderBy([ + "id", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {tDashboard("node.#")} + {orderBy[0] === "id" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + setOrderBy([ + "nick", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("nick")} + {orderBy[0] === "nick" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + setOrderBy([ + "email", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("email")} + {orderBy[0] === "email" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {t("group")} + + + {t("status")} + + + + setOrderBy([ + "storage", + orderBy[1] === "asc" + ? "desc" + : "asc", + ]) + } + > + {t("usedStorage")} + {orderBy[0] === "storage" ? ( + + {orderBy[1] === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + {tDashboard("policy.actions")} + + + + + {users.map((row) => ( + + + + handleClick(event, row.ID) + } + checked={isSelected(row.ID)} + /> + + {row.ID} + {row.Nick} + {row.Email} + + + {row.Group.Name} + + + + {row.Status === 0 && ( + + {t("active")} + + )} + {row.Status === 1 && ( + + {t("notActivated")} + + )} + {row.Status === 2 && ( + + {t("banned")} + + )} + {row.Status === 3 && ( + + {t("bannedBySys")} + + )} + + + {sizeToString(row.Storage)} + + + + + history.push( + "/admin/user/edit/" + + row.ID + ) + } + size={"small"} + > + + + + + block(row.ID)} + size={"small"} + > + + + + + + deletePolicy(row.ID) + } + size={"small"} + > + + + + + + ))} + +
+
+ setPage(p + 1)} + onChangeRowsPerPage={(e) => { + setPageSize(e.target.value); + setPage(1); + }} + /> +
+
+ ); +} diff --git a/src/component/Admin/User/UserForm.js b/src/component/Admin/User/UserForm.js new file mode 100644 index 0000000..817ae82 --- /dev/null +++ b/src/component/Admin/User/UserForm.js @@ -0,0 +1,273 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InputLabel from "@material-ui/core/InputLabel"; +import FormControl from "@material-ui/core/FormControl"; +import Input from "@material-ui/core/Input"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import Button from "@material-ui/core/Button"; +import API from "../../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import { useHistory } from "react-router"; +import { toggleSnackbar } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + root: { + [theme.breakpoints.up("md")]: { + marginLeft: 100, + }, + marginBottom: 40, + }, + form: { + maxWidth: 400, + marginTop: 20, + marginBottom: 20, + }, + formContainer: { + [theme.breakpoints.up("md")]: { + padding: "0px 24px 0 24px", + }, + }, +})); +export default function UserForm(props) { + const { t } = useTranslation("dashboard", { keyPrefix: "user" }); + const { t: tDashboard } = useTranslation("dashboard"); + const classes = useStyles(); + const [loading, setLoading] = useState(false); + const [user, setUser] = useState( + props.user + ? props.user + : { + ID: 0, + Email: "", + Nick: "", + Password: "", // 为空时只读 + Status: "0", // 转换类型 + GroupID: "2", // 转换类型 + Score: "0", // 转换类型 + TwoFactor: "", + } + ); + const [groups, setGroups] = useState([]); + + const history = useHistory(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + API.get("/admin/groups") + .then((response) => { + setGroups(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }, []); + + const handleChange = (name) => (event) => { + setUser({ + ...user, + [name]: event.target.value, + }); + }; + + const submit = (e) => { + e.preventDefault(); + const userCopy = { ...user }; + + // 整型转换 + ["Status", "GroupID", "Score"].forEach((v) => { + userCopy[v] = parseInt(userCopy[v]); + }); + + setLoading(true); + API.post("/admin/user", { + user: userCopy, + password: userCopy.Password, + }) + .then(() => { + history.push("/admin/user"); + ToggleSnackbar( + "top", + "right", + props.user ? t("saved") : t("added"), + "success" + ); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const groupSelections = useMemo( + () => + groups.map((v) => { + if (v.ID === 3) { + return null; + } + return ( + + {v.Name} + + ); + }), + [groups] + ); + + return ( +
+
+
+ + {user.ID === 0 && t("new")} + {user.ID !== 0 && t("editUser", { nick: user.Nick })} + + +
+
+ + + {t("email")} + + + +
+ +
+ + + {t("nick")} + + + +
+ +
+ + + {t("password")} + + + + {user.ID !== 0 && t("passwordDes")} + + +
+ +
+ + + {t("group")} + + + + {t("groupDes")} + + +
+ +
+ + + {t("status")} + + + +
+ +
+ + + {tDashboard("vas.credits")} + + + +
+ +
+ + + {t("2FASecret")} + + + + + {t("2FASecretDes")} + +
+
+
+
+ +
+
+
+ ); +} diff --git a/src/component/Common/ICPFooter.js b/src/component/Common/ICPFooter.js new file mode 100644 index 0000000..d0390b4 --- /dev/null +++ b/src/component/Common/ICPFooter.js @@ -0,0 +1,42 @@ +import { Link, makeStyles } from "@material-ui/core"; +import React, { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { useLocation } from "react-router"; +import pageHelper from "../../utils/page"; + +const useStyles = makeStyles(() => ({ + icp: { + padding: "8px 24px", + position: "absolute", + bottom: 0, + }, +})); + +export const ICPFooter = () => { + const siteICPId = useSelector((state) => state.siteConfig.siteICPId); + const classes = useStyles(); + const location = useLocation(); + const [show, setShow] = useState(true); + + useEffect(() => { + // 只在分享和登录界面显示 + const isSharePage = pageHelper.isSharePage(location.pathname); + const isLoginPage = pageHelper.isLoginPage(location.pathname); + setShow(siteICPId && (isSharePage || isLoginPage)); + }, [siteICPId, location]); + + if (!show) { + return <>; + } + return ( +
+ + {siteICPId} + +
+ ); +}; diff --git a/src/component/Common/Snackbar.js b/src/component/Common/Snackbar.js new file mode 100644 index 0000000..b5262c3 --- /dev/null +++ b/src/component/Common/Snackbar.js @@ -0,0 +1,151 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import classNames from "classnames"; +import ErrorIcon from "@material-ui/icons/Error"; +import InfoIcon from "@material-ui/icons/Info"; +import CloseIcon from "@material-ui/icons/Close"; +import CheckCircleIcon from "@material-ui/icons/CheckCircle"; +import WarningIcon from "@material-ui/icons/Warning"; + +import { + IconButton, + Snackbar, + SnackbarContent, + withStyles, +} from "@material-ui/core"; + +const mapStateToProps = (state) => { + return { + snackbar: state.viewUpdate.snackbar, + }; +}; + +const mapDispatchToProps = () => { + return {}; +}; + +const variantIcon = { + success: CheckCircleIcon, + warning: WarningIcon, + error: ErrorIcon, + info: InfoIcon, +}; + +const styles1 = (theme) => ({ + success: { + backgroundColor: theme.palette.success.main, + }, + error: { + backgroundColor: theme.palette.error.dark, + }, + info: { + backgroundColor: theme.palette.info.main, + }, + warning: { + backgroundColor: theme.palette.warning.main, + }, + icon: { + fontSize: 20, + }, + iconVariant: { + opacity: 0.9, + marginRight: theme.spacing(1), + }, + message: { + display: "flex", + alignItems: "center", + }, +}); + +function MySnackbarContent(props) { + const { classes, className, message, onClose, variant, ...other } = props; + const Icon = variantIcon[variant]; + + return ( + + + {message} + + } + action={[ + + + , + ]} + {...other} + /> + ); +} +MySnackbarContent.propTypes = { + classes: PropTypes.object.isRequired, + className: PropTypes.string, + message: PropTypes.node, + onClose: PropTypes.func, + variant: PropTypes.oneOf(["alert", "success", "warning", "error", "info"]) + .isRequired, +}; + +const MySnackbarContentWrapper = withStyles(styles1)(MySnackbarContent); +const styles = (theme) => ({ + margin: { + margin: theme.spacing(1), + }, +}); +class SnackbarCompoment extends Component { + state = { + open: false, + }; + + UNSAFE_componentWillReceiveProps = (nextProps) => { + if (nextProps.snackbar.toggle !== this.props.snackbar.toggle) { + this.setState({ open: true }); + } + }; + + handleClose = () => { + this.setState({ open: false }); + }; + + render() { + return ( + + + + ); + } +} + +const AlertBar = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(SnackbarCompoment)); + +export default AlertBar; diff --git a/src/component/Dial/Aria2.js b/src/component/Dial/Aria2.js new file mode 100644 index 0000000..3e22a55 --- /dev/null +++ b/src/component/Dial/Aria2.js @@ -0,0 +1,45 @@ +import React, { useCallback } from "react"; +import { useDispatch } from "react-redux"; +import AutoHidden from "./AutoHidden"; +import { makeStyles } from "@material-ui/core"; +import Fab from "@material-ui/core/Fab"; +import { Add } from "@material-ui/icons"; +import Modals from "../FileManager/Modals"; +import { openRemoteDownloadDialog } from "../../redux/explorer"; + +const useStyles = makeStyles(() => ({ + fab: { + margin: 0, + top: "auto", + right: 20, + bottom: 20, + left: "auto", + zIndex: 5, + position: "fixed", + }, +})); + +export default function RemoteDownloadButton() { + const classes = useStyles(); + const dispatch = useDispatch(); + + const OpenRemoteDownloadDialog = useCallback( + () => dispatch(openRemoteDownloadDialog()), + [dispatch] + ); + + return ( + <> + + + OpenRemoteDownloadDialog()} + > + + + + + ); +} diff --git a/src/component/Dial/AutoHidden.js b/src/component/Dial/AutoHidden.js new file mode 100644 index 0000000..b78c34b --- /dev/null +++ b/src/component/Dial/AutoHidden.js @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from "react"; +import Zoom from "@material-ui/core/Zoom"; + +function AutoHidden({ children, enable, hide = false, element = null }) { + const [hidden, setHidden] = useState(false); + let prev = window.scrollY; + let lastUpdate = window.scrollY; + const show = 50; + + useEffect(() => { + const handleNavigation = (e) => { + const window = e.currentTarget; + const current = element ? element.scrollTop : window.scrollY; + + if (prev > current) { + if (lastUpdate - current > show) { + lastUpdate = current; + setHidden(false); + } + } else if (prev < current) { + if (current - lastUpdate > show) { + lastUpdate = current; + setHidden(true); + } + } + prev = current; + }; + if (enable) { + const target = element ? element : window; + target.addEventListener("scroll", (e) => handleNavigation(e)); + } + // eslint-disable-next-line + }, [enable]); + + return {children}; +} + +export default AutoHidden; diff --git a/src/component/Dial/Create.js b/src/component/Dial/Create.js new file mode 100644 index 0000000..2ff29c1 --- /dev/null +++ b/src/component/Dial/Create.js @@ -0,0 +1,195 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge, CircularProgress, makeStyles } from "@material-ui/core"; +import SpeedDial from "@material-ui/lab/SpeedDial"; +import SpeedDialIcon from "@material-ui/lab/SpeedDialIcon"; +import SpeedDialAction from "@material-ui/lab/SpeedDialAction"; +import CreateNewFolderIcon from "@material-ui/icons/CreateNewFolder"; +import PublishIcon from "@material-ui/icons/Publish"; +import { useDispatch, useSelector } from "react-redux"; +import AutoHidden from "./AutoHidden"; +import statusHelper from "../../utils/page"; +import Backdrop from "@material-ui/core/Backdrop"; +import { FilePlus, FolderUpload } from "mdi-material-ui"; +import { green } from "@material-ui/core/colors"; +import { SelectType } from "../Uploader/core"; +import { + openCreateFileDialog, + openCreateFolderDialog, + toggleSnackbar, +} from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles(() => ({ + fab: { + margin: 0, + top: "auto", + right: 20, + bottom: 20, + left: "auto", + zIndex: 5, + position: "fixed", + }, + badge: { + position: "absolute", + bottom: 26, + top: "auto", + zIndex: 9999, + right: 7, + }, + "@global": { + ".MuiSpeedDialAction-staticTooltipLabel": { + whiteSpace: "nowrap", + }, + }, + fabProgress: { + color: green[500], + position: "absolute", + bottom: -6, + left: -6, + zIndex: 1, + }, + buttonSuccess: { + backgroundColor: green[500], + "&:hover": { + backgroundColor: green[700], + }, + }, +})); + +export default function UploadButton(props) { + const { t } = useTranslation("application", { keyPrefix: "fileManager" }); + const [open, setOpen] = useState(false); + const [queued, setQueued] = useState(5); + const path = useSelector((state) => state.navigator.path); + const classes = useStyles(); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const OpenNewFolderDialog = useCallback( + () => dispatch(openCreateFolderDialog()), + [dispatch] + ); + const OpenNewFileDialog = useCallback( + () => dispatch(openCreateFileDialog()), + [dispatch] + ); + + useEffect(() => { + setQueued(props.Queued); + }, [props.Queued]); + + const uploadClicked = () => { + if (open) { + if (queued !== 0) { + props.openFileList(); + } else { + props.selectFile(path); + } + } + }; + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const circularProgress = useMemo(() => { + if (props.progress.totalSize > 0) { + return ( + + ); + } + }, [classes, props.progress]); + + return ( + + + + + {circularProgress} + + + ); +} diff --git a/src/component/Dial/Save.js b/src/component/Dial/Save.js new file mode 100644 index 0000000..104a18e --- /dev/null +++ b/src/component/Dial/Save.js @@ -0,0 +1,84 @@ +import React from "react"; +import { makeStyles } from "@material-ui/core"; +import SaveIcon from "@material-ui/icons/Save"; +import CheckIcon from "@material-ui/icons/Check"; +import AutoHidden from "./AutoHidden"; +import statusHelper from "../../utils/page"; +import Fab from "@material-ui/core/Fab"; +import Tooltip from "@material-ui/core/Tooltip"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import { green } from "@material-ui/core/colors"; +import clsx from "clsx"; + +const useStyles = makeStyles((theme) => ({ + fab: { + margin: 0, + top: "auto", + right: 20, + bottom: 20, + left: "auto", + zIndex: 5, + position: "fixed", + }, + badge: { + position: "absolute", + bottom: 26, + top: "auto", + zIndex: 9999, + right: 7, + }, + fabProgress: { + color: green[500], + position: "absolute", + top: -6, + left: -6, + zIndex: 1, + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonSuccess: { + backgroundColor: green[500], + "&:hover": { + backgroundColor: green[700], + }, + }, +})); + +export default function SaveButton(props) { + const classes = useStyles(); + const buttonClassname = clsx({ + [classes.buttonSuccess]: props.status === "success", + }); + + return ( + +
+
+ + + {props.status === "success" ? ( + + ) : ( + + )} + + + {props.status === "loading" && ( + + )} +
+
+
+ ); +} diff --git a/src/component/Download/Download.js b/src/component/Download/Download.js new file mode 100644 index 0000000..eea5281 --- /dev/null +++ b/src/component/Download/Download.js @@ -0,0 +1,229 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import RefreshIcon from "@material-ui/icons/Refresh"; +import API from "../../middleware/Api"; +import { Button, IconButton, Typography, withStyles } from "@material-ui/core"; +import DownloadingCard from "./DownloadingCard"; +import FinishedCard from "./FinishedCard"; +import RemoteDownloadButton from "../Dial/Aria2"; +import Auth from "../../middleware/Auth"; +import { toggleSnackbar } from "../../redux/explorer"; +import Nothing from "../Placeholder/Nothing"; +import { withTranslation } from "react-i18next"; + +const styles = (theme) => ({ + actions: { + display: "flex", + }, + title: { + marginTop: "20px", + }, + layout: { + width: "auto", + marginTop: "30px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 700, + marginLeft: "auto", + marginRight: "auto", + }, + }, + shareTitle: { + maxWidth: "200px", + }, + avatarFile: { + backgroundColor: theme.palette.primary.light, + }, + avatarFolder: { + backgroundColor: theme.palette.secondary.light, + }, + gird: { + marginTop: "30px", + }, + hide: { + display: "none", + }, + loadingAnimation: { + borderRadius: "6px 6px 0 0", + }, + shareFix: { + marginLeft: "20px", + }, + loadMore: { + textAlign: "center", + marginTop: "20px", + marginBottom: "20px", + }, + margin: { + marginTop: theme.spacing(2), + }, +}); +const mapStateToProps = () => { + return {}; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +class DownloadComponent extends Component { + page = 0; + interval = 0; + previousDownloading = -1; + + state = { + downloading: [], + loading: false, + finishedList: [], + continue: true, + }; + + componentDidMount = () => { + this.loadDownloading(); + }; + + componentWillUnmount() { + clearTimeout(this.interval); + } + + loadDownloading = () => { + this.setState({ + loading: true, + }); + API.get("/aria2/downloading") + .then((response) => { + this.setState({ + downloading: response.data, + loading: false, + }); + // 设定自动更新 + clearTimeout(this.interval); + if (response.data.length > 0) { + this.interval = setTimeout( + this.loadDownloading, + 1000 * + response.data.reduce(function (prev, current) { + return prev.interval < current.interval + ? prev + : current; + }).interval + ); + } + + // 下载中条目变更时刷新已完成列表 + if (response.data.length !== this.previousDownloading) { + this.page = 0; + this.setState({ + finishedList: [], + continue: true, + }); + this.loadMore(); + } + this.previousDownloading = response.data.length; + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + loadMore = () => { + this.setState({ + loading: true, + }); + API.get("/aria2/finished?page=" + ++this.page) + .then((response) => { + this.setState({ + finishedList: [ + ...this.state.finishedList, + ...response.data, + ], + loading: false, + continue: response.data.length >= 10, + }); + }) + .catch(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("download.failedToLoad"), + "error" + ); + this.setState({ + loading: false, + }); + }); + }; + + render() { + const { classes, t } = this.props; + const user = Auth.GetUser(); + + return ( +
+ {user.group.allowRemoteDownload && } + + {t("download.active")} + + + + + {this.state.downloading.length === 0 && ( + + )} + {this.state.downloading.map((value, k) => ( + + ))} + + {t("download.finished")} + +
+ {this.state.finishedList.length === 0 && ( + + )} + {this.state.finishedList.map((value, k) => { + if (value.files) { + return ; + } + return null; + })} + +
+
+ ); + } +} + +const Download = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withTranslation()(DownloadComponent))); + +export default Download; diff --git a/src/component/Download/DownloadingCard.js b/src/component/Download/DownloadingCard.js new file mode 100644 index 0000000..dfcfb03 --- /dev/null +++ b/src/component/Download/DownloadingCard.js @@ -0,0 +1,761 @@ +import React, { useCallback, useEffect,useMemo } from "react"; +import { + Card, + CardContent, + darken, + IconButton, + lighten, + LinearProgress, + makeStyles, + Typography, + useTheme, +} from "@material-ui/core"; +import { useDispatch } from "react-redux"; +import { hex2bin, sizeToString } from "../../utils"; +import PermMediaIcon from "@material-ui/icons/PermMedia"; +import TypeIcon from "../FileManager/TypeIcon"; +import MuiExpansionPanel from "@material-ui/core/ExpansionPanel"; +import MuiExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary"; +import MuiExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails"; +import withStyles from "@material-ui/core/styles/withStyles"; +import Divider from "@material-ui/core/Divider"; +import { ExpandMore, HighlightOff } from "@material-ui/icons"; +import classNames from "classnames"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import Table from "@material-ui/core/Table"; +import Badge from "@material-ui/core/Badge"; +import Tooltip from "@material-ui/core/Tooltip"; +import API from "../../middleware/Api"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import TimeAgo from "timeago-react"; +import SelectFileDialog from "../Modals/SelectFile"; +import { useHistory } from "react-router"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; +import { TableVirtuoso } from "react-virtuoso"; + +const ExpansionPanel = withStyles({ + root: { + maxWidth: "100%", + boxShadow: "none", + "&:not(:last-child)": { + borderBottom: 0, + }, + "&:before": { + display: "none", + }, + "&$expanded": {}, + }, + expanded: {}, +})(MuiExpansionPanel); + +const ExpansionPanelSummary = withStyles({ + root: { + minHeight: 0, + padding: 0, + + "&$expanded": { + minHeight: 56, + }, + }, + content: { + maxWidth: "100%", + margin: 0, + display: "flex", + "&$expanded": { + margin: "0", + }, + }, + expanded: {}, +})(MuiExpansionPanelSummary); + +const ExpansionPanelDetails = withStyles((theme) => ({ + root: { + display: "block", + padding: theme.spacing(0), + }, +}))(MuiExpansionPanelDetails); + +const useStyles = makeStyles((theme) => ({ + card: { + marginTop: "20px", + justifyContent: "space-between", + }, + iconContainer: { + width: "90px", + height: "96px", + padding: " 35px 29px 29px 29px", + paddingLeft: "35px", + [theme.breakpoints.down("sm")]: { + display: "none", + }, + }, + content: { + width: "100%", + minWidth: 0, + [theme.breakpoints.up("sm")]: { + borderInlineStart: "1px " + theme.palette.divider + " solid", + }, + }, + contentSide: { + minWidth: 0, + paddingTop: "24px", + paddingRight: "28px", + [theme.breakpoints.down("sm")]: { + display: "none", + }, + }, + iconBig: { + fontSize: "30px", + }, + iconMultiple: { + fontSize: "30px", + color: "#607D8B", + }, + progress: { + marginTop: 8, + marginBottom: 4, + }, + expand: { + transition: ".15s transform ease-in-out", + }, + expanded: { + transform: "rotate(180deg)", + }, + subFile: { + width: "100%", + minWidth: 300, + wordBreak: "break-all", + }, + subFileName: { + display: "flex", + }, + subFileIcon: { + marginRight: "20px", + }, + subFileSize: { + minWidth: 120, + }, + subFilePercent: { + minWidth: 105, + }, + scroll: { + overflow: "auto", + maxHeight: "300px", + }, + action: { + padding: theme.spacing(2), + textAlign: "right", + }, + actionButton: { + marginLeft: theme.spacing(1), + }, + info: { + padding: theme.spacing(2), + }, + infoTitle: { + fontWeight: 700, + textAlign: "left", + }, + infoValue: { + color: theme.palette.text.secondary, + textAlign: "left", + paddingLeft:theme.spacing(1), + }, + bitmap: { + width: "100%", + height: "50px", + backgroundColor: theme.palette.background.default, + }, +})); + +export default function DownloadingCard(props) { + const { t } = useTranslation("application", { keyPrefix: "download" }); + const { t: tGlobal } = useTranslation(); + const canvasRef = React.createRef(); + const classes = useStyles(); + const theme = useTheme(); + const history = useHistory(); + + const [expanded, setExpanded] = React.useState(""); + const [task, setTask] = React.useState(props.task); + const [loading, setLoading] = React.useState(false); + const [selectDialogOpen, setSelectDialogOpen] = React.useState(false); + const [selectFileOption, setSelectFileOption] = React.useState([]); + + const handleChange = (panel) => (event, newExpanded) => { + setExpanded(newExpanded ? panel : false); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + setTask(props.task); + }, [props.task]); + + useEffect(() => { + if (task.info.bitfield === "") { + return; + } + let result = ""; + task.info.bitfield.match(/.{1,2}/g).forEach((str) => { + result += hex2bin(str); + }); + const canvas = canvasRef.current; + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvas.width, canvas.height); + context.strokeStyle = theme.palette.primary.main; + for (let i = 0; i < canvas.width; i++) { + let bit = + result[ + Math.round(((i + 1) / canvas.width) * task.info.numPieces) + ]; + bit = bit ? bit : result.slice(-1); + if (bit === "1") { + context.beginPath(); + context.moveTo(i, 0); + context.lineTo(i, canvas.height); + context.stroke(); + } + } + // eslint-disable-next-line + }, [task.info.bitfield, task.info.numPieces, theme]); + + const getPercent = (completed, total) => { + if (total === 0) { + return 0; + } + return (completed / total) * 100; + }; + + const activeFiles = useCallback(() => { + return task.info.files.filter((v) => v.selected === "true"); + }, [task.info.files]); + + const deleteFile = (index) => { + setLoading(true); + const current = activeFiles(); + const newIndex = []; + const newFiles = []; + // eslint-disable-next-line + current.map((v) => { + if (v.index !== index && v.selected) { + newIndex.push(parseInt(v.index)); + newFiles.push({ + ...v, + selected: "true", + }); + } else { + newFiles.push({ + ...v, + selected: "false", + }); + } + }); + API.put("/aria2/select/" + task.info.gid, { + indexes: newIndex, + }) + .then(() => { + setTask({ + ...task, + info: { + ...task.info, + files: newFiles, + }, + }); + ToggleSnackbar("top", "right", t("taskFileDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const getDownloadName = useCallback(() => { + if (task.info.bittorrent.info.name !== "") { + return task.info.bittorrent.info.name; + } + return task.name === "." ? t("unknownTaskName") : task.name; + }, [task]); + + const getIcon = useCallback(() => { + if (task.info.bittorrent.mode === "multi") { + return ( + + + + ); + } else { + return ( + + ); + } + // eslint-disable-next-line + }, [task, classes]); + + const cancel = () => { + setLoading(true); + API.delete("/aria2/task/" + task.info.gid) + .then(() => { + ToggleSnackbar("top", "right", t("taskCanceled"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const changeSelectedFile = (fileIndex) => { + setLoading(true); + API.put("/aria2/select/" + task.info.gid, { + indexes: fileIndex, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + t("operationSubmitted"), + "success" + ); + setSelectDialogOpen(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const subFileList = useMemo(() => { + const processStyle = (value) => ({ + background: + "linear-gradient(to right, " + + (theme.palette.type === + "dark" + ? darken( + theme.palette + .primary + .main, + 0.4 + ) + : lighten( + theme.palette + .primary + .main, + 0.85 + )) + + " 0%," + + (theme.palette.type === + "dark" + ? darken( + theme.palette + .primary + .main, + 0.4 + ) + : lighten( + theme.palette + .primary + .main, + 0.85 + )) + + " " + + getPercent( + value.completedLength, + value.length + ).toFixed(0) + + "%," + + theme.palette.background + .paper + + " " + + getPercent( + value.completedLength, + value.length + ).toFixed(0) + + "%," + + theme.palette.background + .paper + + " 100%)", + }); + + const subFileCell = (value) => ( + <> + + + + {value.path} + + + + + {" "} + {sizeToString( + value.length + )} + + + + + {getPercent( + value.completedLength, + value.length + ).toFixed(2)} + % + + + + + + deleteFile( + value.index + ) + } + disabled={loading} + size={"small"} + > + + + + + + ); + + return activeFiles().length > 5 ? ( + , + // eslint-disable-next-line react/display-name + TableRow: (props) => { + const index = props["data-index"]; + const value = activeFiles()[index]; + return ( + + ); + }, + }} + data={activeFiles()} + itemContent={(index, value) => ( + subFileCell(value) + )} + /> + ) : ( +
+
+ + {activeFiles().map((value) => { + return ( + + {subFileCell(value)} + + ); + })} + +
+
+ ); + }, [ + classes, + theme, + activeFiles, + ]); + + return ( + + setSelectDialogOpen(false)} + modalsLoading={loading} + files={selectFileOption} + onSubmit={changeSelectedFile} + /> + + +
{getIcon()}
+ + + + {getDownloadName()} + + + + + {task.total > 0 && ( + + {getPercent( + task.downloaded, + task.total + ).toFixed(2)} + % -{" "} + {task.downloaded === 0 + ? "0Bytes" + : sizeToString(task.downloaded)} + / + {task.total === 0 + ? "0Bytes" + : sizeToString(task.total)}{" "} + -{" "} + {task.speed === "0" + ? "0B/s" + : sizeToString(task.speed) + "/s"} + + )} + {task.total === 0 && - } + + + + + + + +
+ + + {task.info.bittorrent.mode === "multi" && subFileList} +
+ + {task.info.bittorrent.mode === "multi" && ( + + )} + +
+ +
+ {task.info.bitfield !== "" && ( + + )} + + + + + {t("updatedAt")} + + + + + + + + {t("uploaded")} + + + {sizeToString(task.info.uploadLength)} + + + + + {t("uploadSpeed")} + + + {sizeToString(task.info.uploadSpeed)} / s + + + {task.info.bittorrent.mode !== "" && ( + <> + + + {t("InfoHash")} + + + {task.info.infoHash} + + + + + {t("seederCount")} + + + {task.info.numSeeders} + + + + + {t("seeding")} + + + {task.info.seeder === "true" + ? t("isSeeding") + : t("notSeeding")} + + + + )} + + + {t("chunkSize")} + + + {sizeToString(task.info.pieceLength)} + + + + + {t("chunkNumbers")} + + + {task.info.numPieces} + + + {props.task.node && + + {t("downloadNode")} + + + {props.task.node} + + } + +
+
+
+
+ ); +} diff --git a/src/component/Download/FinishedCard.js b/src/component/Download/FinishedCard.js new file mode 100644 index 0000000..c8b1ca9 --- /dev/null +++ b/src/component/Download/FinishedCard.js @@ -0,0 +1,489 @@ +import React, { useCallback, useMemo } from "react"; +import { + Card, + CardContent, + IconButton, + makeStyles, + Typography, + useTheme, +} from "@material-ui/core"; +import { sizeToString } from "../../utils"; +import PermMediaIcon from "@material-ui/icons/PermMedia"; +import TypeIcon from "../FileManager/TypeIcon"; +import MuiExpansionPanel from "@material-ui/core/ExpansionPanel"; +import MuiExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary"; +import MuiExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails"; +import withStyles from "@material-ui/core/styles/withStyles"; +import Divider from "@material-ui/core/Divider"; +import { ExpandMore } from "@material-ui/icons"; +import classNames from "classnames"; +import Table from "@material-ui/core/Table"; +import TableBody from "@material-ui/core/TableBody"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import Badge from "@material-ui/core/Badge"; +import Tooltip from "@material-ui/core/Tooltip"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import API from "../../middleware/Api"; +import { useDispatch } from "react-redux"; +import { useHistory } from "react-router"; +import { formatLocalTime } from "../../utils/datetime"; +import { toggleSnackbar } from "../../redux/explorer"; +import { TableVirtuoso } from "react-virtuoso"; +import { useTranslation } from "react-i18next"; + +const ExpansionPanel = withStyles({ + root: { + maxWidth: "100%", + boxShadow: "none", + "&:not(:last-child)": { + borderBottom: 0, + }, + "&:before": { + display: "none", + }, + "&$expanded": {}, + }, + expanded: {}, +})(MuiExpansionPanel); + +const ExpansionPanelSummary = withStyles({ + root: { + minHeight: 0, + padding: 0, + + "&$expanded": { + minHeight: 56, + }, + }, + content: { + maxWidth: "100%", + margin: 0, + display: "flex", + "&$expanded": { + margin: "0", + }, + }, + expanded: {}, +})(MuiExpansionPanelSummary); + +const ExpansionPanelDetails = withStyles((theme) => ({ + root: { + display: "block", + padding: theme.spacing(0), + }, +}))(MuiExpansionPanelDetails); + +const useStyles = makeStyles((theme) => ({ + card: { + marginTop: "20px", + justifyContent: "space-between", + }, + iconContainer: { + width: "90px", + height: "96px", + padding: " 35px 29px 29px 29px", + paddingLeft: "35px", + [theme.breakpoints.down("sm")]: { + display: "none", + }, + }, + content: { + width: "100%", + minWidth: 0, + [theme.breakpoints.up("sm")]: { + borderInlineStart: "1px " + theme.palette.divider + " solid", + }, + textAlign: "left", + }, + contentSide: { + minWidth: 0, + paddingTop: "24px", + paddingRight: "28px", + [theme.breakpoints.down("sm")]: { + display: "none", + }, + }, + iconBig: { + fontSize: "30px", + }, + iconMultiple: { + fontSize: "30px", + color: "#607D8B", + }, + progress: { + marginTop: 8, + marginBottom: 4, + }, + expand: { + transition: ".15s transform ease-in-out", + }, + expanded: { + transform: "rotate(180deg)", + }, + subFile: { + width: "100%", + minWidth: 300, + wordBreak: "break-all", + }, + subFileName: { + display: "flex", + }, + subFileIcon: { + marginRight: "20px", + }, + subFileSize: { + minWidth: 115, + }, + subFilePercent: { + minWidth: 100, + }, + scroll: { + overflow: "auto", + maxHeight: "300px", + }, + action: { + padding: theme.spacing(2), + textAlign: "right", + }, + actionButton: { + marginLeft: theme.spacing(1), + }, + info: { + padding: theme.spacing(2), + }, + infoTitle: { + fontWeight: 700, + textAlign: "left", + }, + infoValue: { + color: theme.palette.text.secondary, + textAlign: "left", + paddingLeft: theme.spacing(1), + }, +})); + +export default function FinishedCard(props) { + const { t } = useTranslation("application", { keyPrefix: "download" }); + const classes = useStyles(); + const theme = useTheme(); + const history = useHistory(); + + const [expanded, setExpanded] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const handleChange = () => (event, newExpanded) => { + setExpanded(!!newExpanded); + }; + + const cancel = () => { + setLoading(true); + API.delete("/aria2/task/" + props.task.gid) + .then(() => { + ToggleSnackbar("top", "right", t("taskDeleted"), "success"); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + window.location.reload(); + }); + }; + + const getPercent = (completed, total) => { + if (total == 0) { + return 0; + } + return (completed / total) * 100; + }; + + const getDownloadName = useCallback(() => { + return props.task.name === "." ? t("unknownTaskName") : props.task.name; + }, [props.task.name]); + + const activeFiles = useCallback(() => { + return props.task.files.filter((v) => v.selected === "true"); + }, [props.task.files]); + + const getIcon = useCallback(() => { + if (props.task.files.length > 1) { + return ( + + + + ); + } else { + return ( + + ); + } + }, [props.task, classes]); + + const getTaskError = (error) => { + try { + const res = JSON.parse(error); + return res.msg + ":" + res.error; + } catch (e) { + return t("transferFailed"); + } + }; + + const subFileList = useMemo(() => { + const subFileCell = (value) => ( + <> + + + + {value.path} + + + + + {" "} + {sizeToString(value.length)} + + + + + {getPercent( + value.completedLength, + value.length + ).toFixed(2)} + % + + + + ); + + return activeFiles().length > 5 ? ( + , + }} + data={activeFiles()} + itemContent={(index, value) => subFileCell(value)} + /> + ) : ( +
+
+ + {activeFiles().map((value) => { + return ( + + {subFileCell(value)} + + ); + })} + +
+
+ ); + }, [classes, activeFiles]); + + return ( + + + +
{getIcon()}
+ + + + {getDownloadName()} + + + {props.task.status === 3 && ( + + + {t("downloadFailed", { + msg: props.task.error, + })} + + + )} + {props.task.status === 5 && ( + + {t("canceledStatus")} + {props.task.error !== "" && ( + ({props.task.error}) + )} + + )} + {props.task.status === 4 && + props.task.task_status === 4 && ( + + {t("finishedStatus")} + + )} + {props.task.status === 4 && + props.task.task_status === 0 && ( + + {t("pending")} + + )} + {props.task.status === 4 && + props.task.task_status === 1 && ( + + {t("transferring")} + + )} + {props.task.status === 4 && + props.task.task_status === 2 && ( + + + {getTaskError(props.task.task_error)} + + + )} + + + + + + +
+ + + {props.task.files.length > 1 && subFileList} +
+ + +
+ +
+ + + + {t("createdAt")} + + + {formatLocalTime(props.task.create)} + + + + + {t("updatedAt")} + + + {formatLocalTime(props.task.update)} + + + {props.task.node && ( + + + {t("downloadNode")} + + + {props.task.node} + + + )} + +
+
+
+
+ ); +} diff --git a/src/component/FileManager/ContextMenu.js b/src/component/FileManager/ContextMenu.js new file mode 100644 index 0000000..43291a6 --- /dev/null +++ b/src/component/FileManager/ContextMenu.js @@ -0,0 +1,754 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { isCompressFile, isPreviewable, isTorrent } from "../../config"; +import UploadIcon from "@material-ui/icons/CloudUpload"; +import DownloadIcon from "@material-ui/icons/CloudDownload"; +import NewFolderIcon from "@material-ui/icons/CreateNewFolder"; +import OpenFolderIcon from "@material-ui/icons/FolderOpen"; +import FileCopyIcon from "@material-ui/icons/FileCopy"; +import ShareIcon from "@material-ui/icons/Share"; +import RenameIcon from "@material-ui/icons/BorderColor"; +import MoveIcon from "@material-ui/icons/Input"; +import LinkIcon from "@material-ui/icons/InsertLink"; +import DeleteIcon from "@material-ui/icons/Delete"; +import OpenIcon from "@material-ui/icons/OpenInNew"; +import { + FolderDownload, + FilePlus, + FolderUpload, + MagnetOn, + Transfer, +} from "mdi-material-ui"; +import { + Divider, + ListItemIcon, + MenuItem, + Typography, + withStyles, +} from "@material-ui/core"; +import pathHelper from "../../utils/page"; +import { withRouter } from "react-router-dom"; +import Auth from "../../middleware/Auth"; +import { Archive, InfoOutlined, Unarchive } from "@material-ui/icons"; +import Menu from "@material-ui/core/Menu"; +import RefreshIcon from "@material-ui/icons/Refresh"; +import { + batchGetSource, + openParentFolder, + openPreview, + openTorrentDownload, + setSelectedTarget, + startBatchDownload, + startDirectoryDownload, + startDownload, + toggleObjectInfoSidebar, +} from "../../redux/explorer/action"; +import { + changeContextMenu, + navigateTo, + openCompressDialog, + openCopyDialog, + openCreateFileDialog, + openCreateFolderDialog, + openDecompressDialog, + openLoadingDialog, + openMoveDialog, + openMusicDialog, + openRelocateDialog, + openRemoteDownloadDialog, + openRemoveDialog, + openRenameDialog, + openShareDialog, + refreshFileList, + setNavigatorLoadingStatus, + showImgPreivew, + toggleSnackbar, +} from "../../redux/explorer"; +import { pathJoin } from "../Uploader/core/utils"; +import { + openFileSelector, + openFolderSelector, +} from "../../redux/viewUpdate/action"; +import { withTranslation } from "react-i18next"; + +const styles = () => ({ + propover: {}, + divider: { + marginTop: 4, + marginBottom: 4, + }, +}); + +const StyledListItemIcon = withStyles({ + root: { + minWidth: 38, + }, +})(ListItemIcon); + +const mapStateToProps = (state) => { + return { + menuType: state.viewUpdate.contextType, + menuOpen: state.viewUpdate.contextOpen, + isMultiple: state.explorer.selectProps.isMultiple, + withFolder: state.explorer.selectProps.withFolder, + withFile: state.explorer.selectProps.withFile, + withSourceEnabled: state.explorer.selectProps.withSourceEnabled, + path: state.navigator.path, + selected: state.explorer.selected, + search: state.explorer.search, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + changeContextMenu: (type, open) => { + dispatch(changeContextMenu(type, open)); + }, + setNavigatorLoadingStatus: (status) => { + dispatch(setNavigatorLoadingStatus(status)); + }, + setSelectedTarget: (targets) => { + dispatch(setSelectedTarget(targets)); + }, + navigateTo: (path) => { + dispatch(navigateTo(path)); + }, + openCreateFolderDialog: () => { + dispatch(openCreateFolderDialog()); + }, + openCreateFileDialog: () => { + dispatch(openCreateFileDialog()); + }, + openRenameDialog: () => { + dispatch(openRenameDialog()); + }, + openMoveDialog: () => { + dispatch(openMoveDialog()); + }, + openRemoveDialog: () => { + dispatch(openRemoveDialog()); + }, + openShareDialog: () => { + dispatch(openShareDialog()); + }, + showImgPreivew: (first) => { + dispatch(showImgPreivew(first)); + }, + openMusicDialog: () => { + dispatch(openMusicDialog()); + }, + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + openRemoteDownloadDialog: () => { + dispatch(openRemoteDownloadDialog()); + }, + openTorrentDownloadDialog: () => { + dispatch(openTorrentDownload()); + }, + openCopyDialog: () => { + dispatch(openCopyDialog()); + }, + openLoadingDialog: (text) => { + dispatch(openLoadingDialog(text)); + }, + openDecompressDialog: () => { + dispatch(openDecompressDialog()); + }, + openCompressDialog: () => { + dispatch(openCompressDialog()); + }, + refreshFileList: () => { + dispatch(refreshFileList()); + }, + openRelocateDialog: () => { + dispatch(openRelocateDialog()); + }, + openPreview: (share) => { + dispatch(openPreview(share)); + }, + toggleObjectInfoSidebar: (open) => { + dispatch(toggleObjectInfoSidebar(open)); + }, + startBatchDownload: (share) => { + dispatch(startBatchDownload(share)); + }, + openFileSelector: () => { + dispatch(openFileSelector()); + }, + openFolderSelector: () => { + dispatch(openFolderSelector()); + }, + startDownload: (share, file) => { + dispatch(startDownload(share, file)); + }, + batchGetSource: () => { + dispatch(batchGetSource()); + }, + startDirectoryDownload: (share) => { + dispatch(startDirectoryDownload(share)); + }, + openParentFolder: () => { + dispatch(openParentFolder()); + }, + }; +}; + +class ContextMenuCompoment extends Component { + X = 0; + Y = 0; + + state = {}; + + componentDidMount = () => { + window.document.addEventListener("mousemove", this.setPoint); + }; + + setPoint = (e) => { + this.Y = e.clientY; + this.X = e.clientX; + }; + + openArchiveDownload = () => { + this.props.startBatchDownload(this.props.share); + }; + + openDirectoryDownload = () => { + this.props.startDirectoryDownload(this.props.share); + }; + + openDownload = () => { + this.props.startDownload(this.props.share, this.props.selected[0]); + }; + + enterFolder = () => { + this.props.navigateTo( + pathJoin([this.props.path, this.props.selected[0].name]) + ); + }; + + // 暂时只对空白处右键菜单使用这个函数,疑似有bug会导致的一个菜单被默认选中。 + // 相关issue: https://github.com/mui-org/material-ui/issues/23747 + renderMenuItems = (items) => { + const res = []; + let key = 0; + + ["top", "center", "bottom"].forEach((position) => { + let visibleCount = 0; + items[position].forEach((item) => { + if (item.condition) { + res.push( + + {item.icon} + + {item.text} + + + ); + key++; + visibleCount++; + } + }); + if (visibleCount > 0 && position != "bottom") { + res.push( + + ); + key++; + } + }); + + return res; + }; + + render() { + const { classes, t } = this.props; + const user = Auth.GetUser(); + const isHomePage = pathHelper.isHomePage(this.props.location.pathname); + const emptyMenuList = { + top: [ + { + condition: true, + onClick: () => { + this.props.refreshFileList(); + this.props.changeContextMenu( + this.props.menuType, + false + ); + }, + icon: , + text: "刷新", + }, + ], + center: [ + { + condition: true, + onClick: () => this.props.openFileSelector(), + icon: , + text: "上传文件", + }, + { + condition: true, + onClick: () => this.props.openFolderSelector(), + icon: , + text: "上传目录", + }, + { + condition: user.group.allowRemoteDownload, + onClick: () => this.props.openRemoteDownloadDialog(), + icon: , + text: "离线下载", + }, + ], + bottom: [ + { + condition: true, + onClick: () => this.props.openCreateFolderDialog(), + icon: , + text: "创建文件夹", + }, + { + condition: true, + onClick: () => this.props.openCreateFileDialog(), + icon: , + text: "创建文件", + }, + ], + }; + + return ( +
+ + this.props.changeContextMenu(this.props.menuType, false) + } + anchorReference="anchorPosition" + anchorPosition={{ top: this.Y, left: this.X }} + anchorOrigin={{ + vertical: "top", + horizontal: "left", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + > + {this.props.menuType === "empty" && ( +
+ { + this.props.refreshFileList(); + this.props.changeContextMenu( + this.props.menuType, + false + ); + }} + > + + + + + {t("fileManager.refresh")} + + + + this.props.openFileSelector()} + > + + + + + {t("fileManager.uploadFiles")} + + + this.props.openFolderSelector()} + > + + + + + {t("fileManager.uploadFolder")} + + + {user.group.allowRemoteDownload && ( + + this.props.openRemoteDownloadDialog() + } + > + + + + + {t("fileManager.newRemoteDownloads")} + + + )} + + + + this.props.openCreateFolderDialog() + } + > + + + + + {t("fileManager.newFolder")} + + + + this.props.openCreateFileDialog() + } + > + + + + + {t("fileManager.newFile")} + + +
+ )} + {this.props.menuType !== "empty" && ( +
+ {!this.props.isMultiple && this.props.withFolder && ( +
+ + + + + + {t("fileManager.enter")} + + + {isHomePage && ( + + )} +
+ )} + {!this.props.isMultiple && + this.props.withFile && + (!this.props.share || + this.props.share.preview) && + isPreviewable(this.props.selected[0].name) && ( +
+ + this.props.openPreview() + } + > + + + + + {t("fileManager.open")} + + +
+ )} + + {this.props.search && !this.props.isMultiple && ( +
+ + this.props.openParentFolder() + } + > + + + + + {t("fileManager.openParentFolder")} + + +
+ )} + + {!this.props.isMultiple && this.props.withFile && ( +
+ + this.openDownload(this.props.share) + } + > + + + + + {t("fileManager.download")} + + + {isHomePage && ( + + )} +
+ )} + + {(this.props.isMultiple || this.props.withFolder) && + window.showDirectoryPicker && + window.isSecureContext && ( + + this.openDirectoryDownload() + } + > + + + + + {t("fileManager.download")} + + + )} + + {(this.props.isMultiple || + this.props.withFolder) && ( + this.openArchiveDownload()} + > + + + + + {t("fileManager.batchDownload")} + + + )} + + {isHomePage && + user.group.sourceBatch > 0 && + this.props.withSourceEnabled && ( + + this.props.batchGetSource() + } + > + + + + + {this.props.isMultiple || + (this.props.withFolder && + !this.props.withFile) + ? t( + "fileManager.getSourceLinkInBatch" + ) + : t( + "fileManager.getSourceLink" + )} + + + )} + + {!this.props.isMultiple && + isHomePage && + user.group.allowRemoteDownload && + this.props.withFile && + isTorrent(this.props.selected[0].name) && ( + + this.props.openTorrentDownloadDialog() + } + > + + + + + {t( + "fileManager.createRemoteDownloadForTorrent" + )} + + + )} + {!this.props.isMultiple && + isHomePage && + user.group.compress && + this.props.withFile && + isCompressFile(this.props.selected[0].name) && ( + + this.props.openDecompressDialog() + } + > + + + + + {t("fileManager.decompress")} + + + )} + + {isHomePage && user.group.compress && ( + + this.props.openCompressDialog() + } + > + + + + + {t("fileManager.compress")} + + + )} + + {isHomePage && user.group.relocate && ( + + this.props.openRelocateDialog() + } + > + + + + + {t("vas.migrateStoragePolicy")} + + + )} + + {!this.props.isMultiple && isHomePage && ( + this.props.openShareDialog()} + > + + + + + {t("fileManager.createShareLink")} + + + )} + + {!this.props.isMultiple && isHomePage && ( + + this.props.toggleObjectInfoSidebar(true) + } + > + + + + + {t("fileManager.viewDetails")} + + + )} + + {!this.props.isMultiple && isHomePage && ( + + )} + + {!this.props.isMultiple && isHomePage && ( +
+ + this.props.openRenameDialog() + } + > + + + + + {t("fileManager.rename")} + + + {!this.props.search && ( + + this.props.openCopyDialog() + } + > + + + + + {t("fileManager.copy")} + + + )} +
+ )} + {isHomePage && ( +
+ {!this.props.search && ( + + this.props.openMoveDialog() + } + > + + + + + {t("fileManager.move")} + + + )} + + + + this.props.openRemoveDialog() + } + > + + + + + {t("fileManager.delete")} + + +
+ )} +
+ )} +
+
+ ); + } +} + +ContextMenuCompoment.propTypes = { + classes: PropTypes.object.isRequired, + menuType: PropTypes.string.isRequired, +}; + +const ContextMenu = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(ContextMenuCompoment)))); + +export default ContextMenu; diff --git a/src/component/FileManager/DnD/DragLayer.js b/src/component/FileManager/DnD/DragLayer.js new file mode 100644 index 0000000..6b7ee6c --- /dev/null +++ b/src/component/FileManager/DnD/DragLayer.js @@ -0,0 +1,85 @@ +import React, { useMemo } from "react"; +import { useDragLayer } from "react-dnd"; +import Preview from "./Preview"; +import { useSelector } from "react-redux"; + +const layerStyles = { + position: "fixed", + pointerEvents: "none", + zIndex: 100, + left: 0, + top: 0, + width: "100%", + height: "100%", +}; + +function getItemStyles( + initialOffset, + currentOffset, + pointerOffset, + viewMethod +) { + if (!initialOffset || !currentOffset) { + return { + display: "none", + }; + } + let { x, y } = currentOffset; + if (viewMethod === "list") { + x += pointerOffset.x - initialOffset.x; + y += pointerOffset.y - initialOffset.y; + } + + const transform = `translate(${x}px, ${y}px)`; + return { + opacity: 0.5, + transform, + WebkitTransform: transform, + }; +} +const CustomDragLayer = (props) => { + const { + itemType, + isDragging, + item, + initialOffset, + currentOffset, + pointerOffset, + } = useDragLayer((monitor) => ({ + item: monitor.getItem(), + itemType: monitor.getItemType(), + initialOffset: monitor.getInitialSourceClientOffset(), + currentOffset: monitor.getSourceClientOffset(), + pointerOffset: monitor.getInitialClientOffset(), + isDragging: monitor.isDragging(), + })); + const viewMethod = useSelector( + (state) => state.viewUpdate.explorerViewMethod + ); + const image = useMemo(() => { + switch (itemType) { + case "object": + return ; + default: + return null; + } + }, [itemType, item]); + if (!isDragging) { + return null; + } + return ( +
+
+ {image} +
+
+ ); +}; +export default CustomDragLayer; diff --git a/src/component/FileManager/DnD/DropWarpper.js b/src/component/FileManager/DnD/DropWarpper.js new file mode 100644 index 0000000..2833ffb --- /dev/null +++ b/src/component/FileManager/DnD/DropWarpper.js @@ -0,0 +1,49 @@ +import React from "react"; +import { useDrop } from "react-dnd"; +import Folder from "../Folder"; +import classNames from "classnames"; +import TableItem from "../TableRow"; +export default function FolderDropWarpper({ + isListView, + folder, + onIconClick, + contextMenu, + handleClick, + handleDoubleClick, + className, + pref, +}) { + const [{ canDrop, isOver }, drop] = useDrop({ + accept: "object", + drop: () => ({ folder }), + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + const isActive = canDrop && isOver; + if (!isListView) { + return ( +
+ +
+ ); + } + return ( + + ); +} diff --git a/src/component/FileManager/DnD/Preview.js b/src/component/FileManager/DnD/Preview.js new file mode 100644 index 0000000..93d0370 --- /dev/null +++ b/src/component/FileManager/DnD/Preview.js @@ -0,0 +1,74 @@ +import React from "react"; +import SmallIcon from "../SmallIcon"; +import FileIcon from "../FileIcon"; +import { useSelector } from "react-redux"; +import { makeStyles } from "@material-ui/core"; +import Folder from "../Folder"; + +const useStyles = makeStyles(() => ({ + dragging: { + width: "200px", + }, + cardDragged: { + position: "absolute", + "transform-origin": "bottom left", + }, +})); + +const diliverIcon = (object, viewMethod, classes) => { + if (object.type === "dir") { + return ( +
+ +
+ ); + } + if (object.type === "file" && viewMethod === "icon") { + return ( +
+ +
+ ); + } + if ( + (object.type === "file" && viewMethod === "smallIcon") || + viewMethod === "list" + ) { + return ( +
+ +
+ ); + } +}; + +const Preview = (props) => { + const selected = useSelector((state) => state.explorer.selected); + const viewMethod = useSelector( + (state) => state.viewUpdate.explorerViewMethod + ); + const classes = useStyles(); + return ( + <> + {selected.length === 0 && + diliverIcon(props.object, viewMethod, classes)} + {selected.length > 0 && ( + <> + {selected.slice(0, 3).map((card, i) => ( +
+ {diliverIcon(card, viewMethod, classes)} +
+ ))} + + )} + + ); +}; +export default Preview; diff --git a/src/component/FileManager/DnD/Scrolling.js b/src/component/FileManager/DnD/Scrolling.js new file mode 100644 index 0000000..8c0d030 --- /dev/null +++ b/src/component/FileManager/DnD/Scrolling.js @@ -0,0 +1,63 @@ +import { useRef } from "react"; +import { throttle } from "lodash"; + +const useDragScrolling = () => { + const isScrolling = useRef(false); + const target = document.querySelector("#explorer-container"); + + const goDown = () => { + target.scrollTop += 10; + + const { offsetHeight, scrollTop, scrollHeight } = target; + const isScrollEnd = offsetHeight + scrollTop >= scrollHeight; + + if (isScrolling.current && !isScrollEnd) { + window.requestAnimationFrame(goDown); + } + }; + + const goUp = () => { + target.scrollTop -= 10; + + if (isScrolling.current && target.scrollTop > 0) { + window.requestAnimationFrame(goUp); + } + }; + + const onDragOver = (event) => { + const isMouseOnTop = event.clientY < 100; + const isMouseOnDown = window.innerHeight - event.clientY < 100; + + if (!isScrolling.current && (isMouseOnTop || isMouseOnDown)) { + isScrolling.current = true; + + if (isMouseOnTop) { + window.requestAnimationFrame(goUp); + } + + if (isMouseOnDown) { + window.requestAnimationFrame(goDown); + } + } else if (!isMouseOnTop && !isMouseOnDown) { + isScrolling.current = false; + } + }; + + const throttleOnDragOver = throttle(onDragOver, 300); + + const addEventListenerForWindow = () => { + window.addEventListener("dragover", throttleOnDragOver, false); + }; + + const removeEventListenerForWindow = () => { + window.removeEventListener("dragover", throttleOnDragOver, false); + isScrolling.current = false; + }; + + return { + addEventListenerForWindow, + removeEventListenerForWindow, + }; +}; + +export default useDragScrolling; diff --git a/src/component/FileManager/Explorer.js b/src/component/FileManager/Explorer.js new file mode 100644 index 0000000..240165f --- /dev/null +++ b/src/component/FileManager/Explorer.js @@ -0,0 +1,477 @@ +import React, { useCallback, useEffect, useMemo } from "react"; +import explorer, { + changeContextMenu, + openRemoveDialog, + setSelectedTarget, +} from "../../redux/explorer"; +import ObjectIcon from "./ObjectIcon"; +import ContextMenu from "./ContextMenu"; +import classNames from "classnames"; +import ImgPreivew from "./ImgPreview"; +import pathHelper from "../../utils/page"; +import { isMac } from "../../utils"; +import { + CircularProgress, + Grid, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from "@material-ui/core"; +import { configure, GlobalHotKeys } from "react-hotkeys"; +import TableSortLabel from "@material-ui/core/TableSortLabel"; +import Nothing from "../Placeholder/Nothing"; +import { useDispatch, useSelector } from "react-redux"; +import { useLocation } from "react-router"; +import { usePagination } from "../../hooks/pagination"; +import { makeStyles } from "@material-ui/core/styles"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + paper: { + padding: theme.spacing(2), + textAlign: "center", + color: theme.palette.text.secondary, + margin: "10px", + }, + root: { + padding: "10px", + [theme.breakpoints.up("sm")]: { + height: "calc(100vh - 113px)", + }, + }, + rootTable: { + padding: "0px", + backgroundColor: theme.palette.background.paper.white, + [theme.breakpoints.up("sm")]: { + height: "calc(100vh - 113px)", + }, + }, + typeHeader: { + margin: "10px 25px", + color: "#6b6b6b", + fontWeight: "500", + }, + loading: { + justifyContent: "center", + display: "flex", + marginTop: "40px", + }, + errorBox: { + padding: theme.spacing(4), + }, + errorMsg: { + marginTop: "10px", + }, + hideAuto: { + [theme.breakpoints.down("sm")]: { + display: "none", + }, + }, + flexFix: { + minWidth: 0, + }, + upButton: { + marginLeft: "20px", + marginTop: "10px", + marginBottom: "10px", + }, + clickAway: { + height: "100%", + width: "100%", + }, + rootShare: { + height: "100%", + minHeight: 500, + }, + visuallyHidden: { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, + }, + gridContainer: { + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: + "repeat(auto-fill,minmax(180px,1fr))!important", + }, + [theme.breakpoints.up("md")]: { + gridTemplateColumns: + "repeat(auto-fill,minmax(220px,1fr))!important", + }, + display: "grid!important", + }, + gridItem: { + flex: "1 1 220px!important", + }, +})); + +const keyMap = { + DELETE_FILE: "del", + SELECT_ALL_SHOWED: `${isMac() ? "command" : "ctrl"}+a`, + SELECT_ALL: `${isMac() ? "command" : "ctrl"}+shift+a`, + DESELECT_ALL: "esc", +}; + +export default function Explorer({ share }) { + const { t } = useTranslation("application", { keyPrefix: "fileManager" }); + const location = useLocation(); + const dispatch = useDispatch(); + const selected = useSelector((state) => state.explorer.selected); + const search = useSelector((state) => state.explorer.search); + const loading = useSelector((state) => state.viewUpdate.navigatorLoading); + const path = useSelector((state) => state.navigator.path); + const sortMethod = useSelector((state) => state.viewUpdate.sortMethod); + const navigatorErrorMsg = useSelector( + (state) => state.viewUpdate.navigatorErrorMsg + ); + const navigatorError = useSelector( + (state) => state.viewUpdate.navigatorError + ); + const viewMethod = useSelector( + (state) => state.viewUpdate.explorerViewMethod + ); + + const OpenRemoveDialog = useCallback(() => dispatch(openRemoveDialog()), [ + dispatch, + ]); + const SetSelectedTarget = useCallback( + (targets) => dispatch(setSelectedTarget(targets)), + [dispatch] + ); + const ChangeContextMenu = useCallback( + (type, open) => dispatch(changeContextMenu(type, open)), + [dispatch] + ); + const ChangeSortMethod = useCallback( + (method) => dispatch(explorer.actions.changeSortMethod(method)), + [dispatch] + ); + const SelectAll = useCallback( + () => dispatch(explorer.actions.selectAll()), + [dispatch] + ); + + const { dirList, fileList, startIndex } = usePagination(); + + const handlers = { + DELETE_FILE: () => { + if (selected.length > 0 && !share) { + OpenRemoveDialog(); + } + }, + SELECT_ALL_SHOWED: (e) => { + e.preventDefault(); + if (selected.length >= dirList.length + fileList.length) { + SetSelectedTarget([]); + } else { + SetSelectedTarget([...dirList, ...fileList]); + } + }, + SELECT_ALL: (e) => { + e.preventDefault(); + SelectAll(); + }, + DESELECT_ALL: (e) => { + e.preventDefault(); + SetSelectedTarget([]); + }, + }; + + useEffect( + () => + configure({ + ignoreTags: ["input", "select", "textarea"], + }), + [] + ); + + const contextMenu = (e) => { + e.preventDefault(); + if (!search && !pathHelper.isSharePage(location.pathname)) { + if (!loading) { + ChangeContextMenu("empty", true); + } + } + }; + + const ClickAway = (e) => { + const element = e.target; + if (element.dataset.clickaway) { + SetSelectedTarget([]); + } + }; + + const classes = useStyles(); + const isHomePage = pathHelper.isHomePage(location.pathname); + + const showView = + !loading && (dirList.length !== 0 || fileList.length !== 0); + + const listView = useMemo( + () => ( + + + + + { + ChangeSortMethod( + sortMethod === "namePos" + ? "nameRev" + : "namePos" + ); + }} + > + {t("name")} + {sortMethod === "namePos" || + sortMethod === "nameRev" ? ( + + {sortMethod === "nameRev" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + { + ChangeSortMethod( + sortMethod === "sizePos" + ? "sizeRes" + : "sizePos" + ); + }} + > + {t("size")} + {sortMethod === "sizePos" || + sortMethod === "sizeRes" ? ( + + {sortMethod === "sizeRes" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + { + ChangeSortMethod( + sortMethod === "modifyTimePos" + ? "modifyTimeRev" + : "modifyTimePos" + ); + }} + > + {t("lastModified")} + {sortMethod === "modifyTimePos" || + sortMethod === "modifyTimeRev" ? ( + + {sortMethod === "sizeRes" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + + + + {pathHelper.isMobile() && path !== "/" && ( + + )} + {dirList.map((value, index) => ( + + ))} + {fileList.map((value, index) => ( + + ))} + +
+ ), + [dirList, fileList, path, sortMethod, ChangeSortMethod, classes] + ); + + const normalView = useMemo( + () => ( +
+ {dirList.length !== 0 && ( + <> + + {t("folders")} + + + {dirList.map((value, index) => ( + + + + ))} + + + )} + {fileList.length !== 0 && ( + <> + + {t("files")} + + + {fileList.map((value, index) => ( + + + + ))} + + + )} +
+ ), + [dirList, fileList, classes] + ); + + const view = viewMethod === "list" ? listView : normalView; + + return ( +
+ + + + {navigatorError && ( + + + {t("listError")} + + + {navigatorErrorMsg.message} + + + )} + + {loading && !navigatorError && ( +
+ +
+ )} + + {!search && + isHomePage && + dirList.length === 0 && + fileList.length === 0 && + !loading && + !navigatorError && ( + + )} + {((search && + dirList.length === 0 && + fileList.length === 0 && + !loading && + !navigatorError) || + (dirList.length === 0 && + fileList.length === 0 && + !loading && + !navigatorError && + !isHomePage)) && } + {showView && view} +
+ ); +} diff --git a/src/component/FileManager/FileIcon.js b/src/component/FileManager/FileIcon.js new file mode 100644 index 0000000..1219535 --- /dev/null +++ b/src/component/FileManager/FileIcon.js @@ -0,0 +1,301 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import classNames from "classnames"; +import { LazyLoadImage } from "react-lazy-load-image-component"; +import ContentLoader from "react-content-loader"; +import { baseURL } from "../../middleware/Api"; +import { + ButtonBase, + Divider, + fade, + Tooltip, + Typography, + withStyles, +} from "@material-ui/core"; +import TypeIcon from "./TypeIcon"; +import { withRouter } from "react-router"; +import pathHelper from "../../utils/page"; +import statusHelper from "../../utils/page"; +import CheckCircleRoundedIcon from "@material-ui/icons/CheckCircleRounded"; +import Grow from "@material-ui/core/Grow"; +import FileName from "./FileName"; + +const styles = (theme) => ({ + container: {}, + + selected: { + "&:hover": { + border: "1px solid #d0d0d0", + }, + backgroundColor: fade( + theme.palette.primary.main, + theme.palette.type === "dark" ? 0.3 : 0.18 + ), + }, + + notSelected: { + "&:hover": { + backgroundColor: theme.palette.background.default, + border: "1px solid #d0d0d0", + }, + backgroundColor: theme.palette.background.paper, + }, + + button: { + border: "1px solid " + theme.palette.divider, + width: "100%", + borderRadius: theme.shape.borderRadius, + boxSizing: "border-box", + transition: + "background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms", + alignItems: "initial", + display: "initial", + }, + folderNameSelected: { + color: + theme.palette.type === "dark" ? "#fff" : theme.palette.primary.dark, + fontWeight: "500", + }, + folderNameNotSelected: { + color: theme.palette.text.secondary, + }, + folderName: { + marginTop: "15px", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden", + marginRight: "20px", + }, + preview: { + overflow: "hidden", + height: "150px", + width: "100%", + borderRadius: "12px 12px 0 0", + backgroundColor: theme.palette.background.default, + }, + previewIcon: { + overflow: "hidden", + height: "149px", + width: "100%", + borderRadius: "12px 12px 0 0", + backgroundColor: theme.palette.background.paper, + paddingTop: "50px", + }, + iconBig: { + fontSize: 50, + }, + picPreview: { + objectFit: "cover", + width: "100%", + height: "100%", + }, + fileInfo: { + height: "50px", + display: "flex", + }, + icon: { + margin: "10px 10px 10px 16px", + height: "30px", + minWidth: "30px", + backgroundColor: theme.palette.background.paper, + borderRadius: "90%", + paddingTop: "3px", + color: theme.palette.text.secondary, + }, + hide: { + display: "none", + }, + loadingAnimation: { + borderRadius: "12px 12px 0 0", + height: "100%", + width: "100%", + }, + shareFix: { + marginLeft: "20px", + }, + checkIcon: { + color: theme.palette.primary.main, + }, + noDrag: { + userDrag: "none", + }, +}); + +const mapStateToProps = (state) => { + return { + path: state.navigator.path, + selected: state.explorer.selected, + shareInfo: state.viewUpdate.shareInfo, + }; +}; + +const mapDispatchToProps = () => { + return {}; +}; + +class FileIconCompoment extends Component { + static defaultProps = { + share: false, + }; + + state = { + loading: false, + showPicIcon: false, + }; + + shouldComponentUpdate(nextProps, nextState, nextContext) { + const isSelectedCurrent = + this.props.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + const isSelectedNext = + nextProps.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + if ( + nextProps.selected !== this.props.selected && + isSelectedCurrent === isSelectedNext + ) { + return false; + } + + return true; + } + + render() { + const { classes } = this.props; + const isSelected = + this.props.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + const isSharePage = pathHelper.isSharePage( + this.props.location.pathname + ); + const isMobile = statusHelper.isMobile(); + + return ( +
+ + {this.props.file.thumb && !this.state.showPicIcon && ( +
+ + this.setState({ loading: false }) + } + beforeLoad={() => + this.setState({ loading: true }) + } + onError={() => + this.setState({ showPicIcon: true }) + } + /> + + + +
+ )} + {(!this.props.file.thumb || this.state.showPicIcon) && ( +
+ +
+ )} + {(!this.props.file.thumb || this.state.showPicIcon) && ( + + )} +
+ {!this.props.share && ( +
+ {!isSelected && ( + + )} + {isSelected && ( + + + + )} +
+ )} + + + + + +
+
+
+ ); + } +} + +FileIconCompoment.propTypes = { + classes: PropTypes.object.isRequired, + file: PropTypes.object.isRequired, +}; + +const FileIcon = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(FileIconCompoment))); + +export default FileIcon; diff --git a/src/component/FileManager/FileManager.js b/src/component/FileManager/FileManager.js new file mode 100644 index 0000000..3b20a42 --- /dev/null +++ b/src/component/FileManager/FileManager.js @@ -0,0 +1,120 @@ +import React, { Component } from "react"; + +import Navigator from "./Navigator/Navigator"; +import { DndProvider } from "react-dnd"; +import HTML5Backend from "react-dnd-html5-backend"; +import DragLayer from "./DnD/DragLayer"; +import Explorer from "./Explorer"; +import Modals from "./Modals"; +import { connect } from "react-redux"; +import { changeSubTitle } from "../../redux/viewUpdate/action"; +import { withRouter } from "react-router-dom"; +import pathHelper from "../../utils/page"; +import SideDrawer from "./Sidebar/SideDrawer"; +import classNames from "classnames"; +//import { ImageLoader } from "@abslant/cd-image-loader"; +import { + closeAllModals, + navigateTo, + setSelectedTarget, + toggleSnackbar, +} from "../../redux/explorer"; +import PaginationFooter from "./Pagination"; +import withStyles from "@material-ui/core/styles/withStyles"; + +const styles = (theme) => ({ + root: { + display: "flex", + flexDirection: "column", + height: "calc(100vh - 64px)", + [theme.breakpoints.down("xs")]: { + height: "100%", + }, + }, + rootShare: { + display: "flex", + flexDirection: "column", + height: "100%", + minHeight: 500, + }, + explorer: { + display: "flex", + flexDirection: "column", + overflowY: "auto", + }, +}); + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = (dispatch) => { + return { + changeSubTitle: (text) => { + dispatch(changeSubTitle(text)); + }, + setSelectedTarget: (targets) => { + dispatch(setSelectedTarget(targets)); + }, + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + closeAllModals: () => { + dispatch(closeAllModals()); + }, + navigateTo: (path) => { + dispatch(navigateTo(path)); + }, + }; +}; + +class FileManager extends Component { + constructor(props) { + super(props); + this.image = React.createRef(); + } + + componentWillUnmount() { + this.props.setSelectedTarget([]); + this.props.closeAllModals(); + this.props.navigateTo("/"); + } + + componentDidMount() { + if (pathHelper.isHomePage(this.props.location.pathname)) { + this.props.changeSubTitle(null); + } + } + + render() { + const { classes } = this.props; + return ( +
+ + + +
+ + +
+ + +
+ +
+ ); + } +} + +FileManager.propTypes = {}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(FileManager))); diff --git a/src/component/FileManager/FileName.js b/src/component/FileManager/FileName.js new file mode 100644 index 0000000..81813f8 --- /dev/null +++ b/src/component/FileManager/FileName.js @@ -0,0 +1,28 @@ +import Highlighter from "react-highlight-words"; +import { trimPrefix } from "../Uploader/core/utils"; +import React from "react"; +import { useSelector } from "react-redux"; +import { makeStyles } from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + highlight: { + backgroundColor: theme.palette.warning.light, + }, +})); + +export default function FileName({ name }) { + const classes = useStyles(); + const search = useSelector((state) => state.explorer.search); + if (!search) { + return name; + } + + return ( + + ); +} diff --git a/src/component/FileManager/Folder.js b/src/component/FileManager/Folder.js new file mode 100644 index 0000000..d89ba5a --- /dev/null +++ b/src/component/FileManager/Folder.js @@ -0,0 +1,128 @@ +import React from "react"; +import FolderIcon from "@material-ui/icons/Folder"; +import classNames from "classnames"; +import { + ButtonBase, + fade, + makeStyles, + Tooltip, + Typography, +} from "@material-ui/core"; +import { useSelector } from "react-redux"; +import statusHelper from "../../utils/page"; +import CheckCircleRoundedIcon from "@material-ui/icons/CheckCircleRounded"; + +const useStyles = makeStyles((theme) => ({ + container: { + padding: "7px", + }, + + selected: { + "&:hover": { + border: "1px solid #d0d0d0", + }, + backgroundColor: fade( + theme.palette.primary.main, + theme.palette.type === "dark" ? 0.3 : 0.18 + ), + }, + + notSelected: { + "&:hover": { + backgroundColor: theme.palette.background.default, + border: "1px solid #d0d0d0", + }, + backgroundColor: theme.palette.background.paper, + }, + + button: { + height: "50px", + border: "1px solid " + theme.palette.divider, + width: "100%", + borderRadius: theme.shape.borderRadius, + boxSizing: "border-box", + transition: + "background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms", + display: "flex", + justifyContent: "left", + alignItems: "initial", + }, + icon: { + margin: "10px 10px 10px 16px", + height: "30px", + minWidth: "30px", + backgroundColor: theme.palette.background.paper, + borderRadius: "90%", + paddingTop: "3px", + color: theme.palette.text.secondary, + }, + folderNameSelected: { + color: + theme.palette.type === "dark" ? "#fff" : theme.palette.primary.dark, + fontWeight: "500", + }, + folderNameNotSelected: { + color: theme.palette.text.secondary, + }, + folderName: { + marginTop: "15px", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden", + marginRight: "20px", + }, + active: { + boxShadow: "0 0 0 2px " + theme.palette.primary.light, + }, + checkIcon: { + color: theme.palette.primary.main, + }, +})); + +export default function Folder({ folder, isActive, onIconClick }) { + const selected = useSelector((state) => state.explorer.selected); + const classes = useStyles(); + const isMobile = statusHelper.isMobile(); + const isSelected = + selected.findIndex((value) => { + return value === folder; + }) !== -1; + + return ( + +
+ {!isSelected && } + {isSelected && ( + + )} +
+ + + {folder.name} + + +
+ ); +} diff --git a/src/component/FileManager/ImgPreview.js b/src/component/FileManager/ImgPreview.js new file mode 100644 index 0000000..e367bc2 --- /dev/null +++ b/src/component/FileManager/ImgPreview.js @@ -0,0 +1,137 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { baseURL } from "../../middleware/Api"; +import { imgPreviewSuffix } from "../../config"; +import { withStyles } from "@material-ui/core"; +import pathHelper from "../../utils/page"; +import { withRouter } from "react-router"; +import { PhotoSlider } from "react-photo-view"; +import "react-photo-view/dist/index.css"; +import * as explorer from "../../redux/explorer/reducer"; +import { showImgPreivew } from "../../redux/explorer"; + +const styles = () => ({}); + +const mapStateToProps = (state) => { + return { + first: state.explorer.imgPreview.first, + other: state.explorer.imgPreview.other, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + showImgPreivew: (first) => { + dispatch(showImgPreivew(first)); + }, + }; +}; + +class ImagPreviewComponent extends Component { + state = { + items: [], + photoIndex: 0, + isOpen: false, + }; + + UNSAFE_componentWillReceiveProps = (nextProps) => { + const items = []; + let firstOne = 0; + if (nextProps.first.id !== "") { + if ( + pathHelper.isSharePage(this.props.location.pathname) && + !nextProps.first.path + ) { + const newImg = { + intro: nextProps.first.name, + src: baseURL + "/share/preview/" + nextProps.first.key, + }; + firstOne = 0; + items.push(newImg); + this.setState({ + photoIndex: firstOne, + items: items, + isOpen: true, + }); + return; + } + // eslint-disable-next-line + nextProps.other.map((value) => { + const fileType = value.name.split(".").pop().toLowerCase(); + if (imgPreviewSuffix.indexOf(fileType) !== -1) { + let src = ""; + if (pathHelper.isSharePage(this.props.location.pathname)) { + src = baseURL + "/share/preview/" + value.key; + src = + src + + "?path=" + + encodeURIComponent( + value.path === "/" + ? value.path + value.name + : value.path + "/" + value.name + ); + } else { + src = baseURL + "/file/preview/" + value.id; + } + const newImg = { + intro: value.name, + src: src, + }; + if ( + value.path === nextProps.first.path && + value.name === nextProps.first.name + ) { + firstOne = items.length; + } + items.push(newImg); + } + }); + this.setState({ + photoIndex: firstOne, + items: items, + isOpen: true, + }); + } + }; + + handleClose = () => { + this.props.showImgPreivew(explorer.initState.imgPreview.first); + this.setState({ + isOpen: false, + }); + }; + + render() { + const { photoIndex, isOpen, items } = this.state; + + return ( +
+ {isOpen && ( + this.handleClose()} + index={photoIndex} + onIndexChange={(n) => + this.setState({ + photoIndex: n, + }) + } + /> + )} +
+ ); + } +} + +ImagPreviewComponent.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const ImgPreivew = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(ImagPreviewComponent))); + +export default ImgPreivew; diff --git a/src/component/FileManager/Modals.js b/src/component/FileManager/Modals.js new file mode 100644 index 0000000..c640b21 --- /dev/null +++ b/src/component/FileManager/Modals.js @@ -0,0 +1,793 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import PathSelector from "./PathSelector"; +import API from "../../middleware/Api"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, + withStyles, +} from "@material-ui/core"; +import Loading from "../Modals/Loading"; +import CopyDialog from "../Modals/Copy"; +import DirectoryDownloadDialog from "../Modals/DirectoryDownload"; +import CreatShare from "../Modals/CreateShare"; +import { withRouter } from "react-router-dom"; +import PurchaseShareDialog from "../Modals/PurchaseShare"; +import DecompressDialog from "../Modals/Decompress"; +import CompressDialog from "../Modals/Compress"; +import RelocateDialog from "../Modals/Relocate"; +import { + closeAllModals, + openLoadingDialog, + refreshFileList, + refreshStorage, + setModalsLoading, + toggleSnackbar, +} from "../../redux/explorer"; +import OptionSelector from "../Modals/OptionSelector"; +import { Trans, withTranslation } from "react-i18next"; +import RemoteDownload from "../Modals/RemoteDownload"; +import Delete from "../Modals/Delete"; + +const styles = (theme) => ({ + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, + contentFix: { + padding: "10px 24px 0px 24px", + }, +}); + +const mapStateToProps = (state) => { + return { + path: state.navigator.path, + selected: state.explorer.selected, + modalsStatus: state.viewUpdate.modals, + modalsLoading: state.viewUpdate.modalsLoading, + dirList: state.explorer.dirList, + fileList: state.explorer.fileList, + dndSignale: state.explorer.dndSignal, + dndTarget: state.explorer.dndTarget, + dndSource: state.explorer.dndSource, + loading: state.viewUpdate.modals.loading, + loadingText: state.viewUpdate.modals.loadingText, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + closeAllModals: () => { + dispatch(closeAllModals()); + }, + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + setModalsLoading: (status) => { + dispatch(setModalsLoading(status)); + }, + refreshFileList: () => { + dispatch(refreshFileList()); + }, + refreshStorage: () => { + dispatch(refreshStorage()); + }, + openLoadingDialog: (text) => { + dispatch(openLoadingDialog(text)); + }, + }; +}; + +class ModalsCompoment extends Component { + state = { + newFolderName: "", + newFileName: "", + newName: "", + selectedPath: "", + selectedPathName: "", + secretShare: false, + sharePwd: "", + shareUrl: "", + purchaseCallback: null, + }; + + handleInputChange = (e) => { + this.setState({ + [e.target.id]: e.target.value, + }); + }; + + newNameSuffix = ""; + + UNSAFE_componentWillReceiveProps = (nextProps) => { + if (this.props.dndSignale !== nextProps.dndSignale) { + this.dragMove(nextProps.dndSource, nextProps.dndTarget); + return; + } + + if (this.props.modalsStatus.rename !== nextProps.modalsStatus.rename) { + const name = nextProps.selected[0].name; + this.setState({ + newName: name, + }); + return; + } + }; + + submitResave = (e) => { + e.preventDefault(); + this.props.setModalsLoading(true); + API.post("/share/save/" + window.shareKey, { + path: + this.state.selectedPath === "//" + ? "/" + : this.state.selectedPath, + }) + .then(() => { + this.onClose(); + this.props.toggleSnackbar( + "top", + "right", + this.props.t("vas.fileSaved"), + "success" + ); + this.props.refreshFileList(); + this.props.setModalsLoading(false); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.props.setModalsLoading(false); + }); + }; + + submitMove = (e) => { + if (e != null) { + e.preventDefault(); + } + this.props.setModalsLoading(true); + const dirs = [], + items = []; + // eslint-disable-next-line + this.props.selected.map((value) => { + if (value.type === "dir") { + dirs.push(value.id); + } else { + items.push(value.id); + } + }); + API.patch("/object", { + action: "move", + src_dir: this.props.selected[0].path, + src: { + dirs: dirs, + items: items, + }, + dst: this.DragSelectedPath + ? this.DragSelectedPath + : this.state.selectedPath === "//" + ? "/" + : this.state.selectedPath, + }) + .then(() => { + this.onClose(); + this.props.refreshFileList(); + this.props.setModalsLoading(false); + this.DragSelectedPath = ""; + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.props.setModalsLoading(false); + this.DragSelectedPath = ""; + }) + .then(() => { + this.props.closeAllModals(); + }); + }; + + dragMove = (source, target) => { + if (this.props.selected.length === 0) { + this.props.selected[0] = source; + } + let doMove = true; + + // eslint-disable-next-line + this.props.selected.map((value) => { + // 根据ID过滤 + if (value.id === target.id && value.type === target.type) { + doMove = false; + // eslint-disable-next-line + return; + } + // 根据路径过滤 + if ( + value.path === + target.path + (target.path === "/" ? "" : "/") + target.name + ) { + doMove = false; + // eslint-disable-next-line + return; + } + }); + if (doMove) { + this.DragSelectedPath = + target.path === "/" + ? target.path + target.name + : target.path + "/" + target.name; + this.props.openLoadingDialog(this.props.t("modals.processing")); + this.submitMove(); + } + }; + + submitRename = (e) => { + e.preventDefault(); + this.props.setModalsLoading(true); + const newName = this.state.newName; + + const src = { + dirs: [], + items: [], + }; + + if (this.props.selected[0].type === "dir") { + src.dirs[0] = this.props.selected[0].id; + } else { + src.items[0] = this.props.selected[0].id; + } + + // 检查重名 + if ( + this.props.dirList.findIndex((value) => { + return value.name === newName; + }) !== -1 || + this.props.fileList.findIndex((value) => { + return value.name === newName; + }) !== -1 + ) { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("modals.duplicatedObjectName"), + "warning" + ); + this.props.setModalsLoading(false); + } else { + API.post("/object/rename", { + action: "rename", + src: src, + new_name: newName, + }) + .then(() => { + this.onClose(); + this.props.refreshFileList(); + this.props.setModalsLoading(false); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.props.setModalsLoading(false); + }); + } + }; + + submitCreateNewFolder = (e) => { + e.preventDefault(); + this.props.setModalsLoading(true); + if ( + this.props.dirList.findIndex((value) => { + return value.name === this.state.newFolderName; + }) !== -1 + ) { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("modals.duplicatedFolderName"), + "warning" + ); + this.props.setModalsLoading(false); + } else { + API.put("/directory", { + path: + (this.props.path === "/" ? "" : this.props.path) + + "/" + + this.state.newFolderName, + }) + .then(() => { + this.onClose(); + this.props.refreshFileList(); + this.props.setModalsLoading(false); + }) + .catch((error) => { + this.props.setModalsLoading(false); + + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + } + //this.props.toggleSnackbar(); + }; + + submitCreateNewFile = (e) => { + e.preventDefault(); + this.props.setModalsLoading(true); + if ( + this.props.dirList.findIndex((value) => { + return value.name === this.state.newFileName; + }) !== -1 + ) { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("modals.duplicatedFolderName"), + "warning" + ); + this.props.setModalsLoading(false); + } else { + API.post("/file/create", { + path: + (this.props.path === "/" ? "" : this.props.path) + + "/" + + this.state.newFileName, + }) + .then(() => { + this.onClose(); + this.props.refreshFileList(); + this.props.setModalsLoading(false); + }) + .catch((error) => { + this.props.setModalsLoading(false); + + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + } + //this.props.toggleSnackbar(); + }; + + setMoveTarget = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + this.setState({ + selectedPath: path, + selectedPathName: folder.name, + }); + }; + + onClose = () => { + this.setState({ + newFolderName: "", + newFileName: "", + newName: "", + selectedPath: "", + selectedPathName: "", + secretShare: false, + sharePwd: "", + shareUrl: "", + }); + this.newNameSuffix = ""; + this.props.closeAllModals(); + }; + + handleChange = (name) => (event) => { + this.setState({ [name]: event.target.checked }); + }; + + copySource = () => { + if (navigator.clipboard) { + navigator.clipboard.writeText(this.props.modalsStatus.getSource); + this.props.toggleSnackbar( + "top", + "right", + this.props.t("modals.linkCopied"), + "info" + ); + } + }; + + render() { + const { classes, t } = this.props; + + return ( +
+ + + + + + {t("modals.getSourceLinkTitle")} + + + + + + + + + + + + + {t("fileManager.newFolder")} + + + +
+ this.handleInputChange(e)} + fullWidth + /> + +
+ + +
+ +
+
+
+ + + + {t("fileManager.newFile")} + + + +
+ this.handleInputChange(e)} + fullWidth + /> + +
+ + +
+ +
+
+
+ + + + {t("fileManager.rename")} + + + + ]} + /> + +
+ this.handleInputChange(e)} + fullWidth + /> + +
+ + +
+ +
+
+
+ + + + + {t("modals.moveToTitle")} + + + + {this.state.selectedPath !== "" && ( + + + ]} + /> + + + )} + + +
+ +
+
+
+ + + {t("modals.saveToTitle")} + + + + {this.state.selectedPath !== "" && ( + + + ]} + /> + + + )} + + +
+ +
+
+
+ + + + + + + +
+ ); + } +} + +ModalsCompoment.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const Modals = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(ModalsCompoment)))); + +export default Modals; diff --git a/src/component/FileManager/MusicPlayer.js b/src/component/FileManager/MusicPlayer.js new file mode 100644 index 0000000..fc7d638 --- /dev/null +++ b/src/component/FileManager/MusicPlayer.js @@ -0,0 +1,525 @@ +import { + Button, + Card, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + List, + Popover, + Slider, + withStyles, +} from "@material-ui/core"; +import IconButton from "@material-ui/core/IconButton"; +import ListItem from "@material-ui/core/ListItem"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; +import ListItemText from "@material-ui/core/ListItemText"; +import MusicNote from "@material-ui/icons/MusicNote"; +import PlayArrow from "@material-ui/icons/PlayArrow"; +import PlayNext from "@material-ui/icons/SkipNext"; +import PlayPrev from "@material-ui/icons/SkipPrevious"; +import Pause from "@material-ui/icons/Pause"; +import VolumeUp from '@material-ui/icons/VolumeUp'; +import VolumeDown from '@material-ui/icons/VolumeDown'; +import VolumeMute from '@material-ui/icons/VolumeMute'; +import VolumeOff from '@material-ui/icons/VolumeOff'; +import { Repeat, RepeatOne, Shuffle } from "@material-ui/icons"; +import PropTypes from "prop-types"; +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { withRouter } from "react-router"; +import { audioPreviewSuffix } from "../../config"; +import { baseURL } from "../../middleware/Api"; +import * as explorer from "../../redux/explorer/reducer"; +import pathHelper from "../../utils/page"; +import { + audioPreviewSetIsOpen, + audioPreviewSetPlaying, + showAudioPreview, +} from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; + +const styles = (theme) => ({ + list: { + //maxWidth: 360, + backgroundColor: theme.palette.background.paper, + position: "relative", + overflow: "auto", + maxHeight: 300, + }, + slider_root: { + "vertical-align": "middle", + }, + setvol: { + width: 200, + height: 28, + "line-height": "42px", + }, +}); + +const mapStateToProps = (state) => { + return { + first: state.explorer.audioPreview.first, + other: state.explorer.audioPreview.other, + isOpen: state.explorer.audioPreview.isOpen, + playingName: state.explorer.audioPreview.playingName, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + showAudioPreview: (first) => { + dispatch(showAudioPreview(first)); + }, + audioPreviewSetIsOpen: (first) => { + dispatch(audioPreviewSetIsOpen(first)); + }, + audioPreviewSetPlaying: (playingName, paused) => { + dispatch(audioPreviewSetPlaying(playingName, paused)); + }, + }; +}; + +class MusicPlayerComponent extends Component { + state = { + items: [], + currentIndex: 0, + //isOpen: false, + isPlay: false, + currentTime: 0, + duration: 0, + progressText: "00:00/00:00", + looptype: 0, + volume: 0.8, + openPropEl: null, + mute: false, + }; + myAudioRef = React.createRef(); + + UNSAFE_componentWillReceiveProps = (nextProps) => { + const items = []; + let firstOne = 0; + if (nextProps.first.id !== "") { + if ( + pathHelper.isSharePage(this.props.location.pathname) && + !nextProps.first.path + ) { + const newItem = { + intro: nextProps.first.name, + src: baseURL + "/share/preview/" + nextProps.first.key, + }; + firstOne = 0; + items.push(newItem); + this.setState({ + currentIndex: firstOne, + items: items, + //isOpen: true, + }); + this.props.audioPreviewSetIsOpen(true); + this.props.showAudioPreview( + explorer.initState.audioPreview.first + ); + return; + } + // eslint-disable-next-line + nextProps.other.map((value) => { + const fileType = value.name.split(".").pop().toLowerCase(); + if (audioPreviewSuffix.indexOf(fileType) !== -1) { + let src = ""; + if (pathHelper.isSharePage(this.props.location.pathname)) { + src = baseURL + "/share/preview/" + value.key; + src = + src + + "?path=" + + encodeURIComponent( + value.path === "/" + ? value.path + value.name + : value.path + "/" + value.name + ); + } else { + src = baseURL + "/file/preview/" + value.id; + } + const newItem = { + intro: value.name, + src: src, + }; + if ( + value.path === nextProps.first.path && + value.name === nextProps.first.name + ) { + firstOne = items.length; + } + items.push(newItem); + } + }); + this.setState({ + currentIndex: firstOne, + items: items, + //isOpen: true, + }); + this.props.audioPreviewSetIsOpen(true); + this.props.showAudioPreview(explorer.initState.audioPreview.first); + } + }; + + handleItemClick = (currentIndex) => () => { + this.setState({ + currentIndex: currentIndex, + }); + }; + + handleClose = () => { + /*this.setState({ + isOpen: false, + });*/ + this.setState({ + currentIndex: -1, + }); + this.pause(); + this.props.audioPreviewSetPlaying(null, false); + this.props.audioPreviewSetIsOpen(false); + }; + backgroundPlay = () => { + this.props.audioPreviewSetIsOpen(false); + }; + + componentDidMount() { + if (this.myAudioRef.current) { + this.bindEvents(this.myAudioRef.current); + } + } + componentDidUpdate() { + if (this.myAudioRef.current) { + this.bindEvents(this.myAudioRef.current); + } + } + componentWillUnmount() { + this.unbindEvents(this.myAudioRef.current); + } + + bindEvents = (ele) => { + if (ele) { + ele.addEventListener("canplay", this.readyPlay); + ele.addEventListener("ended", this.loopnext); + ele.addEventListener("timeupdate", this.timeUpdate); + } + }; + + unbindEvents = (ele) => { + if (ele) { + ele.removeEventListener("canplay", this.readyPlay); + ele.removeEventListener("ended", this.loopnext); + ele.removeEventListener("timeupdate", this.timeUpdate); + } + }; + + readyPlay = () => { + this.myAudioRef.current.volume = this.state.volume; + this.play(); + }; + + formatTime = (s) => { + if (isNaN(s)) return "00:00"; + const minute = Math.floor(s / 60); + const second = Math.floor(s % 60); + return ( + `${minute}`.padStart(2, "0") + ":" + `${second}`.padStart(2, "0") + ); + }; + + timeUpdate = () => { + const currentTime = Math.floor(this.myAudioRef.current.currentTime); //this.myAudioRef.current.currentTime;// + this.setState({ + currentTime: currentTime, + duration: this.myAudioRef.current.duration, + progressText: + this.formatTime(currentTime) + + "/" + + this.formatTime(this.myAudioRef.current.duration), + }); + }; + + play = () => { + this.myAudioRef.current.play(); + this.setState({ + isPlay: true + }); + this.props.audioPreviewSetPlaying( + this.state.items[this.state.currentIndex].intro, + false + ); + }; + + pause = () => { + if (this.myAudioRef.current) { + this.myAudioRef.current.pause(); + } + this.setState({ + isPlay: false + }) + this.props.audioPreviewSetPlaying( + this.state.items[this.state.currentIndex]?.intro, + true + ); + }; + + playOrPaues = () => { + if (this.state.isPlay) { + this.pause(); + } else { + this.play(); + } + }; + changeLoopType = () => { + let lt = this.state.looptype + 1; + if (lt >= 3) { + lt = 0; + } + this.setState({ + looptype: lt, + }); + }; + loopnext = () => { + let index = this.state.currentIndex; + if (this.state.looptype == 0) { + //all + index = index + 1; + if (index >= this.state.items.length) { + index = 0; + } + } else if (this.state.looptype == 1) { + //single + //index=index; + } else if (this.state.looptype == 2) { + //random + if (this.state.items.length <= 2) { + index = index + 1; + if (index >= this.state.items.length) { + index = 0; + } + } else { + while (index == this.state.currentIndex) { + index = Math.floor(Math.random() * this.state.items.length); + } + } + } + if (this.state.currentIndex == index) { + this.myAudioRef.current.currentTime = 0; + this.play(); + } + this.setState({ + currentIndex: index, + }); + }; + + prev = () => { + let index = this.state.currentIndex - 1; + if (index < 0) { + index = this.state.items.length - 1; + } + this.setState({ + currentIndex: index, + }); + }; + + next = () => { + let index = this.state.currentIndex + 1; + if (index >= this.state.items.length) { + index = 0; + } + this.setState({ + currentIndex: index, + }); + }; + + handleProgress = (e, newValue) => { + this.myAudioRef.current.currentTime = newValue; + }; + + render() { + const { currentIndex, items } = this.state; + const { isOpen, classes, t } = this.props; + return ( + + + {t("fileManager.musicPlayer")} + + + + {items.map((value, idx) => { + const labelId = `label-${value.intro}`; + return ( + + + {idx === currentIndex ? ( + + ) : ( + + )} + + + + ); + })} + + + ); + } +} + +MusicPlayerComponent.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const MusicPlayer = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(MusicPlayerComponent)))); + +export default MusicPlayer; diff --git a/src/component/FileManager/Navigator/DropDown.js b/src/component/FileManager/Navigator/DropDown.js new file mode 100644 index 0000000..288e050 --- /dev/null +++ b/src/component/FileManager/Navigator/DropDown.js @@ -0,0 +1,51 @@ +import React from "react"; +import DropDownItem from "./DropDownItem"; + +export default function DropDown(props) { + let timer; + let first = props.folders.length; + const status = []; + for (let index = 0; index < props.folders.length; index++) { + status[index] = false; + } + + const setActiveStatus = (id, value) => { + status[id] = value; + if (value) { + clearTimeout(timer); + } else { + let shouldClose = true; + status.forEach((element) => { + if (element) { + shouldClose = false; + } + }); + if (shouldClose) { + if (first <= 0) { + timer = setTimeout(() => { + props.onClose(); + }, 100); + } else { + first--; + } + } + } + console.log(status); + }; + + return ( + <> + {props.folders.map((folder, id) => ( + // eslint-disable-next-line react/jsx-key + + ))} + + ); +} diff --git a/src/component/FileManager/Navigator/DropDownItem.js b/src/component/FileManager/Navigator/DropDownItem.js new file mode 100644 index 0000000..6df0a10 --- /dev/null +++ b/src/component/FileManager/Navigator/DropDownItem.js @@ -0,0 +1,54 @@ +import React, { useEffect } from "react"; +import { makeStyles } from "@material-ui/core"; +import FolderIcon from "@material-ui/icons/Folder"; +import { MenuItem, ListItemIcon, ListItemText } from "@material-ui/core"; +import { useDrop } from "react-dnd"; +import classNames from "classnames"; + +const useStyles = makeStyles((theme) => ({ + active: { + border: "2px solid " + theme.palette.primary.light, + }, +})); + +export default function DropDownItem(props) { + const [{ canDrop, isOver }, drop] = useDrop({ + accept: "object", + drop: () => { + console.log({ + folder: { + id: -1, + path: props.path, + name: props.folder === "/" ? "" : props.folder, + }, + }); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + + const isActive = canDrop && isOver; + + useEffect(() => { + props.setActiveStatus(props.id, isActive); + // eslint-disable-next-line + }, [isActive]); + + const classes = useStyles(); + return ( + props.navigateTo(e, props.id)} + > + + + + + + ); +} diff --git a/src/component/FileManager/Navigator/Navigator.js b/src/component/FileManager/Navigator/Navigator.js new file mode 100644 index 0000000..80f6aa7 --- /dev/null +++ b/src/component/FileManager/Navigator/Navigator.js @@ -0,0 +1,500 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import RightIcon from "@material-ui/icons/KeyboardArrowRight"; +import ShareIcon from "@material-ui/icons/Share"; +import NewFolderIcon from "@material-ui/icons/CreateNewFolder"; +import RefreshIcon from "@material-ui/icons/Refresh"; +import explorer, { + drawerToggleAction, + navigateTo, + navigateUp, + openCompressDialog, + openCreateFileDialog, + openCreateFolderDialog, + openShareDialog, + refreshFileList, + setNavigatorError, + setNavigatorLoadingStatus, + setSelectedTarget, +} from "../../../redux/explorer"; +import { fixUrlHash, setGetParameter } from "../../../utils/index"; +import { + Divider, + ListItemIcon, + Menu, + MenuItem, + withStyles, +} from "@material-ui/core"; +import PathButton from "./PathButton"; +import DropDown from "./DropDown"; +import pathHelper from "../../../utils/page"; +import classNames from "classnames"; +import Auth from "../../../middleware/Auth"; +import { Archive } from "@material-ui/icons"; +import { FilePlus } from "mdi-material-ui"; +import SubActions from "./SubActions"; +import { setCurrentPolicy } from "../../../redux/explorer/action"; +import { list } from "../../../services/navigate"; +import { withTranslation } from "react-i18next"; + +const mapStateToProps = (state) => { + return { + path: state.navigator.path, + refresh: state.navigator.refresh, + drawerDesktopOpen: state.viewUpdate.open, + viewMethod: state.viewUpdate.explorerViewMethod, + search: state.explorer.search, + sortMethod: state.viewUpdate.sortMethod, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + navigateToPath: (path) => { + dispatch(navigateTo(path)); + }, + navigateUp: () => { + dispatch(navigateUp()); + }, + setNavigatorError: (status, msg) => { + dispatch(setNavigatorError(status, msg)); + }, + updateFileList: (list) => { + dispatch(explorer.actions.updateFileList(list)); + }, + setNavigatorLoadingStatus: (status) => { + dispatch(setNavigatorLoadingStatus(status)); + }, + refreshFileList: () => { + dispatch(refreshFileList()); + }, + setSelectedTarget: (target) => { + dispatch(setSelectedTarget(target)); + }, + openCreateFolderDialog: () => { + dispatch(openCreateFolderDialog()); + }, + openCreateFileDialog: () => { + dispatch(openCreateFileDialog()); + }, + openShareDialog: () => { + dispatch(openShareDialog()); + }, + handleDesktopToggle: (open) => { + dispatch(drawerToggleAction(open)); + }, + openCompressDialog: () => { + dispatch(openCompressDialog()); + }, + setCurrentPolicy: (policy) => { + dispatch(setCurrentPolicy(policy)); + }, + }; +}; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const styles = (theme) => ({ + container: { + [theme.breakpoints.down("xs")]: { + display: "none", + }, + backgroundColor: theme.palette.background.paper, + }, + navigatorContainer: { + display: "flex", + justifyContent: "space-between", + }, + nav: { + height: "48px", + padding: "5px 15px", + display: "flex", + }, + optionContainer: { + paddingTop: "6px", + marginRight: "10px", + }, + rightIcon: { + marginTop: "6px", + verticalAlign: "top", + color: "#868686", + }, + expandMore: { + color: "#8d8d8d", + }, + roundBorder: { + borderRadius: "4px 4px 0 0", + }, +}); + +class NavigatorComponent extends Component { + search = undefined; + currentID = 0; + + state = { + hidden: false, + hiddenFolders: [], + folders: [], + anchorEl: null, + hiddenMode: false, + anchorHidden: null, + }; + + constructor(props) { + super(props); + this.element = React.createRef(); + } + + componentDidMount = () => { + const url = new URL(fixUrlHash(window.location.href)); + const c = url.searchParams.get("path"); + this.renderPath(c === null ? "/" : c); + + if (!this.props.isShare) { + // 如果是在个人文件管理页,首次加载时打开侧边栏 + this.props.handleDesktopToggle(true); + } + + // 后退操作时重新导航 + window.onpopstate = () => { + const url = new URL(fixUrlHash(window.location.href)); + const c = url.searchParams.get("path"); + if (c !== null) { + this.props.navigateToPath(c); + } + }; + }; + + renderPath = (path = null) => { + this.props.setNavigatorError(false, null); + this.setState({ + folders: + path !== null + ? path.substr(1).split("/") + : this.props.path.substr(1).split("/"), + }); + const newPath = path !== null ? path : this.props.path; + list( + newPath, + this.props.share, + this.search ? this.search.keywords : "", + this.search ? this.search.searchPath : "" + ) + .then((response) => { + this.currentID = response.data.parent; + this.props.updateFileList(response.data.objects); + this.props.setNavigatorLoadingStatus(false); + if (!this.search) { + setGetParameter("path", encodeURIComponent(newPath)); + } + if (response.data.policy) { + this.props.setCurrentPolicy({ + id: response.data.policy.id, + name: response.data.policy.name, + type: response.data.policy.type, + maxSize: response.data.policy.max_size, + allowedSuffix: response.data.policy.file_type, + }); + } + }) + .catch((error) => { + this.props.setNavigatorError(true, error); + }); + + this.checkOverFlow(true); + }; + + redresh = (path) => { + this.props.setNavigatorLoadingStatus(true); + this.props.setNavigatorError(false, "error"); + this.renderPath(path); + }; + + UNSAFE_componentWillReceiveProps = (nextProps) => { + if (this.props.search !== nextProps.search) { + this.search = nextProps.search; + } + if (this.props.path !== nextProps.path) { + this.renderPath(nextProps.path); + } + if (this.props.refresh !== nextProps.refresh) { + this.redresh(nextProps.path); + } + }; + + componentWillUnmount() { + this.props.updateFileList([]); + } + + componentDidUpdate = (prevProps, prevStates) => { + if (this.state.folders !== prevStates.folders) { + this.checkOverFlow(true); + } + if (this.props.drawerDesktopOpen !== prevProps.drawerDesktopOpen) { + delay(500).then(() => this.checkOverFlow()); + } + }; + + checkOverFlow = (force) => { + if (this.overflowInitLock && !force) { + return; + } + if (this.element.current !== null) { + const hasOverflowingChildren = + this.element.current.offsetHeight < + this.element.current.scrollHeight || + this.element.current.offsetWidth < + this.element.current.scrollWidth; + if (hasOverflowingChildren) { + this.overflowInitLock = true; + this.setState({ hiddenMode: true }); + } + if (!hasOverflowingChildren && this.state.hiddenMode) { + this.setState({ hiddenMode: false }); + } + } + }; + + navigateTo = (event, id) => { + if (id === this.state.folders.length - 1) { + //最后一个路径 + this.setState({ anchorEl: event.currentTarget }); + } else if ( + id === -1 && + this.state.folders.length === 1 && + this.state.folders[0] === "" + ) { + this.props.refreshFileList(); + this.handleClose(); + } else if (id === -1) { + this.props.navigateToPath("/"); + this.handleClose(); + } else { + this.props.navigateToPath( + "/" + this.state.folders.slice(0, id + 1).join("/") + ); + this.handleClose(); + } + }; + + handleClose = () => { + this.setState({ anchorEl: null, anchorHidden: null, anchorSort: null }); + }; + + showHiddenPath = (e) => { + this.setState({ anchorHidden: e.currentTarget }); + }; + + performAction = (e) => { + this.handleClose(); + if (e === "refresh") { + this.redresh(); + return; + } + const presentPath = this.props.path.split("/"); + const newTarget = [ + { + id: this.currentID, + type: "dir", + name: presentPath.pop(), + path: presentPath.length === 1 ? "/" : presentPath.join("/"), + }, + ]; + //this.props.navitateUp(); + switch (e) { + case "share": + this.props.setSelectedTarget(newTarget); + this.props.openShareDialog(); + break; + case "newfolder": + this.props.openCreateFolderDialog(); + break; + case "compress": + this.props.setSelectedTarget(newTarget); + this.props.openCompressDialog(); + break; + case "newFile": + this.props.openCreateFileDialog(); + break; + default: + break; + } + }; + + render() { + const { classes, t } = this.props; + const isHomePage = pathHelper.isHomePage(this.props.location.pathname); + const user = Auth.GetUser(); + + const presentFolderMenu = ( + + this.performAction("refresh")}> + + + + {t("fileManager.refresh")} + + {!this.props.search && isHomePage && ( +
+ + this.performAction("share")}> + + + + {t("fileManager.share")} + + {user.group.compress && ( + this.performAction("compress")} + > + + + + {t("fileManager.compress")} + + )} + + this.performAction("newfolder")} + > + + + + {t("fileManager.newFolder")} + + + this.performAction("newFile")}> + + + + {t("fileManager.newFile")} + +
+ )} +
+ ); + + return ( +
+
+
+ + this.navigateTo(e, -1)} + /> + + + {this.state.hiddenMode && ( + + + + + + + + this.navigateTo( + e, + this.state.folders.length - 1 + ) + } + /> + {presentFolderMenu} + + )} + {!this.state.hiddenMode && + this.state.folders.map((folder, id, folders) => ( + + {folder !== "" && ( + + + this.navigateTo(e, id) + } + /> + {id === folders.length - 1 && + presentFolderMenu} + {id !== folders.length - 1 && ( + + )} + + )} + + ))} +
+
+ +
+
+ +
+ ); + } +} + +NavigatorComponent.propTypes = { + classes: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, +}; + +const Navigator = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(NavigatorComponent)))); + +export default Navigator; diff --git a/src/component/FileManager/Navigator/PathButton.js b/src/component/FileManager/Navigator/PathButton.js new file mode 100644 index 0000000..9998b47 --- /dev/null +++ b/src/component/FileManager/Navigator/PathButton.js @@ -0,0 +1,80 @@ +import React, { useEffect } from "react"; +import ExpandMore from "@material-ui/icons/ExpandMore"; +import { Button } from "@material-ui/core"; +import { makeStyles } from "@material-ui/core"; +import { useDrop } from "react-dnd"; +import classNames from "classnames"; +import MoreIcon from "@material-ui/icons/MoreHoriz"; + +const useStyles = makeStyles((theme) => ({ + expandMore: { + color: "#8d8d8d", + }, + active: { + boxShadow: "0 0 0 2px " + theme.palette.primary.light, + }, + button: { + textTransform: "none", + }, +})); + +export default function PathButton(props) { + const inputRef = React.useRef(null); + + const [{ canDrop, isOver }, drop] = useDrop({ + accept: "object", + drop: () => { + if (props.more) { + inputRef.current.click(); + } else { + return { + folder: { + id: -1, + path: props.path, + name: props.folder === "/" ? "" : props.folder, + }, + }; + } + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + + const isActive = canDrop && isOver; + + useEffect(() => { + if (props.more && isActive) { + inputRef.current.click(); + } + // eslint-disable-next-line + }, [isActive]); + + const classes = useStyles(); + return ( + + + + ); +} diff --git a/src/component/FileManager/Navigator/SubActions.js b/src/component/FileManager/Navigator/SubActions.js new file mode 100644 index 0000000..be5731e --- /dev/null +++ b/src/component/FileManager/Navigator/SubActions.js @@ -0,0 +1,238 @@ +import React, { useCallback, useState } from "react"; +import { IconButton, makeStyles, Menu, MenuItem } from "@material-ui/core"; +import ViewListIcon from "@material-ui/icons/ViewList"; +import ViewSmallIcon from "@material-ui/icons/ViewComfy"; +import ViewModuleIcon from "@material-ui/icons/ViewModule"; +import DownloadIcon from "@material-ui/icons/CloudDownload"; +import SaveIcon from "@material-ui/icons/Save"; +import ReportIcon from "@material-ui/icons/Report"; +import Avatar from "@material-ui/core/Avatar"; +import { useDispatch, useSelector } from "react-redux"; +import Auth from "../../../middleware/Auth"; +import { changeSortMethod, startBatchDownload } from "../../../redux/explorer/action"; +import { + changeViewMethod, + openResaveDialog, + setShareUserPopover, +} from "../../../redux/explorer"; +import { FormatPageBreak } from "mdi-material-ui"; +import pathHelper from "../../../utils/page"; +import { changePageSize } from "../../../redux/viewUpdate/action"; +import Report from "../../Modals/Report"; +import { useTranslation } from "react-i18next"; +import Sort from "../Sort"; + +const useStyles = makeStyles((theme) => ({ + sideButton: { + padding: "8px", + marginRight: "5px", + }, +})); + +// const sortOptions = [ +// "A-Z", +// "Z-A", +// "oldestUploaded", +// "newestUploaded", +// "oldestModified", +// "newestModified", +// "smallest", +// "largest", +// ]; + +const paginationOption = ["50", "100", "200", "500", "1000"]; + +export default function SubActions({ isSmall, inherit }) { + const { t } = useTranslation("application", { keyPrefix: "fileManager" }); + const { t: vasT } = useTranslation("application", { keyPrefix: "vas" }); + const dispatch = useDispatch(); + const viewMethod = useSelector( + (state) => state.viewUpdate.explorerViewMethod + ); + const share = useSelector((state) => state.viewUpdate.shareInfo); + const pageSize = useSelector((state) => state.viewUpdate.pagination.size); + const OpenLoadingDialog = useCallback( + (method) => dispatch(changeViewMethod(method)), + [dispatch] + ); + const ChangeSortMethod = useCallback( + (method) => dispatch(changeSortMethod(method)), + [dispatch] + ); + const OpenResaveDialog = useCallback( + (key) => dispatch(openResaveDialog(key)), + [dispatch] + ); + const SetShareUserPopover = useCallback( + (e) => dispatch(setShareUserPopover(e)), + [dispatch] + ); + const StartBatchDownloadAll = useCallback( + () => dispatch(startBatchDownload(share)), + [dispatch, share] + ); + const ChangePageSize = useCallback((e) => dispatch(changePageSize(e)), [ + dispatch, + ]); + // const [anchorSort, setAnchorSort] = useState(null); + const [anchorPagination, setAnchorPagination] = useState(null); + // const [selectedIndex, setSelectedIndex] = useState(0); + const [openReport, setOpenReport] = useState(false); + // const showSortOptions = (e) => { + // setAnchorSort(e.currentTarget); + // }; + const showPaginationOptions = (e) => { + setAnchorPagination(e.currentTarget); + }; + + /** change sort */ + const onChangeSort = (value) => { + ChangeSortMethod(value); + }; + const handlePaginationChange = (s) => { + ChangePageSize(s); + setAnchorPagination(null); + }; + + const toggleViewMethod = () => { + const newMethod = + viewMethod === "icon" + ? "list" + : viewMethod === "list" + ? "smallIcon" + : "icon"; + Auth.SetPreference("view_method", newMethod); + OpenLoadingDialog(newMethod); + }; + const isMobile = pathHelper.isMobile(); + + const classes = useStyles(); + return ( + <> + + + + + {viewMethod === "icon" && ( + + + + )} + {viewMethod === "list" && ( + + + + )} + + {viewMethod === "smallIcon" && ( + + + + )} + + {!isMobile && ( + + + + )} + setAnchorPagination(null)} + > + {paginationOption.map((option, index) => ( + handlePaginationChange(parseInt(option))} + > + {t("paginationOption", { option })} + + ))} + handlePaginationChange(-1)} + > + {t("noPagination")} + + + + + + {share && ( + <> + OpenResaveDialog(share.key)} + color={inherit ? "inherit" : "default"} + > + + + {!inherit && ( + <> + setOpenReport(true)} + > + + + setOpenReport(false)} + /> + + )} + + )} + {share && ( + SetShareUserPopover(e.currentTarget)} + style={{ padding: 5 }} + > + + + )} + + ); +} diff --git a/src/component/FileManager/ObjectIcon.js b/src/component/FileManager/ObjectIcon.js new file mode 100644 index 0000000..c9a1384 --- /dev/null +++ b/src/component/FileManager/ObjectIcon.js @@ -0,0 +1,261 @@ +import React, { useCallback, useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import statusHelper from "../../utils/page"; +import FileIcon from "./FileIcon"; +import SmallIcon from "./SmallIcon"; +import TableItem from "./TableRow"; +import classNames from "classnames"; +import { makeStyles } from "@material-ui/core"; +import { useDrag } from "react-dnd"; +import { getEmptyImage } from "react-dnd-html5-backend"; +import DropWarpper from "./DnD/DropWarpper"; +import { useLocation } from "react-router-dom"; +import { pathBack } from "../../utils"; +import { + changeContextMenu, + dragAndDrop, + navigateTo, + openLoadingDialog, + openPreview, + selectFile, + setSelectedTarget, + toggleSnackbar, +} from "../../redux/explorer"; +import useDragScrolling from "./DnD/Scrolling"; + +const useStyles = makeStyles(() => ({ + container: { + padding: "7px", + }, + fixFlex: { + minWidth: 0, + }, + dragging: { + opacity: 0.4, + }, +})); + +export default function ObjectIcon(props) { + const path = useSelector((state) => state.navigator.path); + const shareInfo = useSelector((state) => state.viewUpdate.shareInfo); + const selected = useSelector((state) => state.explorer.selected); + const viewMethod = useSelector( + (state) => state.viewUpdate.explorerViewMethod + ); + const navigatorPath = useSelector((state) => state.navigator.path); + const location = useLocation(); + + const dispatch = useDispatch(); + const ContextMenu = useCallback( + (type, open) => dispatch(changeContextMenu(type, open)), + [dispatch] + ); + const SetSelectedTarget = useCallback( + (targets) => dispatch(setSelectedTarget(targets)), + [dispatch] + ); + + const NavitateTo = useCallback((targets) => dispatch(navigateTo(targets)), [ + dispatch, + ]); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const DragAndDrop = useCallback( + (source, target) => dispatch(dragAndDrop(source, target)), + [dispatch] + ); + const OpenLoadingDialog = useCallback( + (text) => dispatch(openLoadingDialog(text)), + [dispatch] + ); + const OpenPreview = useCallback((share) => dispatch(openPreview(share)), [ + dispatch, + ]); + const StartDownload = useCallback( + (share, file) => dispatch(StartDownload(share, file)), + [dispatch] + ); + + const classes = useStyles(); + + const contextMenu = (e) => { + if (props.file.type === "up") { + return; + } + e.preventDefault(); + if ( + selected.findIndex((value) => { + return value === props.file; + }) === -1 + ) { + SetSelectedTarget([props.file]); + } + ContextMenu("file", true); + }; + + const SelectFile = (e) => { + dispatch(selectFile(props.file, e, props.index)); + }; + const enterFolder = () => { + NavitateTo( + path === "/" ? path + props.file.name : path + "/" + props.file.name + ); + }; + const handleClick = (e) => { + if (props.file.type === "up") { + NavitateTo(pathBack(navigatorPath)); + return; + } + + SelectFile(e); + if ( + props.file.type === "dir" && + !e.ctrlKey && + !e.metaKey && + !e.shiftKey + ) { + enterFolder(); + } + }; + + const handleDoubleClick = () => { + if (props.file.type === "up") { + return; + } + if (props.file.type === "dir") { + enterFolder(); + return; + } + + OpenPreview(shareInfo); + }; + + const handleIconClick = (e) => { + e.stopPropagation(); + if (!e.shiftKey) { + e.ctrlKey = true; + } + SelectFile(e); + return false; + }; + + const { + addEventListenerForWindow, + removeEventListenerForWindow, + } = useDragScrolling(); + + const [{ isDragging }, drag, preview] = useDrag({ + item: { + object: props.file, + type: "object", + selected: [...selected], + viewMethod: viewMethod, + }, + begin: () => { + addEventListenerForWindow(); + }, + end: (item, monitor) => { + removeEventListenerForWindow(); + const dropResult = monitor.getDropResult(); + if (item && dropResult) { + if (dropResult.folder) { + if ( + item.object.id !== dropResult.folder.id || + item.object.type !== dropResult.folder.type + ) { + DragAndDrop(item.object, dropResult.folder); + } + } + } + }, + canDrag: () => { + return ( + !statusHelper.isMobile() && + statusHelper.isHomePage(location.pathname) + ); + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }); + // eslint-disable-next-line + }, []); + + if (viewMethod === "list") { + return ( + <> + {props.file.type === "dir" && ( + + )} + {props.file.type !== "dir" && ( + + )} + + ); + } + + return ( +
+
+ {props.file.type === "dir" && viewMethod !== "list" && ( + + )} + {props.file.type === "file" && viewMethod === "icon" && ( + + )} + {props.file.type === "file" && viewMethod === "smallIcon" && ( + + )} +
+
+ ); +} diff --git a/src/component/FileManager/Pagination.js b/src/component/FileManager/Pagination.js new file mode 100644 index 0000000..b5adcf4 --- /dev/null +++ b/src/component/FileManager/Pagination.js @@ -0,0 +1,87 @@ +import React, { useCallback, useMemo } from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import { useDispatch, useSelector } from "react-redux"; +import { Pagination } from "@material-ui/lab"; +import CustomPaginationItem from "./PaginationItem"; +import { setPagination } from "../../redux/viewUpdate/action"; +import AutoHidden from "../Dial/AutoHidden"; +import statusHelper from "../../utils/page"; +import { useLocation } from "react-router-dom"; + +const useStyles = makeStyles((theme) => ({ + root: { + position: "fixed", + bottom: 23, + /* left: 8px; */ + background: theme.palette.background.paper, + borderRadius: 24, + boxShadow: + " 0px 3px 5px -1px rgb(0 0 0 / 20%), 0px 6px 10px 0px rgb(0 0 0 / 14%), 0px 1px 18px 0px rgb(0 0 0 / 12%)", + padding: "8px 4px 8px 4px", + marginLeft: 20, + }, + placeholder: { + marginTop: 80, + }, +})); + +export default function PaginationFooter() { + const classes = useStyles(); + const dispatch = useDispatch(); + const files = useSelector((state) => state.explorer.fileList); + const folders = useSelector((state) => state.explorer.dirList); + const pagination = useSelector((state) => state.viewUpdate.pagination); + const loading = useSelector((state) => state.viewUpdate.navigatorLoading); + const location = useLocation(); + + const SetPagination = useCallback((p) => dispatch(setPagination(p)), [ + dispatch, + ]); + + const handleChange = (event, value) => { + SetPagination({ ...pagination, page: value }); + }; + + const count = useMemo( + () => Math.ceil((files.length + folders.length) / pagination.size), + [files, folders, pagination.size] + ); + + const isMobile = statusHelper.isMobile(); + const isSharePage = statusHelper.isSharePage(location.pathname); + + if (count > 1 && !loading) { + return ( + <> + {!isMobile && !isSharePage && ( +
+ )} + +
+ ( + + )} + color="secondary" + count={count} + page={pagination.page} + onChange={handleChange} + /> +
+
+ + ); + } + return
; +} diff --git a/src/component/FileManager/PaginationItem.js b/src/component/FileManager/PaginationItem.js new file mode 100644 index 0000000..9b76a9a --- /dev/null +++ b/src/component/FileManager/PaginationItem.js @@ -0,0 +1,50 @@ +import React, { useEffect, useRef } from "react"; +import { useDrop } from "react-dnd"; +import { PaginationItem } from "@material-ui/lab"; + +export default function CustomPaginationItem(props) { + const inputRef = useRef(null); + + const [{ canDrop, isOver }, drop] = useDrop({ + accept: "object", + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + + const isActive = canDrop && isOver; + + useEffect(() => { + if ( + isActive && + props.onClick && + props.type !== "start-ellipsis" && + props.type !== "end-ellipsis" + ) { + console.log("ss"); + props.onClick(); + } + }, [isActive, inputRef]); + + if ( + props.isMobile && + (props.type === "start-ellipsis" || + props.type === "end-ellipsis" || + props.type === "page") + ) { + if (props.selected) { + return ( +
+ {props.page} / {props.count} +
+ ); + } + return <>; + } + return ( +
+ +
+ ); +} diff --git a/src/component/FileManager/PathSelector.js b/src/component/FileManager/PathSelector.js new file mode 100644 index 0000000..944cf23 --- /dev/null +++ b/src/component/FileManager/PathSelector.js @@ -0,0 +1,268 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import FolderIcon from "@material-ui/icons/Folder"; +import RightIcon from "@material-ui/icons/KeyboardArrowRight"; +import UpIcon from "@material-ui/icons/ArrowUpward"; +import { connect } from "react-redux"; +import classNames from "classnames"; + +import { + IconButton, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + MenuItem, + MenuList, + withStyles, +} from "@material-ui/core"; +import Sort, { sortMethodFuncs } from './Sort'; +import API from "../../middleware/Api"; +import { toggleSnackbar } from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; + +const mapStateToProps = (state) => { + return { + search: state.explorer.search, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +const styles = (theme) => ({ + iconWhite: { + color: theme.palette.common.white, + }, + selected: { + backgroundColor: theme.palette.primary.main + "!important", + "& $primary, & $icon": { + color: theme.palette.common.white, + }, + }, + primary: {}, + icon: {}, + buttonIcon: {}, + selector: { + minWidth: "300px", + }, + container: { + maxHeight: "330px", + overflowY: " auto", + }, + sortWrapper: { + textAlign: "right", + paddingRight: "30px", + }, + sortButton: { + padding: "0", + }, +}); + +class PathSelectorCompoment extends Component { + state = { + presentPath: "/", + sortBy: '', + dirList: [], + selectedTarget: null, + }; + /** + * the source dir list from api `/directory` + * + * `state.dirList` is a sorted copy of it + */ + sourceDirList = [] + + componentDidMount = () => { + const toBeLoad = this.props.presentPath; + this.enterFolder(!this.props.search ? toBeLoad : "/"); + }; + + back = () => { + const paths = this.state.presentPath.split("/"); + paths.pop(); + const toBeLoad = paths.join("/"); + this.enterFolder(toBeLoad === "" ? "/" : toBeLoad); + }; + + enterFolder = (toBeLoad) => { + API.get( + (this.props.api ? this.props.api : "/directory") + + encodeURIComponent(toBeLoad) + ) + .then((response) => { + const dirList = response.data.objects.filter((x) => { + return ( + x.type === "dir" && + this.props.selected.findIndex((value) => { + return ( + value.name === x.name && value.path === x.path + ); + }) === -1 + ); + }); + dirList.forEach((value) => { + value.displayName = value.name; + }); + this.sourceDirList = dirList + this.setState({ + presentPath: toBeLoad, + dirList: dirList, + selectedTarget: null, + }, this.updateDirList); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "warning" + ); + }); + }; + + handleSelect = (index) => { + this.setState({ selectedTarget: index }); + this.props.onSelect(this.state.dirList[index]); + }; + + + /** + * change sort type + * @param {Event} event + */ + onChangeSort = (sortBy) => { + this.setState({ sortBy }, this.updateDirList) + }; + + /** + * sort dir list, and handle parent dirs + */ + updateDirList = () => { + const { state, sourceDirList } = this + const { sortBy, presentPath } = state + + // copy + const dirList = [...sourceDirList] + // sort + const sortMethod = sortMethodFuncs[sortBy] + if (sortMethod) dirList.sort(sortMethod) + + // add root/parent dirs to top + if (presentPath === "/") { + dirList.unshift({ name: "/", path: "", displayName: "/" }); + } else { + let path = presentPath; + let name = presentPath; + const displayNames = ["fileManager.currentFolder", "fileManager.backToParentFolder"]; + for (let i = 0; i < 2; i++) { + const paths = path.split("/"); + name = paths.pop(); + name = name === "" ? "/" : name; + path = paths.join("/"); + dirList.unshift({ + name: name, + path: path, + displayName: this.props.t( + displayNames[i] + ), + }); + } + } + this.setState({ dirList }) + } + render() { + const { classes, t } = this.props; + + const showActionIcon = (index) => { + if (this.state.presentPath === "/") { + return index !== 0; + } + return index !== 1; + }; + + const actionIcon = (index) => { + if (this.state.presentPath === "/") { + return ; + } + + if (index === 0) { + return ; + } + return ; + }; + + return ( +
+
+ +
+ + {this.state.dirList.map((value, index) => ( + this.handleSelect(index)} + > + + + + + {showActionIcon(index) && ( + + + index === 0 + ? this.back() + : this.enterFolder( + value.path === "/" + ? value.path + + value.name + : value.path + + "/" + + value.name + ) + } + > + {actionIcon(index)} + + + )} + + ))} + +
+ ); + } +} + +PathSelectorCompoment.propTypes = { + classes: PropTypes.object.isRequired, + presentPath: PropTypes.string.isRequired, + selected: PropTypes.array.isRequired, +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withTranslation()(PathSelectorCompoment))); diff --git a/src/component/FileManager/Sidebar/SideDrawer.js b/src/component/FileManager/Sidebar/SideDrawer.js new file mode 100644 index 0000000..4dca4db --- /dev/null +++ b/src/component/FileManager/Sidebar/SideDrawer.js @@ -0,0 +1,339 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles } from "@material-ui/core"; +import { useDispatch, useSelector } from "react-redux"; +import Drawer from "@material-ui/core/Drawer"; +import Toolbar from "@material-ui/core/Toolbar"; +import { Clear, Folder } from "@material-ui/icons"; +import Divider from "@material-ui/core/Divider"; +import { setSideBar } from "../../../redux/explorer/action"; +import TypeIcon from "../TypeIcon"; +import Typography from "@material-ui/core/Typography"; +import IconButton from "@material-ui/core/IconButton"; +import Grid from "@material-ui/core/Grid"; +import API from "../../../middleware/Api"; +import { filename, sizeToString } from "../../../utils"; +import Link from "@material-ui/core/Link"; +import Tooltip from "@material-ui/core/Tooltip"; +import TimeAgo from "timeago-react"; +import ListLoading from "../../Placeholder/ListLoading"; +import Hidden from "@material-ui/core/Hidden"; +import Dialog from "@material-ui/core/Dialog"; +import Slide from "@material-ui/core/Slide"; +import AppBar from "@material-ui/core/AppBar"; +import { formatLocalTime } from "../../../utils/datetime"; +import { navigateTo, toggleSnackbar } from "../../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; + +const drawerWidth = 350; + +const useStyles = makeStyles((theme) => ({ + drawer: { + width: drawerWidth, + flexShrink: 0, + }, + drawerPaper: { + width: drawerWidth, + boxShadow: + "0px 8px 10px -5px rgb(0 0 0 / 20%), 0px 16px 24px 2px rgb(0 0 0 / 14%), 0px 6px 30px 5px rgb(0 0 0 / 12%)", + }, + drawerContainer: { + overflow: "auto", + }, + header: { + display: "flex", + padding: theme.spacing(3), + placeContent: "space-between", + }, + fileIcon: { width: 33, height: 33 }, + fileIconSVG: { fontSize: 20 }, + folderIcon: { + color: theme.palette.text.secondary, + width: 33, + height: 33, + }, + fileName: { + marginLeft: theme.spacing(2), + marginRight: theme.spacing(2), + wordBreak: "break-all", + flexGrow: 2, + }, + closeIcon: { + placeSelf: "flex-start", + marginTop: 2, + }, + propsContainer: { + padding: theme.spacing(3), + }, + propsLabel: { + color: theme.palette.text.secondary, + padding: theme.spacing(1), + }, + propsTime: { + color: theme.palette.text.disabled, + padding: theme.spacing(1), + }, + propsValue: { + padding: theme.spacing(1), + wordBreak: "break-all", + }, + appBar: { + position: "relative", + }, + title: { + marginLeft: theme.spacing(2), + flex: 1, + }, +})); + +const Transition = React.forwardRef(function Transition(props, ref) { + return ; +}); + +export default function SideDrawer() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const sideBarOpen = useSelector((state) => state.explorer.sideBarOpen); + const selected = useSelector((state) => state.explorer.selected); + const SetSideBar = useCallback((open) => dispatch(setSideBar(open)), [ + dispatch, + ]); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const NavigateTo = useCallback((k) => dispatch(navigateTo(k)), [dispatch]); + const search = useSelector((state) => state.explorer.search); + const [target, setTarget] = useState(null); + const [details, setDetails] = useState(null); + const loadProps = (object) => { + API.get( + "/object/property/" + + object.id + + "?trace_root=" + + (search ? "true" : "false") + + "&is_folder=" + + (object.type === "dir").toString() + ) + .then((response) => { + setDetails(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + setDetails(null); + if (sideBarOpen) { + if (selected.length !== 1) { + SetSideBar(false); + } else { + setTarget(selected[0]); + loadProps(selected[0]); + } + } + }, [selected, sideBarOpen]); + + const classes = useStyles(); + const propsItem = [ + { + label: t("fileManager.size"), + value: (d, target) => + sizeToString(d.size) + + t("fileManager.bytes", { bytes: d.size.toLocaleString() }), + show: (d) => true, + }, + { + label: t("fileManager.storagePolicy"), + value: (d, target) => d.policy, + show: (d) => d.type === "file", + }, + { + label: t("fileManager.storagePolicy"), + value: (d, target) => + d.policy === "" + ? t("fileManager.inheritedFromParent") + : d.policy, + show: (d) => d.type === "dir", + }, + { + label: t("fileManager.childFolders"), + value: (d, target) => + t("fileManager.childCount", { + num: d.child_folder_num.toLocaleString(), + }), + show: (d) => d.type === "dir", + }, + { + label: t("fileManager.childFiles"), + value: (d, target) => + t("fileManager.childCount", { + num: d.child_file_num.toLocaleString(), + }), + show: (d) => d.type === "dir", + }, + { + label: t("fileManager.parentFolder"), + // eslint-disable-next-line react/display-name + value: (d, target) => { + const path = d.path === "" ? target.path : d.path; + const name = filename(path); + return ( + + NavigateTo(path)} + > + {name === "" ? t("fileManager.rootFolder") : name} + + + ); + }, + show: (d) => true, + }, + { + label: t("fileManager.modifiedAt"), + value: (d, target) => formatLocalTime(d.updated_at), + show: (d) => true, + }, + { + label: t("fileManager.createdAt"), + value: (d) => formatLocalTime(d.created_at), + show: (d) => true, + }, + ]; + const content = ( + + {!details && } + {details && ( + <> + {propsItem.map((item) => { + if (item.show(target)) { + return ( + <> + + {item.label} + + + {item.value(details, target)} + + + ); + } + })} + {target.type === "dir" && ( + + , + , + , + ]} + /> + + )} + + )} + + ); + return ( + <> + + + {target && ( + <> + + + SetSideBar(false)} + aria-label="close" + > + + + + {target.name} + + + + {content} + + )} + + + + + +
+ {target && ( + <> +
+ {target.type === "dir" && ( + + )} + {target.type !== "dir" && ( + + )} +
+ + {target.name} + +
+ SetSideBar(false)} + className={classes.closeIcon} + aria-label="close" + size={"small"} + > + + +
+ + )} + + {content} +
+
+
+ + ); +} diff --git a/src/component/FileManager/SmallIcon.js b/src/component/FileManager/SmallIcon.js new file mode 100644 index 0000000..10b4e55 --- /dev/null +++ b/src/component/FileManager/SmallIcon.js @@ -0,0 +1,183 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import classNames from "classnames"; +import { + ButtonBase, + fade, + Tooltip, + Typography, + withStyles, +} from "@material-ui/core"; +import TypeIcon from "./TypeIcon"; +import CheckCircleRoundedIcon from "@material-ui/icons/CheckCircleRounded"; +import Grow from "@material-ui/core/Grow"; +import { Folder } from "@material-ui/icons"; +import FileName from "./FileName"; + +const styles = (theme) => ({ + container: { + padding: "7px", + }, + + selected: { + "&:hover": { + border: "1px solid #d0d0d0", + }, + backgroundColor: fade( + theme.palette.primary.main, + theme.palette.type === "dark" ? 0.3 : 0.18 + ), + }, + notSelected: { + "&:hover": { + backgroundColor: theme.palette.background.default, + border: "1px solid #d0d0d0", + }, + backgroundColor: theme.palette.background.paper, + }, + + button: { + height: "50px", + border: "1px solid " + theme.palette.divider, + width: "100%", + borderRadius: theme.shape.borderRadius, + boxSizing: "border-box", + transition: + "background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms", + display: "flex", + justifyContent: "left", + alignItems: "initial", + }, + icon: { + margin: "10px 10px 10px 16px", + height: "30px", + minWidth: "30px", + backgroundColor: theme.palette.background.paper, + borderRadius: "90%", + paddingTop: "3px", + color: theme.palette.text.secondary, + }, + folderNameSelected: { + color: + theme.palette.type === "dark" ? "#fff" : theme.palette.primary.dark, + fontWeight: "500", + }, + folderNameNotSelected: { + color: theme.palette.text.secondary, + }, + folderName: { + marginTop: "15px", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden", + marginRight: "20px", + }, + checkIcon: { + color: theme.palette.primary.main, + }, +}); + +const mapStateToProps = (state) => { + return { + selected: state.explorer.selected, + }; +}; + +const mapDispatchToProps = () => { + return {}; +}; + +class SmallIconCompoment extends Component { + state = {}; + + shouldComponentUpdate(nextProps, nextState, nextContext) { + const isSelectedCurrent = + this.props.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + const isSelectedNext = + nextProps.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + if ( + nextProps.selected !== this.props.selected && + isSelectedCurrent === isSelectedNext + ) { + return false; + } + + return true; + } + + render() { + const { classes } = this.props; + const isSelected = + this.props.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + + return ( + +
+ {!isSelected && ( + <> + {this.props.isFolder && } + {!this.props.isFolder && ( + + )} + + )} + {isSelected && ( + + + + )} +
+ + + + + +
+ ); + } +} + +SmallIconCompoment.propTypes = { + classes: PropTypes.object.isRequired, + file: PropTypes.object.isRequired, +}; + +const SmallIcon = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(SmallIconCompoment)); + +export default SmallIcon; diff --git a/src/component/FileManager/Sort.tsx b/src/component/FileManager/Sort.tsx new file mode 100644 index 0000000..017687d --- /dev/null +++ b/src/component/FileManager/Sort.tsx @@ -0,0 +1,105 @@ +import React, { MouseEventHandler, useState } from "react"; +import { IconButton, Menu, MenuItem } from "@material-ui/core"; +import TextTotateVerticalIcon from "@material-ui/icons/TextRotateVertical"; +import { useTranslation } from "react-i18next"; +import { CloudreveFile, SortMethod } from "./../../types/index"; + +const SORT_OPTIONS: { + value: SortMethod; + label: string; +}[] = [ + { value: "namePos", label: "A-Z" }, + { value: "nameRev", label: "Z-A" }, + { value: "timePos", label: "oldestUploaded" }, + { value: "timeRev", label: "newestUploaded" }, + { value: "modifyTimePos", label: "oldestModified" }, + { value: "modifyTimeRev", label: "newestModified" }, + { value: "sizePos", label: "smallest" }, + { value: "sizeRes", label: "largest" }, +] + +export default function Sort({ value, onChange, isSmall, inherit, className }) { + const { t } = useTranslation("application", { keyPrefix: "fileManager.sortMethods" }); + + const [anchorSort, setAnchorSort] = useState(null); + const showSortOptions: MouseEventHandler = (e) => { + setAnchorSort(e.currentTarget); + } + + const [sortBy, setSortBy] = useState(value || '') + function onChangeSort(value: SortMethod) { + setSortBy(value) + onChange(value) + setAnchorSort(null); + } + return ( + <> + + + + setAnchorSort(null)} + > + { + SORT_OPTIONS.map((option, index) => ( + onChangeSort(option.value)} + > + {t(option.label)} + + )) + } + + + ) +} + + +type SortFunc = (a: CloudreveFile, b: CloudreveFile) => number; + +export const sortMethodFuncs: Record = { + sizePos: (a: CloudreveFile, b: CloudreveFile) => { + return a.size - b.size; + }, + sizeRes: (a: CloudreveFile, b: CloudreveFile) => { + return b.size - a.size; + }, + namePos: (a: CloudreveFile, b: CloudreveFile) => { + return a.name.localeCompare( + b.name, + navigator.languages[0] || navigator.language, + { numeric: true, ignorePunctuation: true } + ); + }, + nameRev: (a: CloudreveFile, b: CloudreveFile) => { + return b.name.localeCompare( + a.name, + navigator.languages[0] || navigator.language, + { numeric: true, ignorePunctuation: true } + ); + }, + timePos: (a: CloudreveFile, b: CloudreveFile) => { + return Date.parse(a.create_date) - Date.parse(b.create_date); + }, + timeRev: (a: CloudreveFile, b: CloudreveFile) => { + return Date.parse(b.create_date) - Date.parse(a.create_date); + }, + modifyTimePos: (a: CloudreveFile, b: CloudreveFile) => { + return Date.parse(a.date) - Date.parse(b.date); + }, + modifyTimeRev: (a: CloudreveFile, b: CloudreveFile) => { + return Date.parse(b.date) - Date.parse(a.date); + }, +}; diff --git a/src/component/FileManager/TableRow.js b/src/component/FileManager/TableRow.js new file mode 100644 index 0000000..f562dc7 --- /dev/null +++ b/src/component/FileManager/TableRow.js @@ -0,0 +1,229 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import FolderIcon from "@material-ui/icons/Folder"; +import classNames from "classnames"; +import { sizeToString } from "../../utils/index"; +import { + fade, + TableCell, + TableRow, + Typography, + withStyles, +} from "@material-ui/core"; +import TypeIcon from "./TypeIcon"; +import pathHelper from "../../utils/page"; +import statusHelper from "../../utils/page"; +import { withRouter } from "react-router"; +import KeyboardReturnIcon from "@material-ui/icons/KeyboardReturn"; +import CheckCircleRoundedIcon from "@material-ui/icons/CheckCircleRounded"; +import Grow from "@material-ui/core/Grow"; +import { formatLocalTime } from "../../utils/datetime"; +import FileName from "./FileName"; + +const styles = (theme) => ({ + selected: { + "&:hover": {}, + backgroundColor: fade(theme.palette.primary.main, 0.18), + }, + + selectedShared: { + "&:hover": {}, + backgroundColor: fade(theme.palette.primary.main, 0.18), + }, + + notSelected: { + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + }, + icon: { + verticalAlign: "middle", + marginRight: "20px", + color: theme.palette.text.secondary, + }, + tableIcon: { + marginRight: "20px", + verticalAlign: "middle", + }, + folderNameSelected: { + color: + theme.palette.type === "dark" ? "#fff" : theme.palette.primary.dark, + fontWeight: "500", + userSelect: "none", + }, + folderNameNotSelected: { + color: theme.palette.text.secondary, + userSelect: "none", + }, + folderName: { + marginRight: "20px", + display: "flex", + alignItems: "center", + }, + hideAuto: { + [theme.breakpoints.down("sm")]: { + display: "none", + }, + }, + tableRow: { + padding: "10px 16px", + }, + checkIcon: { + color: theme.palette.primary.main, + }, + active: { + backgroundColor: fade(theme.palette.primary.main, 0.1), + }, +}); + +const mapStateToProps = (state) => { + return { + selected: state.explorer.selected, + }; +}; + +const mapDispatchToProps = () => { + return {}; +}; + +class TableRowCompoment extends Component { + state = {}; + + shouldComponentUpdate(nextProps, nextState, nextContext) { + const isSelectedCurrent = + this.props.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + const isSelectedNext = + nextProps.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + if ( + nextProps.selected !== this.props.selected && + isSelectedCurrent === isSelectedNext + ) { + return false; + } + + return true; + } + + render() { + const { classes } = this.props; + const isShare = pathHelper.isSharePage(this.props.location.pathname); + + let icon; + if (this.props.file.type === "dir") { + icon = ; + } else if (this.props.file.type === "up") { + icon = ; + } else { + icon = ( + + ); + } + const isSelected = + this.props.selected.findIndex((value) => { + return value === this.props.file; + }) !== -1; + const isMobile = statusHelper.isMobile(); + + return ( + + + +
+ {!isSelected && icon} + {isSelected && ( + + + + )} +
+ +
+
+ + + {" "} + {this.props.file.type !== "dir" && + this.props.file.type !== "up" && + sizeToString(this.props.file.size)} + + + + + {" "} + {formatLocalTime(this.props.file.date)} + + +
+ ); + } +} + +TableRowCompoment.propTypes = { + classes: PropTypes.object.isRequired, + file: PropTypes.object.isRequired, +}; + +const TableItem = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(TableRowCompoment))); + +export default TableItem; diff --git a/src/component/FileManager/TypeIcon.js b/src/component/FileManager/TypeIcon.js new file mode 100644 index 0000000..fb75c84 --- /dev/null +++ b/src/component/FileManager/TypeIcon.js @@ -0,0 +1,162 @@ +import React from "react"; +import { mediaType } from "../../config"; +import ImageIcon from "@material-ui/icons/PhotoSizeSelectActual"; +import VideoIcon from "@material-ui/icons/Videocam"; +import AudioIcon from "@material-ui/icons/Audiotrack"; +import PdfIcon from "@material-ui/icons/PictureAsPdf"; +import { + Android, + FileExcelBox, + FilePowerpointBox, + FileWordBox, + LanguageC, + LanguageCpp, + LanguageGo, + LanguageJavascript, + LanguagePhp, + LanguagePython, + MagnetOn, + ScriptText, + WindowRestore, + ZipBox, +} from "mdi-material-ui"; +import FileShowIcon from "@material-ui/icons/InsertDriveFile"; +import { lighten } from "@material-ui/core/styles"; +import useTheme from "@material-ui/core/styles/useTheme"; +import { Avatar } from "@material-ui/core"; +import { MenuBook } from "@material-ui/icons"; + +const icons = { + audio: { + color: "#651fff", + icon: AudioIcon, + }, + video: { + color: "#d50000", + icon: VideoIcon, + }, + image: { + color: "#d32f2f", + icon: ImageIcon, + }, + pdf: { + color: "#f44336", + icon: PdfIcon, + }, + word: { + color: "#538ce5", + icon: FileWordBox, + }, + ppt: { + color: "rgb(239, 99, 63)", + icon: FilePowerpointBox, + }, + excel: { + color: "#4caf50", + icon: FileExcelBox, + }, + text: { + color: "#607d8b", + icon: ScriptText, + }, + torrent: { + color: "#5c6bc0", + icon: MagnetOn, + }, + zip: { + color: "#f9a825", + icon: ZipBox, + }, + excute: { + color: "#1a237e", + icon: WindowRestore, + }, + android: { + color: "#8bc34a", + icon: Android, + }, + file: { + color: "#607d8b", + icon: FileShowIcon, + }, + php: { + color: "#777bb3", + icon: LanguagePhp, + }, + go: { + color: "#16b3da", + icon: LanguageGo, + }, + python: { + color: "#3776ab", + icon: LanguagePython, + }, + c: { + color: "#a8b9cc", + icon: LanguageC, + }, + cpp: { + color: "#004482", + icon: LanguageCpp, + }, + js: { + color: "#f4d003", + icon: LanguageJavascript, + }, + epub: { + color: "#81b315", + icon: MenuBook, + }, +}; + +const getColor = (theme, color) => + theme.palette.type === "light" ? color : lighten(color, 0.2); + +let color; + +const TypeIcon = (props) => { + const theme = useTheme(); + + const fileSuffix = props.fileName.split(".").pop().toLowerCase(); + let fileType = "file"; + Object.keys(mediaType).forEach((k) => { + if (mediaType[k].indexOf(fileSuffix) !== -1) { + fileType = k; + } + }); + const IconComponent = icons[fileType].icon; + color = getColor(theme, icons[fileType].color); + if (props.getColorValue) { + props.getColorValue(color); + } + + return ( + <> + {props.isUpload && ( + + + + )} + {!props.isUpload && ( + + )} + + ); +}; + +export default TypeIcon; diff --git a/src/component/Login/Activication.js b/src/component/Login/Activication.js new file mode 100644 index 0000000..472bfbc --- /dev/null +++ b/src/component/Login/Activication.js @@ -0,0 +1,114 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import { + Avatar, + Button, + makeStyles, + Paper, + Typography, +} from "@material-ui/core"; +import { useHistory } from "react-router-dom"; +import API from "../../middleware/Api"; +import EmailIcon from "@material-ui/icons/EmailOutlined"; +import { useLocation } from "react-router"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "110px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up("sm")]: { + width: 400, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: 110, + }, + paper: { + marginTop: theme.spacing(8), + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing( + 3 + )}px`, + }, + avatar: { + margin: theme.spacing(1), + backgroundColor: theme.palette.secondary.main, + }, + submit: { + marginTop: theme.spacing(3), + }, +})); + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +function Activation() { + const { t } = useTranslation(); + const query = useQuery(); + const location = useLocation(); + + const [success, setSuccess] = useState(false); + const [email, setEmail] = useState(""); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const history = useHistory(); + + const classes = useStyles(); + + useEffect(() => { + API.get( + "/user/activate/" + query.get("id") + "?sign=" + query.get("sign") + ) + .then((response) => { + setEmail(response.data); + setSuccess(true); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "warning"); + history.push("/login"); + }); + // eslint-disable-next-line + }, [location]); + + return ( +
+ {success && ( + + + + + + {t("login.activateSuccess")} + + + {t("login.accountActivated")} + + + + )} +
+ ); +} + +export default Activation; diff --git a/src/component/Login/LoginForm.js b/src/component/Login/LoginForm.js new file mode 100644 index 0000000..b74062f --- /dev/null +++ b/src/component/Login/LoginForm.js @@ -0,0 +1,517 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import LockOutlinedIcon from "@material-ui/icons/LockOutlined"; +import { + Avatar, + Button, + Divider, + FormControl, + Link, + makeStyles, + Paper, + TextField, + Typography, +} from "@material-ui/core"; +import { Link as RouterLink, useHistory } from "react-router-dom"; +import API from "../../middleware/Api"; +import Auth from "../../middleware/Auth"; +import { bufferDecode, bufferEncode } from "../../utils/index"; +import { + EmailOutlined, + Fingerprint, + VpnKey, + VpnKeyOutlined, +} from "@material-ui/icons"; +import VpnIcon from "@material-ui/icons/VpnKeyOutlined"; +import { useLocation } from "react-router"; +import { useCaptcha } from "../../hooks/useCaptcha"; +import { + applyThemes, + setSessionStatus, + toggleSnackbar, +} from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; +import { useTheme } from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "110px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up("sm")]: { + width: 400, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: 110, + }, + paper: { + marginTop: theme.spacing(8), + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing( + 3 + )}px`, + }, + avatar: { + margin: theme.spacing(1), + backgroundColor: theme.palette.secondary.main, + }, + form: { + width: "100%", // Fix IE 11 issue. + marginTop: theme.spacing(1), + }, + submit: { + marginTop: theme.spacing(3), + }, + link: { + marginTop: "20px", + display: "flex", + width: "100%", + justifyContent: "space-between", + }, + buttonContainer: { + display: "flex", + }, + authnLink: { + textAlign: "center", + marginTop: 16, + }, +})); + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +function LoginForm() { + const { t } = useTranslation(); + + const [email, setEmail] = useState(""); + const [pwd, setPwd] = useState(""); + const [loading, setLoading] = useState(false); + const [useAuthn, setUseAuthn] = useState(false); + const [twoFA, setTwoFA] = useState(false); + const [faCode, setFACode] = useState(""); + + const loginCaptcha = useSelector((state) => state.siteConfig.loginCaptcha); + const registerEnabled = useSelector( + (state) => state.siteConfig.registerEnabled + ); + const title = useSelector((state) => state.siteConfig.title); + const QQLogin = useSelector((state) => state.siteConfig.QQLogin); + const authn = useSelector((state) => state.siteConfig.authn); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const ApplyThemes = useCallback( + (theme) => dispatch(applyThemes(theme)), + [dispatch] + ); + const SetSessionStatus = useCallback( + (status) => dispatch(setSessionStatus(status)), + [dispatch] + ); + + const history = useHistory(); + const location = useLocation(); + const { + captchaLoading, + isValidate, + validate, + CaptchaRender, + captchaRefreshRef, + captchaParamsRef, + } = useCaptcha(); + const query = useQuery(); + + const classes = useStyles(); + + useEffect(() => { + setEmail(query.get("username")); + }, [location]); + + const afterLogin = (data) => { + Auth.authenticate(data); + + // 设置用户主题色 + if (data["preferred_theme"] !== "") { + ApplyThemes(data["preferred_theme"]); + } + + // 设置登录状态 + SetSessionStatus(true); + + // eslint-disable-next-line react-hooks/rules-of-hooks + if (query.get("redirect")) { + history.push(query.get("redirect")); + } else { + history.push("/home"); + } + ToggleSnackbar("top", "right", t("login.success"), "success"); + + localStorage.removeItem("siteConfigCache"); + }; + + const authnLogin = (e) => { + e.preventDefault(); + if (!navigator.credentials) { + ToggleSnackbar( + "top", + "right", + t("login.browserNotSupport"), + "warning" + ); + + return; + } + + setLoading(true); + + API.get("/user/authn/" + email) + .then((response) => { + const credentialRequestOptions = response.data; + console.log(credentialRequestOptions); + credentialRequestOptions.publicKey.challenge = bufferDecode( + credentialRequestOptions.publicKey.challenge + ); + credentialRequestOptions.publicKey.allowCredentials.forEach( + function (listItem) { + listItem.id = bufferDecode(listItem.id); + } + ); + + return navigator.credentials.get({ + publicKey: credentialRequestOptions.publicKey, + }); + }) + .then((assertion) => { + const authData = assertion.response.authenticatorData; + const clientDataJSON = assertion.response.clientDataJSON; + const rawId = assertion.rawId; + const sig = assertion.response.signature; + const userHandle = assertion.response.userHandle; + + return API.post( + "/user/authn/finish/" + email, + JSON.stringify({ + id: assertion.id, + rawId: bufferEncode(rawId), + type: assertion.type, + response: { + authenticatorData: bufferEncode(authData), + clientDataJSON: bufferEncode(clientDataJSON), + signature: bufferEncode(sig), + userHandle: bufferEncode(userHandle), + }, + }) + ); + }) + .then((response) => { + afterLogin(response.data); + }) + .catch((error) => { + console.log(error); + ToggleSnackbar("top", "right", error.message, "warning"); + }) + .then(() => { + setLoading(false); + }); + }; + + const login = (e) => { + e.preventDefault(); + setLoading(true); + if (!isValidate.current.isValidate && loginCaptcha) { + validate(() => login(e), setLoading); + return; + } + API.post("/user/session", { + userName: email, + Password: pwd, + ...captchaParamsRef.current, + }) + .then((response) => { + setLoading(false); + if (response.rawData.code === 203) { + setTwoFA(true); + } else { + afterLogin(response.data); + } + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "warning"); + captchaRefreshRef.current(); + }); + }; + + const initQQLogin = () => { + setLoading(true); + API.post("/user/qq") + .then((response) => { + window.location.href = response.data; + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "warning"); + }); + }; + + const twoFALogin = (e) => { + e.preventDefault(); + setLoading(true); + API.post("/user/2fa", { + code: faCode, + }) + .then((response) => { + setLoading(false); + afterLogin(response.data); + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "warning"); + }); + }; + + return ( +
+ {!twoFA && ( + <> + + + + + + {t("login.title", { title })} + + {!useAuthn && ( +
+ + + setEmail(e.target.value) + } + InputProps={{ + startAdornment: !isMobile && ( + + + + ), + }} + autoComplete + value={email} + autoFocus + /> + + + setPwd(e.target.value)} + InputProps={{ + startAdornment: !isMobile && ( + + + + ), + }} + value={pwd} + autoComplete + /> + + {loginCaptcha && } + {QQLogin && ( +
+ + +
+ )} + {!QQLogin && ( + + )} + + )} + {useAuthn && ( +
+ + + + + ), + }} + onChange={(e) => + setEmail(e.target.value) + } + autoComplete + value={email} + autoFocus + required + /> + + +
+ )} + +
+
+ + {t("login.forgetPassword")} + +
+
+ {registerEnabled && ( + + {t("login.signUpAccount")} + + )} +
+
+
+ + {authn && ( +
+ +
+ )} + + )} + {twoFA && ( + + + + + + {t("login.2FA")} + +
+ + + setFACode(event.target.value) + } + autoComplete + value={faCode} + autoFocus + /> + + {" "} +
{" "} + +
+ )} +
+ ); +} + +export default LoginForm; diff --git a/src/component/Login/QQ.js b/src/component/Login/QQ.js new file mode 100644 index 0000000..25dcae7 --- /dev/null +++ b/src/component/Login/QQ.js @@ -0,0 +1,82 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import Notice from "../Share/NotFound"; +import { useHistory, useLocation } from "react-router"; +import API from "../../middleware/Api"; +import Auth from "../../middleware/Auth"; +import { + applyThemes, + setSessionStatus, + toggleSnackbar, +} from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +export default function QQCallback() { + const { t } = useTranslation(); + const query = useQuery(); + const location = useLocation(); + const history = useHistory(); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const ApplyThemes = useCallback((theme) => dispatch(applyThemes(theme)), [ + dispatch, + ]); + const SetSessionStatus = useCallback( + (status) => dispatch(setSessionStatus(status)), + [dispatch] + ); + + const [msg, setMsg] = useState(""); + + const afterLogin = (data) => { + Auth.authenticate(data); + + // 设置用户主题色 + if (data["preferred_theme"] !== "") { + ApplyThemes(data["preferred_theme"]); + } + + // 设置登录状态 + SetSessionStatus(true); + + history.push("/home"); + ToggleSnackbar("top", "right", t("login.success"), "success"); + + localStorage.removeItem("siteConfigCache"); + }; + + useEffect(() => { + if (query.get("error_description")) { + setMsg(query.get("error_description")); + return; + } + if (query.get("code") === null) { + return; + } + API.post("/callback/qq", { + code: query.get("code"), + state: query.get("state"), + }) + .then((response) => { + if (response.rawData.code === 203) { + afterLogin(response.data); + } else { + history.push(response.data); + } + }) + .catch((error) => { + setMsg(error.message); + }); + // eslint-disable-next-line + }, [location]); + + return <>{msg !== "" && }; +} diff --git a/src/component/Login/ReCaptcha.js b/src/component/Login/ReCaptcha.js new file mode 100644 index 0000000..b6c6f83 --- /dev/null +++ b/src/component/Login/ReCaptcha.js @@ -0,0 +1,15 @@ +import ReCAPTCHA from "./ReCaptchaWrapper"; +import makeAsyncScriptLoader from "react-async-script"; + +const callbackName = "onloadcallback"; +const globalName = "grecaptcha"; + +function getURL() { + const hostname = "recaptcha.net"; + return `https://${hostname}/recaptcha/api.js?onload=${callbackName}&render=explicit`; +} + +export default makeAsyncScriptLoader(getURL, { + callbackName, + globalName, +})(ReCAPTCHA); diff --git a/src/component/Login/ReCaptchaWrapper.js b/src/component/Login/ReCaptchaWrapper.js new file mode 100644 index 0000000..7fc46c3 --- /dev/null +++ b/src/component/Login/ReCaptchaWrapper.js @@ -0,0 +1,173 @@ +import React from "react"; +import PropTypes from "prop-types"; + +export default class ReCAPTCHA extends React.Component { + constructor() { + super(); + this.handleExpired = this.handleExpired.bind(this); + this.handleErrored = this.handleErrored.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleRecaptchaRef = this.handleRecaptchaRef.bind(this); + } + + getValue() { + if (this.props.grecaptcha && this._widgetId !== undefined) { + return this.props.grecaptcha.getResponse(this._widgetId); + } + return null; + } + + getWidgetId() { + if (this.props.grecaptcha && this._widgetId !== undefined) { + return this._widgetId; + } + return null; + } + + execute() { + const { grecaptcha } = this.props; + + if (grecaptcha && this._widgetId !== undefined) { + return grecaptcha.execute(this._widgetId); + } else { + this._executeRequested = true; + } + } + + reset() { + if (this.props.grecaptcha && this._widgetId !== undefined) { + this.props.grecaptcha.reset(this._widgetId); + } + } + + handleExpired() { + if (this.props.onExpired) { + this.props.onExpired(); + } else { + this.handleChange(null); + } + } + + handleErrored() { + if (this.props.onErrored) this.props.onErrored(); + } + + handleChange(token) { + if (this.props.onChange) this.props.onChange(token); + } + + explicitRender() { + if ( + this.props.grecaptcha && + this.props.grecaptcha.render && + this._widgetId === undefined + ) { + const wrapper = document.createElement("div"); + this._widgetId = this.props.grecaptcha.render(wrapper, { + sitekey: this.props.sitekey, + callback: this.handleChange, + theme: this.props.theme, + type: this.props.type, + tabindex: this.props.tabindex, + "expired-callback": this.handleExpired, + "error-callback": this.handleErrored, + size: this.props.size, + stoken: this.props.stoken, + hl: this.props.hl, + badge: this.props.badge, + }); + this.captcha.appendChild(wrapper); + } + if ( + this._executeRequested && + this.props.grecaptcha && + this._widgetId !== undefined + ) { + this._executeRequested = false; + this.execute(); + } + } + + componentDidMount() { + this.explicitRender(); + } + + componentDidUpdate() { + this.explicitRender(); + } + + componentWillUnmount() { + if (this._widgetId !== undefined) { + this.delayOfCaptchaIframeRemoving(); + this.reset(); + } + } + + delayOfCaptchaIframeRemoving() { + const temporaryNode = document.createElement("div"); + document.body.appendChild(temporaryNode); + temporaryNode.style.display = "none"; + + // move of the recaptcha to a temporary node + while (this.captcha.firstChild) { + temporaryNode.appendChild(this.captcha.firstChild); + } + + // delete the temporary node after reset will be done + setTimeout(() => { + document.body.removeChild(temporaryNode); + }, 5000); + } + + handleRecaptchaRef(elem) { + this.captcha = elem; + } + + render() { + // consume properties owned by the reCATPCHA, pass the rest to the div so the user can style it. + /* eslint-disable no-unused-vars */ + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + sitekey, + onChange, + theme, + type, + tabindex, + onExpired, + onErrored, + size, + stoken, + grecaptcha, + badge, + hl, + ...childProps + } = this.props; + /* eslint-enable no-unused-vars */ + return
; + } +} + +ReCAPTCHA.displayName = "ReCAPTCHA"; +ReCAPTCHA.propTypes = { + sitekey: PropTypes.string.isRequired, + onChange: PropTypes.func, + grecaptcha: PropTypes.object, + theme: PropTypes.oneOf(["dark", "light"]), + type: PropTypes.oneOf(["image", "audio"]), + tabindex: PropTypes.number, + onExpired: PropTypes.func, + onErrored: PropTypes.func, + size: PropTypes.oneOf(["compact", "normal", "invisible"]), + stoken: PropTypes.string, + hl: PropTypes.string, + badge: PropTypes.oneOf(["bottomright", "bottomleft", "inline"]), +}; +ReCAPTCHA.defaultProps = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + onChange: () => {}, + theme: "light", + type: "image", + tabindex: 0, + size: "normal", + badge: "bottomright", +}; diff --git a/src/component/Login/Register.js b/src/component/Login/Register.js new file mode 100644 index 0000000..4206930 --- /dev/null +++ b/src/component/Login/Register.js @@ -0,0 +1,294 @@ +import React, { useCallback, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import RegIcon from "@material-ui/icons/AssignmentIndOutlined"; +import { + Avatar, + Button, + Divider, + FormControl, + Input, + InputLabel, + Link, + makeStyles, + Paper, + TextField, + Typography, +} from "@material-ui/core"; +import { Link as RouterLink, useHistory } from "react-router-dom"; +import API from "../../middleware/Api"; +import EmailIcon from "@material-ui/icons/EmailOutlined"; +import { useCaptcha } from "../../hooks/useCaptcha"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import { EmailOutlined, VpnKeyOutlined } from "@material-ui/icons"; +import { useTheme } from "@material-ui/core/styles"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "110px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up("sm")]: { + width: 400, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: 110, + }, + paper: { + marginTop: theme.spacing(8), + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing( + 3 + )}px`, + }, + avatar: { + margin: theme.spacing(1), + backgroundColor: theme.palette.secondary.main, + }, + form: { + width: "100%", // Fix IE 11 issue. + marginTop: theme.spacing(1), + }, + submit: { + marginTop: theme.spacing(3), + }, + link: { + marginTop: "20px", + display: "flex", + width: "100%", + justifyContent: "space-between", + }, + buttonContainer: { + display: "flex", + }, + authnLink: { + textAlign: "center", + marginTop: 16, + }, + avatarSuccess: { + margin: theme.spacing(1), + backgroundColor: theme.palette.primary.main, + }, +})); + +function Register() { + const { t } = useTranslation(); + + const [input, setInput] = useState({ + email: "", + password: "", + password_repeat: "", + }); + const [loading, setLoading] = useState(false); + const [emailActive, setEmailActive] = useState(false); + + const title = useSelector((state) => state.siteConfig.title); + const regCaptcha = useSelector((state) => state.siteConfig.regCaptcha); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const history = useHistory(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const handleInputChange = (name) => (e) => { + setInput({ + ...input, + [name]: e.target.value, + }); + }; + + const { + captchaLoading, + isValidate, + validate, + CaptchaRender, + captchaRefreshRef, + captchaParamsRef, + } = useCaptcha(); + const classes = useStyles(); + + const register = (e) => { + e.preventDefault(); + + if (input.password !== input.password_repeat) { + ToggleSnackbar( + "top", + "right", + t("login.passwordNotMatch"), + "warning" + ); + return; + } + + setLoading(true); + if (!isValidate.current.isValidate && regCaptcha) { + validate(() => register(e), setLoading); + return; + } + API.post("/user", { + userName: input.email, + Password: input.password, + ...captchaParamsRef.current, + }) + .then((response) => { + setLoading(false); + if (response.rawData.code === 203) { + setEmailActive(true); + } else { + history.push("/login?username=" + input.email); + ToggleSnackbar( + "top", + "right", + t("login.signUpSuccess"), + "success" + ); + } + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "warning"); + captchaRefreshRef.current(); + }); + }; + + return ( +
+ <> + {!emailActive && ( + + + + + + {t("login.sinUpTitle", { title })} + + +
+ + + + + ), + }} + onChange={handleInputChange("email")} + autoComplete + value={input.email} + autoFocus + /> + + + + + + ), + }} + onChange={handleInputChange("password")} + value={input.password} + autoComplete + /> + + + + + + ), + }} + value={input.password_repeat} + autoComplete + /> + + {regCaptcha && } + + + + + +
+
+ + {t("login.backToSingIn")} + +
+
+ + {t("login.forgetPassword")} + +
+
+
+ )} + {emailActive && ( + + + + + + {t("login.activateTitle")} + + + {t("login.activateDescription")} + + + )} + +
+ ); +} + +export default Register; diff --git a/src/component/Login/Reset.js b/src/component/Login/Reset.js new file mode 100644 index 0000000..07aa8cf --- /dev/null +++ b/src/component/Login/Reset.js @@ -0,0 +1,196 @@ +import React, { useCallback, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { + Avatar, + Button, + Divider, + FormControl, + Input, + InputLabel, + Link, + makeStyles, + Paper, + TextField, + Typography, +} from "@material-ui/core"; +import API from "../../middleware/Api"; +import KeyIcon from "@material-ui/icons/VpnKeyOutlined"; +import { useCaptcha } from "../../hooks/useCaptcha"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; +import { Link as RouterLink } from "react-router-dom"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import { EmailOutlined } from "@material-ui/icons"; +import { useTheme } from "@material-ui/core/styles"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "110px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up("sm")]: { + width: 400, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: 110, + }, + paper: { + marginTop: theme.spacing(8), + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing( + 3 + )}px`, + }, + avatar: { + margin: theme.spacing(1), + backgroundColor: theme.palette.secondary.main, + }, + submit: { + marginTop: theme.spacing(3), + }, + link: { + marginTop: "20px", + display: "flex", + width: "100%", + justifyContent: "space-between", + }, +})); + +function Reset() { + const { t } = useTranslation(); + + const [input, setInput] = useState({ + email: "", + }); + const [loading, setLoading] = useState(false); + const forgetCaptcha = useSelector( + (state) => state.siteConfig.forgetCaptcha + ); + const registerEnabled = useSelector( + (state) => state.siteConfig.registerEnabled + ); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const handleInputChange = (name) => (e) => { + setInput({ + ...input, + [name]: e.target.value, + }); + }; + + const { + captchaLoading, + isValidate, + validate, + CaptchaRender, + captchaRefreshRef, + captchaParamsRef, + } = useCaptcha(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const submit = (e) => { + e.preventDefault(); + setLoading(true); + if (!isValidate.current.isValidate && forgetCaptcha) { + validate(() => submit(e), setLoading); + return; + } + API.post("/user/reset", { + userName: input.email, + ...captchaParamsRef.current, + }) + .then(() => { + setLoading(false); + ToggleSnackbar( + "top", + "right", + t("login.resetEmailSent"), + "success" + ); + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "warning"); + captchaRefreshRef.current(); + }); + }; + + const classes = useStyles(); + + return ( +
+ + + + + + {t("login.findMyPassword")} + +
+ + + + + ), + }} + onChange={handleInputChange("email")} + autoComplete + value={input.email} + autoFocus + /> + + {forgetCaptcha && } + {" "} + {" "} + +
+
+ + {t("login.backToSingIn")} + +
+
+ {registerEnabled && ( + + {t("login.signUpAccount")} + + )} +
+
+
+
+ ); +} + +export default Reset; diff --git a/src/component/Login/ResetForm.js b/src/component/Login/ResetForm.js new file mode 100644 index 0000000..b3d70f6 --- /dev/null +++ b/src/component/Login/ResetForm.js @@ -0,0 +1,213 @@ +import React, { useCallback, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { + Avatar, + Button, + Divider, + FormControl, + Link, + makeStyles, + Paper, + TextField, + Typography, +} from "@material-ui/core"; +import { Link as RouterLink, useHistory } from "react-router-dom"; +import API from "../../middleware/Api"; +import { useLocation } from "react-router"; +import KeyIcon from "@material-ui/icons/VpnKeyOutlined"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import { VpnKeyOutlined } from "@material-ui/icons"; +import { useTheme } from "@material-ui/core/styles"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "110px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up("sm")]: { + width: 400, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: 110, + }, + paper: { + marginTop: theme.spacing(8), + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing( + 3 + )}px`, + }, + avatar: { + margin: theme.spacing(1), + backgroundColor: theme.palette.secondary.main, + }, + submit: { + marginTop: theme.spacing(3), + }, + link: { + marginTop: "20px", + display: "flex", + width: "100%", + justifyContent: "space-between", + }, +})); + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +function ResetForm() { + const { t } = useTranslation(); + const query = useQuery(); + const [input, setInput] = useState({ + password: "", + password_repeat: "", + }); + const [loading, setLoading] = useState(false); + const registerEnabled = useSelector( + (state) => state.siteConfig.registerEnabled + ); + const handleInputChange = (name) => (e) => { + setInput({ + ...input, + [name]: e.target.value, + }); + }; + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const history = useHistory(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const submit = (e) => { + e.preventDefault(); + if (input.password !== input.password_repeat) { + ToggleSnackbar( + "top", + "right", + t("login.passwordNotMatch"), + "warning" + ); + return; + } + setLoading(true); + API.patch("/user/reset", { + secret: query.get("sign"), + id: query.get("id"), + Password: input.password, + }) + .then(() => { + setLoading(false); + history.push("/login"); + ToggleSnackbar( + "top", + "right", + t("login.passwordReset"), + "success" + ); + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "warning"); + }); + }; + + const classes = useStyles(); + + return ( +
+ + + + + + {t("login.findMyPassword")} + +
+ + + + + ), + }} + onChange={handleInputChange("password")} + autoComplete + value={input.password} + autoFocus + /> + + + + + + ), + }} + onChange={handleInputChange("password_repeat")} + autoComplete + value={input.password_repeat} + autoFocus + /> + + {" "} +
{" "} + +
+
+ + {t("login.backToSingIn")} + +
+
+ {registerEnabled && ( + + {t("login.signUpAccount")} + + )} +
+
+
+
+ ); +} + +export default ResetForm; diff --git a/src/component/Modals/AddTag.js b/src/component/Modals/AddTag.js new file mode 100644 index 0000000..d454219 --- /dev/null +++ b/src/component/Modals/AddTag.js @@ -0,0 +1,411 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, + useTheme, +} from "@material-ui/core"; +import PathSelector from "../FileManager/PathSelector"; +import { useDispatch } from "react-redux"; +import API from "../../middleware/Api"; +import AppBar from "@material-ui/core/AppBar"; +import Tabs from "@material-ui/core/Tabs"; +import Tab from "@material-ui/core/Tab"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import FormLabel from "@material-ui/core/FormLabel"; +import ToggleButtonGroup from "@material-ui/lab/ToggleButtonGroup"; +import ToggleButton from "@material-ui/lab/ToggleButton"; +import { + Circle, + CircleOutline, + Heart, + HeartOutline, + Hexagon, + HexagonOutline, + Hexagram, + HexagramOutline, + Rhombus, + RhombusOutline, + Square, + SquareOutline, + Triangle, +} from "mdi-material-ui"; +import { toggleSnackbar } from "../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + contentFix: { + padding: "10px 24px 0px 24px", + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, + content: { + padding: 0, + marginTop: 0, + }, + marginTop: { + marginTop: theme.spacing(2), + display: "block", + }, + textField: { + marginTop: theme.spacing(1), + }, + scroll: { + overflowX: "auto", + }, + dialogContent: { + marginTop: theme.spacing(2), + }, + pathSelect: { + marginTop: theme.spacing(2), + display: "flex", + }, +})); + +const icons = { + Circle: , + CircleOutline: , + Heart: , + HeartOutline: , + Hexagon: , + HexagonOutline: , + Hexagram: , + HexagramOutline: , + Rhombus: , + RhombusOutline: , + Square: , + SquareOutline: , + Triangle: , +}; + +export default function AddTag(props) { + const theme = useTheme(); + const { t } = useTranslation(); + + const [value, setValue] = React.useState(0); + const [loading, setLoading] = React.useState(false); + const [alignment, setAlignment] = React.useState("Circle"); + const [color, setColor] = React.useState(theme.palette.text.secondary); + const [input, setInput] = React.useState({ + filename: "", + tagName: "", + path: "/", + }); + const [pathSelectDialog, setPathSelectDialog] = React.useState(false); + const [selectedPath, setSelectedPath] = useState(""); + // eslint-disable-next-line + const [selectedPathName, setSelectedPathName] = useState(""); + const setMoveTarget = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setSelectedPath(path); + setSelectedPathName(folder.name); + }; + + const handleChange = (event, newValue) => { + setValue(newValue); + }; + + const handleIconChange = (event, newAlignment) => { + if (newAlignment) { + setAlignment(newAlignment); + } + }; + + const handleColorChange = (event, newAlignment) => { + if (newAlignment) { + setColor(newAlignment); + } + }; + + const handleInputChange = (name) => (event) => { + setInput({ + ...input, + [name]: event.target.value, + }); + }; + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitNewLink = () => { + setLoading(true); + + API.post("/tag/link", { + path: input.path, + name: input.tagName, + }) + .then((response) => { + setLoading(false); + props.onClose(); + props.onSuccess({ + type: 1, + name: input.tagName, + expression: input.path, + color: theme.palette.text.secondary, + icon: "FolderHeartOutline", + id: response.data, + }); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + + const submitNewTag = () => { + setLoading(true); + + API.post("/tag/filter", { + expression: input.filename, + name: input.tagName, + color: color, + icon: alignment, + }) + .then((response) => { + setLoading(false); + props.onClose(); + props.onSuccess({ + type: 0, + name: input.tagName, + color: color, + icon: alignment, + id: response.data, + }); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setLoading(false); + }); + }; + const submit = () => { + if (value === 0) { + submitNewTag(); + } else { + submitNewLink(); + } + }; + const selectPath = () => { + setInput({ + ...input, + path: selectedPath === "//" ? "/" : selectedPath, + }); + setPathSelectDialog(false); + }; + + const classes = useStyles(); + + return ( + + setPathSelectDialog(false)} + aria-labelledby="form-dialog-title" + > + + {t("navbar.addTagDialog.selectFolder")} + + + + + + + + + + + + + + + + {value === 0 && ( + + + + + + {[, ]} + + + + {t("navbar.addTagDialog.icon")} + +
+ + {Object.keys(icons).map((key, index) => ( + + {icons[key]} + + ))} + +
+ + {t("navbar.addTagDialog.color")} + +
+ + {[ + theme.palette.text.secondary, + "#f44336", + "#e91e63", + "#9c27b0", + "#673ab7", + "#3f51b5", + "#2196f3", + "#03a9f4", + "#00bcd4", + "#009688", + "#4caf50", + "#cddc39", + "#ffeb3b", + "#ffc107", + "#ff9800", + "#ff5722", + "#795548", + "#9e9e9e", + "#607d8b", + ].map((key, index) => ( + + + + ))} + +
+
+ )} + {value === 1 && ( + + +
+ + +
+
+ )} + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/BindPhone.js b/src/component/Modals/BindPhone.js new file mode 100644 index 0000000..89ff226 --- /dev/null +++ b/src/component/Modals/BindPhone.js @@ -0,0 +1,128 @@ +import React, { useState, useCallback } from "react"; +import { makeStyles } from "@material-ui/core"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + CircularProgress, +} from "@material-ui/core"; +import { useDispatch } from "react-redux"; +import TextField from "@material-ui/core/TextField"; +import FormControl from "@material-ui/core/FormControl"; +import { useCaptcha } from "../../hooks/useCaptcha"; +import { toggleSnackbar } from "../../redux/explorer"; + +const useStyles = makeStyles((theme) => ({ + smsCode: { + flexDirection: "row", + alignItems: "baseline", + }, + sendButton: { + marginLeft: theme.spacing(1), + }, +})); + +export default function BindPhone(props) { + const [phone, setPhone] = useState(props.phone); + const [loading, setLoading] = useState(false); + const [verifyCode, setVerifyCode] = useState(""); + const [countdown, setCountdown] = useState(0); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + let countdownTimer, countdownSecond; + + const { + captchaLoading, + isValidate, + validate, + CaptchaRender, + captchaRefreshRef, + captchaParamsRef, + } = useCaptcha(); + + const savePhoneInfo = () => { + return false; + }; + + const sendSMS = () => { + setCountdown(60); + countdownSecond = 60; + countdownTimer = setInterval(function () { + countdownSecond = countdownSecond - 1; + setCountdown(countdownSecond); + if (countdownSecond <= 0) { + clearInterval(countdownTimer); + } + }, 1000); + return false; + }; + + const classes = useStyles(); + + return ( + + 绑定手机 + + + + setPhone(e.target.value)} + /> + + + + setVerifyCode(e.target.value)} + /> + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/Compress.js b/src/component/Modals/Compress.js new file mode 100644 index 0000000..3225933 --- /dev/null +++ b/src/component/Modals/Compress.js @@ -0,0 +1,154 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import PathSelector from "../FileManager/PathSelector"; +import { useDispatch } from "react-redux"; +import TextField from "@material-ui/core/TextField"; +import { setModalsLoading, toggleSnackbar } from "../../redux/explorer"; +import { submitCompressTask } from "../../redux/explorer/action"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + contentFix: { + padding: "10px 24px 0px 24px", + backgroundColor: theme.palette.background.default, + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, +})); + +export default function CompressDialog(props) { + const { t } = useTranslation(); + const [selectedPath, setSelectedPath] = useState(""); + const [fileName, setFileName] = useState(""); + // eslint-disable-next-line + const [selectedPathName, setSelectedPathName] = useState(""); + + const dispatch = useDispatch(); + + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const SetModalsLoading = useCallback( + (status) => { + dispatch(setModalsLoading(status)); + }, + [dispatch] + ); + + const SubmitCompressTask = useCallback( + (name, path) => dispatch(submitCompressTask(name, path)), + [dispatch] + ); + + const setMoveTarget = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setSelectedPath(path); + setSelectedPathName(folder.name); + }; + + const submitMove = (e) => { + if (e != null) { + e.preventDefault(); + } + SetModalsLoading(true); + + SubmitCompressTask(fileName, selectedPath) + .then(() => { + props.onClose(); + ToggleSnackbar( + "top", + "right", + t("modals.taskCreated"), + "success" + ); + SetModalsLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + SetModalsLoading(false); + }); + }; + + const classes = useStyles(); + + return ( + + + {t("modals.saveToTitle")} + + + + {selectedPath !== "" && ( + + + setFileName(e.target.value)} + value={fileName} + fullWidth + autoFocus + id="standard-basic" + label={t("modals.zipFileName")} + /> + + + )} + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/ConcurrentOption.js b/src/component/Modals/ConcurrentOption.js new file mode 100644 index 0000000..7881b8e --- /dev/null +++ b/src/component/Modals/ConcurrentOption.js @@ -0,0 +1,71 @@ +import React, { useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Input, + InputLabel, + makeStyles, +} from "@material-ui/core"; +import FormControl from "@material-ui/core/FormControl"; +import Auth from "../../middleware/Auth"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({})); + +export default function ConcurrentOptionDialog({ open, onClose, onSave }) { + const { t } = useTranslation(); + const [count, setCount] = useState( + Auth.GetPreferenceWithDefault("concurrent_limit", "5") + ); + const classes = useStyles(); + + return ( + + + {t("uploader.setConcurrent")} + + + + + + {t("uploader.concurrentTaskNumber")} + + setCount(e.target.value)} + /> + + + + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/Copy.js b/src/component/Modals/Copy.js new file mode 100644 index 0000000..438569b --- /dev/null +++ b/src/component/Modals/Copy.js @@ -0,0 +1,156 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import PathSelector from "../FileManager/PathSelector"; +import { useDispatch } from "react-redux"; +import API from "../../middleware/Api"; +import { + refreshFileList, + setModalsLoading, + toggleSnackbar, +} from "../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + contentFix: { + padding: "10px 24px 0px 24px", + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, +})); + +export default function CopyDialog(props) { + const { t } = useTranslation(); + const [selectedPath, setSelectedPath] = useState(""); + const [selectedPathName, setSelectedPathName] = useState(""); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const SetModalsLoading = useCallback( + (status) => { + dispatch(setModalsLoading(status)); + }, + [dispatch] + ); + const RefreshFileList = useCallback(() => { + dispatch(refreshFileList()); + }, [dispatch]); + + const setMoveTarget = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setSelectedPath(path); + setSelectedPathName(folder.name); + }; + + const submitMove = (e) => { + if (e != null) { + e.preventDefault(); + } + SetModalsLoading(true); + const dirs = [], + items = []; + // eslint-disable-next-line + + if (props.selected[0].type === "dir") { + dirs.push(props.selected[0].id); + } else { + items.push(props.selected[0].id); + } + + API.post("/object/copy", { + src_dir: props.selected[0].path, + src: { + dirs: dirs, + items: items, + }, + dst: selectedPath === "//" ? "/" : selectedPath, + }) + .then(() => { + props.onClose(); + RefreshFileList(); + SetModalsLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + SetModalsLoading(false); + }); + }; + + const classes = useStyles(); + + return ( + + + {t("fileManager.copyTo")} + + + + {selectedPath !== "" && ( + + + ]} + /> + + + )} + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/CreateShare.js b/src/component/Modals/CreateShare.js new file mode 100644 index 0000000..8f16cc8 --- /dev/null +++ b/src/component/Modals/CreateShare.js @@ -0,0 +1,656 @@ +import React, { useCallback, useRef } from "react"; +import { + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + Input, + makeStyles, + TextField, +} from "@material-ui/core"; +import { useDispatch, useSelector } from "react-redux"; +import API from "../../middleware/Api"; +import List from "@material-ui/core/List"; +import ListItemText from "@material-ui/core/ListItemText"; +import ListItem from "@material-ui/core/ListItem"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; +import LockIcon from "@material-ui/icons/Lock"; +import TimerIcon from "@material-ui/icons/Timer"; +import CasinoIcon from "@material-ui/icons/Casino"; +import AccountBalanceWalletIcon from "@material-ui/icons/AccountBalanceWallet"; +import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction"; +import Divider from "@material-ui/core/Divider"; +import MuiExpansionPanel from "@material-ui/core/ExpansionPanel"; +import MuiExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary"; +import MuiExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails"; +import Typography from "@material-ui/core/Typography"; +import withStyles from "@material-ui/core/styles/withStyles"; +import InputLabel from "@material-ui/core/InputLabel"; +import { Visibility, VisibilityOff } from "@material-ui/icons"; +import IconButton from "@material-ui/core/IconButton"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import OutlinedInput from "@material-ui/core/OutlinedInput"; +import Tooltip from "@material-ui/core/Tooltip"; +import MenuItem from "@material-ui/core/MenuItem"; +import Select from "@material-ui/core/Select"; +import ToggleIcon from "material-ui-toggle-icon"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + widthAnimation: {}, + shareUrl: { + minWidth: "400px", + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + }, + flexCenter: { + alignItems: "center", + }, + noFlex: { + display: "block", + }, + scoreCalc: { + marginTop: 10, + }, + expireLabel: { + whiteSpace: "nowrap", + }, +})); + +const ExpansionPanel = withStyles({ + root: { + border: "0px solid rgba(0, 0, 0, .125)", + boxShadow: "none", + "&:not(:last-child)": { + borderBottom: 0, + }, + "&:before": { + display: "none", + }, + "&$expanded": { + margin: "auto", + }, + }, + expanded: {}, +})(MuiExpansionPanel); + +const ExpansionPanelSummary = withStyles({ + root: { + padding: 0, + "&$expanded": {}, + }, + content: { + margin: 0, + display: "initial", + "&$expanded": { + margin: "0 0", + }, + }, + expanded: {}, +})(MuiExpansionPanelSummary); + +const ExpansionPanelDetails = withStyles((theme) => ({ + root: { + padding: 24, + backgroundColor: theme.palette.background.default, + }, +}))(MuiExpansionPanelDetails); + +export default function CreatShare(props) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const classes = useStyles(); + + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const scoreEnabled = useSelector((state) => state.siteConfig.score_enabled); + const scoreRate = useSelector((state) => state.siteConfig.share_score_rate); + const lastSubmit = useRef(null); + + const [expanded, setExpanded] = React.useState(false); + const [shareURL, setShareURL] = React.useState(""); + const [values, setValues] = React.useState({ + password: "", + downloads: 1, + expires: 24 * 3600, + showPassword: false, + score: 0, + }); + const [shareOption, setShareOption] = React.useState({ + password: false, + expire: false, + score: false, + preview: true, + }); + const [customExpires, setCustomExpires] = React.useState(3600); + const [customDownloads, setCustomDownloads] = React.useState(10); + + const handleChange = (prop) => (event) => { + // 输入密码 + if (prop === "password") { + if (event.target.value === "") { + setShareOption({ ...shareOption, password: false }); + } else { + setShareOption({ ...shareOption, password: true }); + } + } + + // 输入积分 + if (prop === "score") { + if (event.target.value == "0") { + setShareOption({ ...shareOption, score: false }); + } else { + setShareOption({ ...shareOption, score: true }); + } + } + + setValues({ ...values, [prop]: event.target.value }); + }; + + const handleClickShowPassword = () => { + setValues({ ...values, showPassword: !values.showPassword }); + }; + + const handleMouseDownPassword = (event) => { + event.preventDefault(); + }; + + const randomPassword = () => { + setShareOption({ ...shareOption, password: true }); + setValues({ + ...values, + password: Math.random().toString(36).substr(2).slice(2, 8), + showPassword: true, + }); + }; + + const handleExpand = (panel) => (event, isExpanded) => { + setExpanded(isExpanded ? panel : false); + }; + + const handleCheck = (prop) => () => { + if (!shareOption[prop]) { + handleExpand(prop)(null, true); + } + if (prop === "password" && shareOption[prop]) { + setValues({ + ...values, + password: "", + }); + } + if (prop === "score" && shareOption[prop]) { + setValues({ + ...values, + score: 0, + }); + } + setShareOption({ ...shareOption, [prop]: !shareOption[prop] }); + }; + + const onClose = () => { + props.onClose(); + setTimeout(() => { + setShareURL(""); + }, 500); + }; + + const senLink = () => { + if (navigator.share) { + let text = t("modals.shareLinkShareContent", { + name: props.selected[0].name, + link: shareURL, + }); + if (lastSubmit.current && lastSubmit.current.password) { + text += t("modals.shareLinkPasswordInfo", { + password: lastSubmit.current.password, + }); + } + navigator.share({ text }); + } else if (navigator.clipboard) { + navigator.clipboard.writeText(shareURL); + ToggleSnackbar("top", "right", t("modals.linkCopied"), "info"); + } + }; + + const submitShare = (e) => { + e.preventDefault(); + props.setModalsLoading(true); + const submitFormBody = { + id: props.selected[0].id, + is_dir: props.selected[0].type === "dir", + password: values.password, + downloads: shareOption.expire + ? values.downloads === -1 + ? parseInt(customDownloads) + : values.downloads + : -1, + expire: + values.expires === -1 + ? parseInt(customExpires) + : values.expires, + score: parseInt(values.score), + preview: shareOption.preview, + }; + lastSubmit.current = submitFormBody; + + API.post("/share", submitFormBody) + .then((response) => { + setShareURL(response.data); + setValues({ + password: "", + downloads: 1, + expires: 24 * 3600, + showPassword: false, + score: 0, + }); + setShareOption({ + password: false, + expire: false, + score: false, + }); + props.setModalsLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + props.setModalsLoading(false); + }); + }; + + const handleFocus = (event) => event.target.select(); + + return ( + + + {t("modals.createShareLink")} + + + {shareURL === "" && ( + <> + + + + + + + + + + + + + + + + + + {t("modals.sharePassword")} + + + + + + + + + } + offIcon={ + + } + /> + + + } + labelWidth={70} + /> + + + + + + + + + + + + + + + + + + {values.downloads >= 0 && ( + + )} + {values.downloads === -1 && ( + + setCustomDownloads( + e.target.value + ) + } + endAdornment={ + + {t("modals.downloads")} + + } + /> + )} + + + {t("modals.or")} + + + {values.expires >= 0 && ( + + )} + {values.expires === -1 && ( + + setCustomExpires(e.target.value) + } + endAdornment={ + + {t("modals.seconds")} + + } + /> + )} + + + {t("modals.downloadSuffix")} + + + + {scoreEnabled && ( + + + + + + + + + + + + + + + + + {t("vas.creditToBePaid")} + + + + {values.score !== 0 && scoreRate !== "100" && ( + + {t("vas.creditGainPredict", { + num: Math.ceil( + (values.score * scoreRate) / + 100 + ), + })} + + )} + + + )} + + + + + + + + + + + + + + + {t("modals.allowPreviewDescription")} + + + + + + + )} + {shareURL !== "" && ( + + + + )} + + + {shareURL !== "" && ( +
+ +
+ )} + + + {shareURL === "" && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/component/Modals/CreateWebDAVAccount.js b/src/component/Modals/CreateWebDAVAccount.js new file mode 100644 index 0000000..a82d136 --- /dev/null +++ b/src/component/Modals/CreateWebDAVAccount.js @@ -0,0 +1,155 @@ +import React, { useState } from "react"; +import { Dialog, makeStyles } from "@material-ui/core"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import TextField from "@material-ui/core/TextField"; +import { FolderOpenOutlined, LabelOutlined } from "@material-ui/icons"; +import PathSelector from "../FileManager/PathSelector"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + formGroup: { + display: "flex", + marginTop: theme.spacing(1), + }, + formIcon: { + marginTop: 21, + marginRight: 19, + color: theme.palette.text.secondary, + }, + input: { + width: 250, + }, + dialogContent: { + paddingTop: 24, + paddingRight: 24, + paddingBottom: 8, + paddingLeft: 24, + }, + button: { + marginTop: 8, + }, +})); + +export default function CreateWebDAVAccount(props) { + const { t } = useTranslation(); + const [value, setValue] = useState({ + name: "", + path: "/", + }); + const [pathSelectDialog, setPathSelectDialog] = React.useState(false); + const [selectedPath, setSelectedPath] = useState(""); + // eslint-disable-next-line + const [selectedPathName, setSelectedPathName] = useState(""); + const classes = useStyles(); + + const setMoveTarget = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setSelectedPath(path); + setSelectedPathName(folder.name); + }; + + const handleInputChange = (name) => (e) => { + setValue({ + ...value, + [name]: e.target.value, + }); + }; + + const selectPath = () => { + setValue({ + ...value, + path: selectedPath === "//" ? "/" : selectedPath, + }); + setPathSelectDialog(false); + }; + + return ( + + setPathSelectDialog(false)} + aria-labelledby="form-dialog-title" + > + + {t("navbar.addTagDialog.selectFolder")} + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+ +
+
+ +
+ +
+
+
+
+ + + + +
+ ); +} diff --git a/src/component/Modals/CreateWebDAVMount.js b/src/component/Modals/CreateWebDAVMount.js new file mode 100644 index 0000000..a6f5466 --- /dev/null +++ b/src/component/Modals/CreateWebDAVMount.js @@ -0,0 +1,192 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Dialog, makeStyles } from "@material-ui/core"; +import API from "../../middleware/Api"; +import { useDispatch } from "react-redux"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import TextField from "@material-ui/core/TextField"; +import FormControl from "@material-ui/core/FormControl"; +import { FolderOpenOutlined, Storage } from "@material-ui/icons"; +import PathSelector from "../FileManager/PathSelector"; +import Select from "@material-ui/core/Select"; +import InputLabel from "@material-ui/core/InputLabel"; +import MenuItem from "@material-ui/core/MenuItem"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + formGroup: { + display: "flex", + marginTop: theme.spacing(1), + }, + formIcon: { + marginTop: 21, + marginRight: 19, + color: theme.palette.text.secondary, + }, + input: { + width: 250, + }, + dialogContent: { + paddingTop: 24, + paddingRight: 24, + paddingBottom: 8, + paddingLeft: 24, + }, + button: { + marginTop: 8, + }, +})); + +export default function CreateWebDAVMount(props) { + const { t } = useTranslation(); + const [value, setValue] = useState({ + policy: "", + path: "/", + }); + const [policies, setPolicies] = useState([]); + const [pathSelectDialog, setPathSelectDialog] = React.useState(false); + const [selectedPath, setSelectedPath] = useState(""); + // eslint-disable-next-line + const [selectedPathName, setSelectedPathName] = useState(""); + + const classes = useStyles(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const setMoveTarget = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setSelectedPath(path); + setSelectedPathName(folder.name); + }; + + const handleInputChange = (name) => (e) => { + setValue({ + ...value, + [name]: e.target.value, + }); + }; + + const selectPath = () => { + setValue({ + ...value, + path: selectedPath === "//" ? "/" : selectedPath, + }); + setPathSelectDialog(false); + }; + + useEffect(() => { + API.get("/user/setting/policies") + .then((response) => { + setPolicies(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, []); + + return ( + + setPathSelectDialog(false)} + aria-labelledby="form-dialog-title" + > + + {t("navbar.addTagDialog.selectFolder")} + + + + + + + + +
+
+
+
+ +
+ + + {t("fileManager.storagePolicy")} + + + +
+
+
+ +
+
+ +
+ +
+
+
+
+ + + + +
+ ); +} diff --git a/src/component/Modals/Decompress.js b/src/component/Modals/Decompress.js new file mode 100644 index 0000000..44ee371 --- /dev/null +++ b/src/component/Modals/Decompress.js @@ -0,0 +1,141 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import PathSelector from "../FileManager/PathSelector"; +import { useDispatch } from "react-redux"; +import { setModalsLoading, toggleSnackbar } from "../../redux/explorer"; +import { submitDecompressTask } from "../../redux/explorer/action"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + contentFix: { + padding: "10px 24px 0px 24px", + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, +})); + +export default function DecompressDialog(props) { + const { t } = useTranslation(); + const [selectedPath, setSelectedPath] = useState(""); + const [selectedPathName, setSelectedPathName] = useState(""); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const SetModalsLoading = useCallback( + (status) => { + dispatch(setModalsLoading(status)); + }, + [dispatch] + ); + const SubmitDecompressTask = useCallback( + (path) => dispatch(submitDecompressTask(path)), + [dispatch] + ); + + const setMoveTarget = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setSelectedPath(path); + setSelectedPathName(folder.name); + }; + + const submitMove = (e) => { + if (e != null) { + e.preventDefault(); + } + SetModalsLoading(true); + SubmitDecompressTask(selectedPath) + .then(() => { + props.onClose(); + ToggleSnackbar( + "top", + "right", + t("modals.taskCreated"), + "success" + ); + SetModalsLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + SetModalsLoading(false); + }); + }; + + const classes = useStyles(); + + return ( + + + {t("modals.decompressTo")} + + + + {selectedPath !== "" && ( + + + ]} + /> + + + )} + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/Delete.js b/src/component/Modals/Delete.js new file mode 100644 index 0000000..d9f6750 --- /dev/null +++ b/src/component/Modals/Delete.js @@ -0,0 +1,173 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + makeStyles, + Tooltip, +} from "@material-ui/core"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { useTheme } from "@material-ui/core/styles"; +import Auth from "../../middleware/Auth"; +import API from "../../middleware/Api"; +import FormLabel from "@material-ui/core/FormLabel"; +import FormGroup from "@material-ui/core/FormGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Checkbox from "@material-ui/core/Checkbox"; + +const useStyles = makeStyles((theme) => ({ + form: { + marginTop: theme.spacing(2), + }, +})); + +export default function Delete(props) { + const { t } = useTranslation(); + const theme = useTheme(); + const user = Auth.GetUser(); + const [force, setForce] = useState(false); + const [unlink, setUnlink] = useState(false); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const submitRemove = (e) => { + e.preventDefault(); + props.setModalsLoading(true); + const dirs = [], + items = []; + // eslint-disable-next-line + props.selected.map((value) => { + if (value.type === "dir") { + dirs.push(value.id); + } else { + items.push(value.id); + } + }); + API.delete("/object", { + data: { + items: items, + dirs: dirs, + force, + unlink, + }, + }) + .then((response) => { + if (response.rawData.code === 0) { + props.onClose(); + setTimeout(props.refreshFileList, 500); + } else { + ToggleSnackbar( + "top", + "right", + response.rawData.msg, + "warning" + ); + } + props.setModalsLoading(false); + props.refreshStorage(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + props.setModalsLoading(false); + }); + }; + + const classes = useStyles(); + + return ( + + + {t("modals.deleteTitle")} + + + + + {props.selected.length === 1 && ( + ]} + /> + )} + {props.selected.length > 1 && + t("modals.deleteMultipleDescription", { + num: props.selected.length, + })} + + {user.group.advanceDelete && ( + + + {t("modals.advanceOptions")} + + + + + setForce(e.target.checked) + } + /> + } + label={t("modals.forceDelete")} + /> + + + + setUnlink(e.target.checked) + } + /> + } + label={t("modals.unlinkOnly")} + /> + + + + )} + + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/DirectoryDownload.js b/src/component/Modals/DirectoryDownload.js new file mode 100644 index 0000000..f301f5f --- /dev/null +++ b/src/component/Modals/DirectoryDownload.js @@ -0,0 +1,118 @@ +import React, { useState, useEffect, useRef } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, + FormControlLabel, + Checkbox, +} from "@material-ui/core"; +import TextField from "@material-ui/core/TextField"; +import { useTranslation } from "react-i18next"; +import { useInterval, usePrevious, useGetState } from "ahooks"; +import { cancelDirectoryDownload } from "../../redux/explorer/action"; +import Auth from "../../middleware/Auth"; + +const useStyles = makeStyles((theme) => ({ + contentFix: { + padding: "10px 24px 0px 24px", + backgroundColor: theme.palette.background.default, + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, +})); + +export default function DirectoryDownloadDialog(props) { + const { t } = useTranslation(); + + const classes = useStyles(); + + const logRef = useRef(); + const [autoScroll, setAutoScroll] = useState( + Auth.GetPreferenceWithDefault("autoScroll", true) + ); + const previousLog = usePrevious(props.log, (prev, next) => true); + const [timer, setTimer] = useState(-1); + + useInterval(() => { + if (autoScroll && logRef.current && previousLog !== props.log) { + logRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); + } + }, timer); + + useEffect(() => { + if (props.done) { + setTimer(-1); + } else if (props.open) { + setTimer(1000); + } + }, [props.done, props.open]); + + return ( + + + {t("modals.directoryDownloadTitle")} + + + + + + + } + checked={autoScroll} + onChange={() => + setAutoScroll((previous) => { + Auth.SetPreference("autoScroll", !previous); + return !previous; + }) + } + label={t("modals.directoryDownloadAutoscroll")} + /> + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/Loading.js b/src/component/Modals/Loading.js new file mode 100644 index 0000000..042a898 --- /dev/null +++ b/src/component/Modals/Loading.js @@ -0,0 +1,39 @@ +import React from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import DialogContent from "@material-ui/core/DialogContent"; +import Dialog from "@material-ui/core/Dialog"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import { blue } from "@material-ui/core/colors"; +import { useSelector } from "react-redux"; + +const useStyles = makeStyles({ + avatar: { + backgroundColor: blue[100], + color: blue[600], + }, + loadingContainer: { + display: "flex", + }, + loading: { + marginTop: 10, + marginLeft: 20, + }, +}); + +export default function LoadingDialog() { + const classes = useStyles(); + const open = useSelector((state) => state.viewUpdate.modals.loading); + const text = useSelector((state) => state.viewUpdate.modals.loadingText); + + return ( + + + + +
{text}
+
+
+
+ ); +} diff --git a/src/component/Modals/OptionSelector.js b/src/component/Modals/OptionSelector.js new file mode 100644 index 0000000..00fc7aa --- /dev/null +++ b/src/component/Modals/OptionSelector.js @@ -0,0 +1,60 @@ +import React from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + ListItemText, + makeStyles, +} from "@material-ui/core"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + content: { + minWidth: 250, + }, +})); + +export default function OptionSelector() { + const { t } = useTranslation("common"); + const classes = useStyles(); + const option = useSelector((state) => state.viewUpdate.modals.option); + + return ( + + + {option && option.title} + + + + {option && + option.options.map((o) => ( + option && option.callback(o)} + button + > + + + ))} + + + + + + + ); +} diff --git a/src/component/Modals/PurchaseShare.js b/src/component/Modals/PurchaseShare.js new file mode 100644 index 0000000..58b86d7 --- /dev/null +++ b/src/component/Modals/PurchaseShare.js @@ -0,0 +1,46 @@ +import React from "react"; +import { Dialog } from "@material-ui/core"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogActions from "@material-ui/core/DialogActions"; +import Button from "@material-ui/core/Button"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; + +export default function PurchaseShareDialog() { + const { t } = useTranslation(); + const purchase = useSelector((state) => state.explorer.purchase); + return ( + <> + {purchase && ( + + + {t("vas.sharePurchaseTitle", { score: purchase.score })} + + + + {t("vas.sharePurchaseDescription")} + + + + + + + + )} + + ); +} diff --git a/src/component/Modals/Relocate.js b/src/component/Modals/Relocate.js new file mode 100644 index 0000000..e3ab36f --- /dev/null +++ b/src/component/Modals/Relocate.js @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import { useDispatch, useSelector } from "react-redux"; +import API from "../../middleware/Api"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import { setModalsLoading, toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, + input: { + width: 250, + }, +})); + +export default function RelocateDialog(props) { + const { t } = useTranslation(); + const [selectedPolicy, setSelectedPolicy] = useState(""); + const [policies, setPolicies] = useState([]); + const dispatch = useDispatch(); + const policy = useSelector((state) => state.explorer.currentPolicy); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const SetModalsLoading = useCallback( + (status) => { + dispatch(setModalsLoading(status)); + }, + [dispatch] + ); + + const submitRelocate = (e) => { + if (e != null) { + e.preventDefault(); + } + SetModalsLoading(true); + + const dirs = [], + items = []; + // eslint-disable-next-line + props.selected.map((value) => { + if (value.type === "dir") { + dirs.push(value.id); + } else { + items.push(value.id); + } + }); + + API.post("/file/relocate", { + src: { + dirs: dirs, + items: items, + }, + dst_policy_id: selectedPolicy, + }) + .then(() => { + props.onClose(); + ToggleSnackbar( + "top", + "right", + t("modals.taskCreated"), + "success" + ); + SetModalsLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + SetModalsLoading(false); + }); + }; + + useEffect(() => { + if (props.open) { + API.get("/user/setting/policies") + .then((response) => { + setPolicies(response.data); + setSelectedPolicy(policy.id); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + } + + // eslint-disable-next-line + }, [props.open]); + + const classes = useStyles(); + + return ( + + + {t("vas.migrateStoragePolicy")} + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/RemoteDownload.js b/src/component/Modals/RemoteDownload.js new file mode 100644 index 0000000..fcfa953 --- /dev/null +++ b/src/component/Modals/RemoteDownload.js @@ -0,0 +1,380 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + makeStyles, + MenuItem, + TextField, +} from "@material-ui/core"; +import PathSelector from "../FileManager/PathSelector"; +import { useDispatch } from "react-redux"; +import API, { AppError } from "../../middleware/Api"; +import { setModalsLoading, toggleSnackbar } from "../../redux/explorer"; +import { Trans, useTranslation } from "react-i18next"; +import { DnsOutlined, FolderOpenOutlined } from "@material-ui/icons"; +import { pathBack } from "../../utils"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import { useTheme } from "@material-ui/core/styles"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; +import LinkIcon from "@material-ui/icons/Link"; +import Auth from "../../middleware/Auth"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; + +const useStyles = makeStyles((theme) => ({ + contentFix: { + padding: "10px 24px 0px 24px", + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, + formGroup: { + display: "flex", + marginBottom: theme.spacing(3), + }, + forumInput: { + flexGrow: 1, + }, +})); + +export default function RemoteDownload(props) { + const { t } = useTranslation(); + const [selectPathOpen, setSelectPathOpen] = useState(false); + const [selectedPath, setSelectedPath] = useState(""); + const [selectedPathName, setSelectedPathName] = useState(""); + const [downloadTo, setDownloadTo] = useState(""); + const [url, setUrl] = useState(""); + const [nodes, setNodes] = useState([]); + const [nodesLoading, setNodesLoading] = useState(false); + const [preferredNode, setPreferredNode] = useState(0); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const user = Auth.GetUser(); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + if (props.open) { + setDownloadTo(props.presentPath); + + if (user.group.selectNode && nodes.length === 0) { + setNodesLoading(true); + API.get("/user/setting/nodes") + .then((response) => { + setNodes(response.data); + setNodesLoading(false); + }) + .catch(() => { + setNodes([]); + setNodesLoading(false); + }); + } + } + }, [props.open]); + + const setDownloadToPath = (folder) => { + const path = + folder.path === "/" + ? folder.path + folder.name + : folder.path + "/" + folder.name; + setSelectedPath(path); + setSelectedPathName(folder.name); + }; + + const selectPath = () => { + setDownloadTo(selectedPath === "//" ? "/" : selectedPath); + setSelectPathOpen(false); + }; + + const submitTorrentDownload = (e) => { + e.preventDefault(); + props.setModalsLoading(true); + API.post("/aria2/torrent/" + props.torrent.id, { + dst: downloadTo === "//" ? "/" : downloadTo, + preferred_node: preferredNode, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + t("modals.taskCreated"), + "success" + ); + props.onClose(); + props.setModalsLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "warning"); + props.setModalsLoading(false); + }); + }; + + const submitDownload = (e) => { + e.preventDefault(); + props.setModalsLoading(true); + API.post("/aria2/url", { + url: url.split("\n"), + dst: downloadTo === "//" ? "/" : downloadTo, + preferred_node: preferredNode, + }) + .then((response) => { + const failed = response.data + .filter((r) => r.code !== 0) + .map((r) => new AppError(r.msg, r.code, r.error).message); + if (failed.length > 0) { + ToggleSnackbar( + "top", + "right", + t("modals.taskCreateFailed", { + failed: failed.length, + details: failed.join(","), + }), + "warning" + ); + } else { + ToggleSnackbar( + "top", + "right", + t("modals.taskCreated"), + "success" + ); + } + + props.onClose(); + props.setModalsLoading(false); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + props.setModalsLoading(false); + }); + }; + + const classes = useStyles(); + + return ( + <> + + + {t("modals.newRemoteDownloadTitle")} + + + +
+
+ setUrl(e.target.value)} + placeholder={t( + "modals.remoteDownloadURLDescription" + )} + InputProps={{ + startAdornment: !isMobile && ( + + + + ), + }} + /> +
+
+
+
+ + setDownloadTo(e.target.value) + } + className={classes.input} + label={t("modals.remoteDownloadDst")} + InputProps={{ + startAdornment: !isMobile && ( + + + + ), + endAdornment: ( + + + + ), + }} + /> +
+
+
+ {user.group.selectNode && ( +
+
+ + + {t("modals.remoteDownloadNode")} + + + +
+
+
+ )} +
+
+ + +
+ +
+
+
+ + setSelectPathOpen(false)} + aria-labelledby="form-dialog-title" + > + + {t("modals.remoteDownloadDst")} + + + + {selectedPathName !== "" && ( + + + ]} + /> + + + )} + + + + + + + ); +} diff --git a/src/component/Modals/Report.js b/src/component/Modals/Report.js new file mode 100644 index 0000000..18e36fd --- /dev/null +++ b/src/component/Modals/Report.js @@ -0,0 +1,148 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + makeStyles, + TextField, +} from "@material-ui/core"; +import { useDispatch, useSelector } from "react-redux"; +import API from "../../middleware/Api"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import { reportReasons } from "../../config"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + widthAnimation: {}, + shareUrl: { + minWidth: "400px", + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + }, + flexCenter: { + alignItems: "center", + }, + noFlex: { + display: "block", + }, + scoreCalc: { + marginTop: 10, + }, +})); + +export default function Report(props) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const classes = useStyles(); + const [reason, setReason] = useState("0"); + const [des, setDes] = useState(""); + const [loading, setLoading] = useState(false); + + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const reportEnabled = useSelector( + (state) => state.siteConfig.report_enabled + ); + + const onClose = () => { + props.onClose(); + setTimeout(() => { + setDes(""); + setReason("0"); + }, 500); + }; + + const submitReport = () => { + setLoading(true); + API.post("/share/report/" + props.share.key, { + des: des, + reason: parseInt(reason), + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + t("vas.reportSuccessful"), + "success" + ); + setLoading(false); + onClose(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + setLoading(false); + }); + }; + + return ( + + {t("vas.report")} + + + setReason(e.target.value)} + > + {reportReasons.map((v, k) => ( + } + label={t(v)} + /> + ))} + + + setDes(e.target.value)} + variant="filled" + rows={4} + /> + + + + + + + + ); +} diff --git a/src/component/Modals/SelectFile.js b/src/component/Modals/SelectFile.js new file mode 100644 index 0000000..e054f0a --- /dev/null +++ b/src/component/Modals/SelectFile.js @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import FormGroup from "@material-ui/core/FormGroup"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Checkbox from "@material-ui/core/Checkbox"; +import MenuItem from "@material-ui/core/MenuItem"; +import { Virtuoso } from "react-virtuoso"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + contentFix: { + padding: "10px 24px 0px 24px", + }, + wrapper: { + margin: theme.spacing(1), + position: "relative", + }, + buttonProgress: { + color: theme.palette.secondary.light, + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, + content: { + padding: 0, + }, + scroll: { + maxHeight: "calc(100vh - 200px)", + }, +})); + +export default function SelectFileDialog(props) { + const { t } = useTranslation(); + const [files, setFiles] = useState(props.files); + + useEffect(() => { + setFiles(props.files); + }, [props.files]); + + const handleChange = (index) => (event) => { + const filesCopy = [...files]; + // eslint-disable-next-line + filesCopy.map((v, k) => { + if (v.index === index) { + filesCopy[k] = { + ...filesCopy[k], + selected: event.target.checked ? "true" : "false", + }; + } + }); + setFiles(filesCopy); + }; + + const submit = () => { + const index = []; + // eslint-disable-next-line + files.map((v) => { + if (v.selected === "true") { + index.push(parseInt(v.index)); + } + }); + props.onSubmit(index); + }; + + const classes = useStyles(); + + return ( + + + {t("download.selectDownloadingFile")} + + + ( + + + + } + label={v.path} + /> + + + )} + /> + + + +
+ +
+
+
+ ); +} diff --git a/src/component/Modals/SiteNotice.js b/src/component/Modals/SiteNotice.js new file mode 100644 index 0000000..fdd9fb0 --- /dev/null +++ b/src/component/Modals/SiteNotice.js @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import { useSelector } from "react-redux"; +import Auth from "../../middleware/Auth"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + widthAnimation: {}, + content: { + overflowWrap: "break-word", + }, +})); + +export default function SiteNotice() { + const { t } = useTranslation(); + const content = useSelector((state) => state.siteConfig.site_notice); + const classes = useStyles(); + const [show, setShow] = useState(false); + const setRead = () => { + setShow(false); + Auth.SetPreference("notice_read", content); + }; + useEffect(() => { + const newNotice = Auth.GetPreference("notice_read"); + if (content !== "" && newNotice !== content) { + setShow(true); + } + }, [content]); + return ( + setShow(false)} + aria-labelledby="form-dialog-title" + maxWidth="sm" + fullWidth + > + + {t("vas.announcement")} + + + + + + + + + ); +} diff --git a/src/component/Modals/TimeZone.js b/src/component/Modals/TimeZone.js new file mode 100644 index 0000000..2459f85 --- /dev/null +++ b/src/component/Modals/TimeZone.js @@ -0,0 +1,89 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import { useDispatch } from "react-redux"; +import TextField from "@material-ui/core/TextField"; +import { + refreshTimeZone, + timeZone, + validateTimeZone, +} from "../../utils/datetime"; +import FormControl from "@material-ui/core/FormControl"; +import Auth from "../../middleware/Auth"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({})); + +export default function TimeZoneDialog(props) { + const { t } = useTranslation(); + const [timeZoneValue, setTimeZoneValue] = useState(timeZone); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const saveZoneInfo = () => { + if (!validateTimeZone(timeZoneValue)) { + ToggleSnackbar("top", "right", "无效的时区名称", "warning"); + return; + } + Auth.SetPreference("timeZone", timeZoneValue); + refreshTimeZone(); + props.onClose(); + }; + + const classes = useStyles(); + + return ( + + + {t("setting.timeZone")} + + + + + setTimeZoneValue(e.target.value)} + /> + + + + + +
+ +
+
+
+ ); +} diff --git a/src/component/Navbar/DarkModeSwitcher.js b/src/component/Navbar/DarkModeSwitcher.js new file mode 100644 index 0000000..0757a94 --- /dev/null +++ b/src/component/Navbar/DarkModeSwitcher.js @@ -0,0 +1,56 @@ +import React, { useCallback } from "react"; +import { IconButton, makeStyles } from "@material-ui/core"; +import DayIcon from "@material-ui/icons/Brightness7"; +import NightIcon from "@material-ui/icons/Brightness4"; +import { useDispatch, useSelector } from "react-redux"; +import Tooltip from "@material-ui/core/Tooltip"; +import Auth from "../../middleware/Auth"; +import classNames from "classnames"; +import { toggleDaylightMode } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles(() => ({ + icon: { + color: "rgb(255, 255, 255)", + opacity: "0.54", + }, +})); + +const DarkModeSwitcher = ({ position }) => { + const { t } = useTranslation(); + const ThemeType = useSelector( + (state) => state.siteConfig.theme.palette.type + ); + const dispatch = useDispatch(); + const ToggleThemeMode = useCallback(() => dispatch(toggleDaylightMode()), [ + dispatch, + ]); + const isDayLight = (ThemeType && ThemeType === "light") || !ThemeType; + const isDark = ThemeType && ThemeType === "dark"; + const toggleMode = () => { + Auth.SetPreference("theme_mode", isDayLight ? "dark" : "light"); + ToggleThemeMode(); + }; + const classes = useStyles(); + return ( + + + {isDayLight && } + {isDark && } + + + ); +}; + +export default DarkModeSwitcher; diff --git a/src/component/Navbar/FileTags.js b/src/component/Navbar/FileTags.js new file mode 100644 index 0000000..8bb014c --- /dev/null +++ b/src/component/Navbar/FileTags.js @@ -0,0 +1,407 @@ +import React, { Suspense, useCallback, useState } from "react"; +import { + Divider, + List, + ListItemIcon, + ListItemText, + makeStyles, + withStyles, +} from "@material-ui/core"; +import { Clear, KeyboardArrowRight } from "@material-ui/icons"; +import classNames from "classnames"; +import FolderShared from "@material-ui/icons/FolderShared"; +import UploadIcon from "@material-ui/icons/CloudUpload"; +import VideoIcon from "@material-ui/icons/VideoLibraryOutlined"; +import ImageIcon from "@material-ui/icons/CollectionsOutlined"; +import MusicIcon from "@material-ui/icons/LibraryMusicOutlined"; +import DocIcon from "@material-ui/icons/FileCopyOutlined"; +import { useHistory, useLocation } from "react-router"; +import pathHelper from "../../utils/page"; +import MuiExpansionPanel from "@material-ui/core/ExpansionPanel"; +import MuiExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary"; +import MuiExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails"; +import MuiListItem from "@material-ui/core/ListItem"; +import { useDispatch } from "react-redux"; +import Auth from "../../middleware/Auth"; +import { + Circle, + CircleOutline, + FolderHeartOutline, + Heart, + HeartOutline, + Hexagon, + HexagonOutline, + Hexagram, + HexagramOutline, + Rhombus, + RhombusOutline, + Square, + SquareOutline, + TagPlus, + Triangle, + TriangleOutline, +} from "mdi-material-ui"; +import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction"; +import IconButton from "@material-ui/core/IconButton"; +import API from "../../middleware/Api"; +import { navigateTo, searchMyFile, toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const ListItem = withStyles((theme) => ({ + root: { + borderRadius:theme.shape.borderRadius, + }, +}))(MuiListItem); + +const ExpansionPanel = withStyles({ + root: { + maxWidth: "100%", + boxShadow: "none", + "&:not(:last-child)": { + borderBottom: 0, + }, + "&:before": { + display: "none", + }, + "&$expanded": { margin: 0 }, + }, + expanded: {}, +})(MuiExpansionPanel); + +const ExpansionPanelSummary = withStyles((theme) =>({ + root: { + minHeight: 0, + padding: 0, + "&$expanded": { + minHeight: 0, + }, + }, + content: { + maxWidth: "100%", + margin: 0, + display: "block", + "&$expanded": { + margin: "0", + }, + }, + expanded: {}, +}))(MuiExpansionPanelSummary); + +const ExpansionPanelDetails = withStyles((theme) => ({ + root: { + display: "block", + padding: theme.spacing(0), + }, +}))(MuiExpansionPanelDetails); + +const useStyles = makeStyles((theme) => ({ + expand: { + display: "none", + transition: ".15s all ease-in-out", + }, + expanded: { + display: "block", + transform: "rotate(90deg)", + }, + iconFix: { + marginLeft: "16px", + }, + hiddenButton: { + display: "none", + }, + subMenu: { + marginLeft: theme.spacing(2), + }, + overFlow: { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, + paddingList:{ + padding:theme.spacing(1), + }, + paddingSummary:{ + paddingLeft:theme.spacing(1), + paddingRight:theme.spacing(1), + } +})); + +const icons = { + Circle: Circle, + CircleOutline: CircleOutline, + Heart: Heart, + HeartOutline: HeartOutline, + Hexagon: Hexagon, + HexagonOutline: HexagonOutline, + Hexagram: Hexagram, + HexagramOutline: HexagramOutline, + Rhombus: Rhombus, + RhombusOutline: RhombusOutline, + Square: Square, + SquareOutline: SquareOutline, + Triangle: Triangle, + TriangleOutline: TriangleOutline, + FolderHeartOutline: FolderHeartOutline, +}; + +const AddTag = React.lazy(() => import("../Modals/AddTag")); + +export default function FileTag() { + const classes = useStyles(); + const { t } = useTranslation(); + + const location = useLocation(); + const history = useHistory(); + + const isHomePage = pathHelper.isHomePage(location.pathname); + + const [tagOpen, setTagOpen] = useState(true); + const [addTagModal, setAddTagModal] = useState(false); + const [tagHover, setTagHover] = useState(null); + const [tags, setTags] = useState( + Auth.GetUser().tags ? Auth.GetUser().tags : [] + ); + + const dispatch = useDispatch(); + const SearchMyFile = useCallback((k, p) => dispatch(searchMyFile(k, p)), [ + dispatch, + ]); + const NavigateTo = useCallback((k) => dispatch(navigateTo(k)), [dispatch]); + + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const getIcon = (icon, color) => { + if (icons[icon]) { + const IconComponent = icons[icon]; + return ( + + ); + } + return ; + }; + + const submitSuccess = (tag) => { + const newTags = [...tags, tag]; + setTags(newTags); + const user = Auth.GetUser(); + user.tags = newTags; + Auth.SetUser(user); + }; + + const submitDelete = (id) => { + API.delete("/tag/" + id) + .then(() => { + const newTags = tags.filter((v) => { + return v.id !== id; + }); + setTags(newTags); + const user = Auth.GetUser(); + user.tags = newTags; + Auth.SetUser(user); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + return ( + <> + + setAddTagModal(false)} + /> + + isHomePage && setTagOpen(!tagOpen)} + > + +
+ + !isHomePage && history.push("/home?path=%2F") + } + > + + + {!(tagOpen && isHomePage) && ( + + )} + + + +
+ + +
+ + + setTagHover(null)}> + + + + + + + + + + + + + {[ + { + key: t("navbar.videos"), + id: "video", + icon: ( + + ), + }, + { + key: t("navbar.photos"), + id: "image", + icon: ( + + ), + }, + { + key: t("navbar.music"), + id: "audio", + icon: ( + + ), + }, + { + key: t("navbar.documents"), + id: "doc", + icon: ( + + ), + }, + ].map((v) => ( + + SearchMyFile(v.id + "/internal", "") + } + > + + {v.icon} + + + + ))} + {tags.map((v) => ( + setTagHover(v.id)} + onClick={() => { + if (v.type === 0) { + SearchMyFile("tag/" + v.id, ""); + } else { + NavigateTo(v.expression); + } + }} + > + + {getIcon( + v.type === 0 + ? v.icon + : "FolderHeartOutline", + v.type === 0 ? v.color : null + )} + + + + {tagHover === v.id && ( + submitDelete(v.id)} + > + + + + + )} + + ))} + + setAddTagModal(true)}> + + + + + + {" "} + + +
+ + ); +} diff --git a/src/component/Navbar/Navbar.js b/src/component/Navbar/Navbar.js new file mode 100644 index 0000000..3a534b0 --- /dev/null +++ b/src/component/Navbar/Navbar.js @@ -0,0 +1,984 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { connect } from "react-redux"; +import ShareIcon from "@material-ui/icons/Share"; +import MusicNote from "@material-ui/icons/MusicNote"; +import BackIcon from "@material-ui/icons/ArrowBack"; +import SdStorage from "@material-ui/icons/SdStorage"; +import OpenIcon from "@material-ui/icons/OpenInNew"; +import DownloadIcon from "@material-ui/icons/CloudDownload"; +import RenameIcon from "@material-ui/icons/BorderColor"; +import MoveIcon from "@material-ui/icons/Input"; +import DeleteIcon from "@material-ui/icons/Delete"; +import MenuIcon from "@material-ui/icons/Menu"; +import { isPreviewable } from "../../config"; +import { changeThemeColor, sizeToString, vhCheck } from "../../utils"; +import Uploader from "../Uploader/Uploader.js"; +import pathHelper from "../../utils/page"; +import SezrchBar from "./SearchBar"; +import StorageBar from "./StorageBar"; +import UserAvatar from "./UserAvatar"; +import UserInfo from "./UserInfo"; +import { + FolderDownload, + AccountArrowRight, + AccountPlus, + LogoutVariant, +} from "mdi-material-ui"; +import { withRouter } from "react-router-dom"; +import { + AppBar, + Drawer, + Grow, + Hidden, + IconButton, + List, + ListItemIcon, + ListItemText, + SwipeableDrawer, + Toolbar, + Tooltip, + Typography, + withStyles, + withTheme +} from "@material-ui/core"; +import Auth from "../../middleware/Auth"; +import API from "../../middleware/Api"; +import FileTag from "./FileTags"; +import { Assignment, Devices, MoreHoriz, Settings } from "@material-ui/icons"; +import Divider from "@material-ui/core/Divider"; +import SubActions from "../FileManager/Navigator/SubActions"; +import { + audioPreviewSetIsOpen, + changeContextMenu, + drawerToggleAction, + navigateTo, + openCreateFolderDialog, + openLoadingDialog, + openMoveDialog, + openMusicDialog, + openPreview, + openRemoveDialog, + openRenameDialog, + openShareDialog, + saveFile, + setSelectedTarget, + setSessionStatus, + showImgPreivew, + toggleSnackbar, +} from "../../redux/explorer"; +import { + startBatchDownload, + startDirectoryDownload, + startDownload, +} from "../../redux/explorer/action"; +import PolicySwitcher from "./PolicySwitcher"; +import { withTranslation } from "react-i18next"; +import MuiListItem from "@material-ui/core/ListItem"; + +vhCheck(); +const drawerWidth = 240; +const drawerWidthMobile = 270; + +const ListItem = withStyles((theme) => ({ + root: { + borderRadius:theme.shape.borderRadius, + }, +}))(MuiListItem); + +const mapStateToProps = (state) => { + return { + desktopOpen: state.viewUpdate.open, + selected: state.explorer.selected, + isMultiple: state.explorer.selectProps.isMultiple, + withFolder: state.explorer.selectProps.withFolder, + withFile: state.explorer.selectProps.withFile, + path: state.navigator.path, + title: state.siteConfig.title, + subTitle: state.viewUpdate.subTitle, + loadUploader: state.viewUpdate.loadUploader, + isLogin: state.viewUpdate.isLogin, + shareInfo: state.viewUpdate.shareInfo, + registerEnabled: state.siteConfig.registerEnabled, + audioPreviewPlayingName: state.explorer.audioPreview.playingName, + audioPreviewIsOpen: state.explorer.audioPreview.isOpen, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + handleDesktopToggle: (open) => { + dispatch(drawerToggleAction(open)); + }, + setSelectedTarget: (targets) => { + dispatch(setSelectedTarget(targets)); + }, + navigateTo: (path) => { + dispatch(navigateTo(path)); + }, + openCreateFolderDialog: () => { + dispatch(openCreateFolderDialog()); + }, + changeContextMenu: (type, open) => { + dispatch(changeContextMenu(type, open)); + }, + saveFile: () => { + dispatch(saveFile()); + }, + openMusicDialog: () => { + dispatch(openMusicDialog()); + }, + showImgPreivew: (first) => { + dispatch(showImgPreivew(first)); + }, + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + openRenameDialog: () => { + dispatch(openRenameDialog()); + }, + openMoveDialog: () => { + dispatch(openMoveDialog()); + }, + openRemoveDialog: () => { + dispatch(openRemoveDialog()); + }, + openShareDialog: () => { + dispatch(openShareDialog()); + }, + openLoadingDialog: (text) => { + dispatch(openLoadingDialog(text)); + }, + setSessionStatus: () => { + dispatch(setSessionStatus()); + }, + openPreview: (share) => { + dispatch(openPreview(share)); + }, + audioPreviewOpen: () => { + dispatch(audioPreviewSetIsOpen(true)); + }, + startBatchDownload: (share) => { + dispatch(startBatchDownload(share)); + }, + startDirectoryDownload: (share) => { + dispatch(startDirectoryDownload(share)); + }, + startDownload: (share, file) => { + dispatch(startDownload(share, file)); + }, + }; +}; + +const styles = (theme) => ({ + appBar: { + marginLeft: drawerWidth, + [theme.breakpoints.down("xs")]: { + marginLeft: drawerWidthMobile, + }, + zIndex: theme.zIndex.drawer + 1, + transition: " background-color 250ms", + }, + + drawer: { + width: 0, + flexShrink: 0, + }, + drawerDesktop: { + width: drawerWidth, + flexShrink: 0, + }, + icon: { + marginRight: theme.spacing(2), + }, + menuButton: { + marginRight: 20, + [theme.breakpoints.up("sm")]: { + display: "none", + }, + }, + menuButtonDesktop: { + marginRight: 20, + [theme.breakpoints.down("xs")]: { + display: "none", + }, + }, + menuIcon: { + marginRight: 20, + }, + toolbar: theme.mixins.toolbar, + drawerPaper: { + width: drawerWidthMobile, + }, + drawerPaperDesktop: { + width: drawerWidth, + }, + upDrawer: { + overflowX: "hidden", + [theme.breakpoints.up("sm")]: { + display: "flex", + flexDirection: "column", + height: "100%", + justifyContent: "space-between", + }, + }, + drawerOpen: { + width: drawerWidth, + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen, + }), + }, + drawerClose: { + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + overflowX: "hidden", + width: 0, + }, + content: { + flexGrow: 1, + padding: theme.spacing(3), + }, + grow: { + flexGrow: 1, + }, + badge: { + top: 1, + right: -15, + }, + nested: { + paddingLeft: theme.spacing(4), + }, + sectionForFile: { + display: "flex", + }, + extendedIcon: { + marginRight: theme.spacing(1), + }, + addButton: { + marginLeft: "40px", + marginTop: "25px", + marginBottom: "15px", + }, + fabButton: { + borderRadius: "100px", + }, + badgeFix: { + right: "10px", + }, + iconFix: { + marginLeft: "16px", + }, + dividerFix: { + marginTop: "8px", + }, + folderShareIcon: { + verticalAlign: "sub", + marginRight: "5px", + }, + shareInfoContainer: { + display: "flex", + marginTop: "15px", + marginBottom: "20px", + marginLeft: "28px", + textDecoration: "none", + }, + shareAvatar: { + width: "40px", + height: "40px", + }, + stickFooter: { + bottom: "0px", + position: "absolute", + backgroundColor: theme.palette.background.paper, + width: "100%", + }, + ownerInfo: { + marginLeft: "10px", + width: "150px", + }, + minStickDrawer: { + overflowY: "auto", + }, + paddingList:{ + padding:theme.spacing(1), + } +}); +class NavbarCompoment extends Component { + constructor(props) { + super(props); + this.state = { + mobileOpen: false, + }; + this.UploaderRef = React.createRef(); + } + + UNSAFE_componentWillMount() { + this.unlisten = this.props.history.listen(() => { + this.setState(() => ({ mobileOpen: false })); + }); + } + componentWillUnmount() { + this.unlisten(); + } + + componentDidMount = () => { + changeThemeColor( + this.props.selected.length <= 1 && + !(!this.props.isMultiple && this.props.withFile) + ? this.props.theme.palette.primary.main + : this.props.theme.palette.background.default + ); + }; + + UNSAFE_componentWillReceiveProps = (nextProps) => { + if ( + (this.props.selected.length === 0) !== + (nextProps.selected.length === 0) + ) { + changeThemeColor( + !(this.props.selected.length === 0) + ? this.props.theme.palette.type === "dark" + ? this.props.theme.palette.background.default + : this.props.theme.palette.primary.main + : this.props.theme.palette.background.default + ); + } + }; + + handleDrawerToggle = () => { + this.setState((state) => ({ mobileOpen: !state.mobileOpen })); + }; + + openDownload = () => { + this.props.startDownload(this.props.shareInfo, this.props.selected[0]); + }; + + openDirectoryDownload = (e) => { + this.props.startDirectoryDownload(this.props.shareInfo); + }; + + archiveDownload = (e) => { + this.props.startBatchDownload(this.props.shareInfo); + }; + + signOut = () => { + API.delete("/user/session/") + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("login.loggedOut"), + "success" + ); + Auth.signout(); + window.location.reload(); + this.props.setSessionStatus(false); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "warning" + ); + }) + .finally(() => { + this.handleClose(); + }); + }; + + render() { + const { classes, t } = this.props; + const user = Auth.GetUser(this.props.isLogin); + const isHomePage = pathHelper.isHomePage(this.props.location.pathname); + const isSharePage = pathHelper.isSharePage( + this.props.location.pathname + ); + + const drawer = ( +
+ {pathHelper.isMobile() && } + + {Auth.Check(this.props.isLogin) && ( + <> +
+ + + + this.props.history.push("/shares?") + } + > + + + + + + {user.group.allowRemoteDownload && ( + + this.props.history.push("/aria2?") + } + > + + + + + + )} + + this.props.history.push("/quota?") + } + > + + + + + + + this.props.history.push("/connect?") + } + > + + + + + + + + this.props.history.push("/tasks?") + } + > + + + + + + {pathHelper.isMobile() && ( + <> + + + this.props.history.push( + "/setting?" + ) + } + > + + + + + + + + + + + + + + )} + +
+
+ +
+ + )} + + {!Auth.Check(this.props.isLogin) && ( +
+ this.props.history.push("/login")} + > + + + + + + {this.props.registerEnabled && ( + + this.props.history.push("/signup") + } + > + + + + + + )} +
+ )} +
+ ); + const iOS = + process.browser && /iPad|iPhone|iPod/.test(navigator.userAgent); + return ( +
+ + + {this.props.selected.length === 0 && ( + + + + )} + {this.props.selected.length === 0 && ( + + this.props.handleDesktopToggle( + !this.props.desktopOpen + ) + } + className={classes.menuButtonDesktop} + > + + + )} + {this.props.selected.length > 0 && + (isHomePage || + pathHelper.isSharePage( + this.props.location.pathname + )) && ( + 0}> + + this.props.setSelectedTarget([]) + } + > + + + + )} + {this.props.selected.length === 0 && ( + { + this.props.history.push("/"); + }} + > + {this.props.subTitle + ? this.props.subTitle + : this.props.title} + + )} + + {!this.props.isMultiple && + (this.props.withFile || this.props.withFolder) && + !pathHelper.isMobile() && ( + + {this.props.selected[0].name}{" "} + {this.props.withFile && + (isHomePage || + pathHelper.isSharePage( + this.props.location.pathname + )) && + "(" + + sizeToString( + this.props.selected[0].size + ) + + ")"} + + )} + + {this.props.selected.length > 1 && + !pathHelper.isMobile() && ( + + {t("navbar.objectsSelected", { + num: this.props.selected.length, + })} + + )} + {this.props.selected.length === 0 && } +
+ {this.props.selected.length > 0 && + (isHomePage || isSharePage) && ( +
+ {!this.props.isMultiple && + this.props.withFile && + isPreviewable( + this.props.selected[0].name + ) && ( + + + + this.props.openPreview( + this.props + .shareInfo + ) + } + > + + + + + )} + {!this.props.isMultiple && + this.props.withFile && ( + + + + this.openDownload() + } + > + + + + + )} + {(this.props.isMultiple || + this.props.withFolder) && + window.showDirectoryPicker && + window.isSecureContext && ( + + + + this.openDirectoryDownload() + } + > + + + + + )} + {(this.props.isMultiple || + this.props.withFolder) && ( + + + + this.archiveDownload() + } + > + + + + + )} + {!this.props.isMultiple && + !pathHelper.isMobile() && + !isSharePage && ( + + + + this.props.openShareDialog() + } + > + + + + + )} + {!this.props.isMultiple && !isSharePage && ( + + + + this.props.openRenameDialog() + } + > + + + + + )} + {!isSharePage && ( +
+ {!pathHelper.isMobile() && ( + + + + this.props.openMoveDialog() + } + > + + + + + )} + + + + + this.props.openRemoveDialog() + } + > + + + + + + {pathHelper.isMobile() && ( + + + + this.props.changeContextMenu( + "file", + true + ) + } + > + + + + + )} +
+ )} +
+ )} + {this.props.selected.length <= 1 && + !(!this.props.isMultiple && this.props.withFile) && + this.props.audioPreviewPlayingName != null && ( + + + + )} + + {this.props.selected.length === 0 && } + {this.props.selected.length === 0 && + pathHelper.isMobile() && ( + <> + {isHomePage && } + {(isHomePage || this.props.shareInfo) && ( + + )} + + )} + + + + + + + this.setState(() => ({ mobileOpen: true })) + } + disableDiscovery={iOS} + ModalProps={{ + keepMounted: true, // Better open performance on mobile. + }} + > + {drawer} + + + + +
+ {drawer} + + +
+ ); + } +} +NavbarCompoment.propTypes = { + classes: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, +}; + +const Navbar = connect( + mapStateToProps, + mapDispatchToProps +)( + withTheme( + withStyles(styles)(withRouter(withTranslation()(NavbarCompoment))) + ) +); + +export default Navbar; diff --git a/src/component/Navbar/PolicySwitcher.js b/src/component/Navbar/PolicySwitcher.js new file mode 100644 index 0000000..a3b3da3 --- /dev/null +++ b/src/component/Navbar/PolicySwitcher.js @@ -0,0 +1,202 @@ +import React, { useCallback } from "react"; +import { + Avatar, + CircularProgress, + IconButton, + ListItem, + ListItemAvatar, + ListItemText, + makeStyles, +} from "@material-ui/core"; +import Tooltip from "@material-ui/core/Tooltip"; +import { Nas } from "mdi-material-ui"; +import Popover from "@material-ui/core/Popover"; +import API from "../../middleware/Api"; +import { useDispatch, useSelector } from "react-redux"; +import { Backup, Check } from "@material-ui/icons"; +import { blue, green } from "@material-ui/core/colors"; +import List from "@material-ui/core/List"; +import { refreshFileList, toggleSnackbar } from "../../redux/explorer"; +import Divider from "@material-ui/core/Divider"; +import Box from "@material-ui/core/Box"; +import Link from "@material-ui/core/Link"; +import { Link as RouterLink } from "react-router-dom"; +import pathHelper from "../../utils/page"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + uploadFromFile: { + backgroundColor: blue[100], + color: blue[600], + }, + policySelected: { + backgroundColor: green[100], + color: green[800], + }, + header: { + padding: "8px 16px", + fontSize: 14, + }, + list: { + minWidth: 300, + maxHeight: 600, + overflow: "auto", + }, +})); + +const PolicySwitcher = () => { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = React.useState(null); + const [policies, setPolicies] = React.useState([]); + const [loading, setLoading] = React.useState(null); + const policy = useSelector((state) => state.explorer.currentPolicy); + const path = useSelector((state) => state.navigator.path); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const RefreshFileList = useCallback(() => dispatch(refreshFileList()), [ + dispatch, + ]); + const search = useSelector((state) => state.explorer.search); + + const handleClick = (event) => { + if (policies.length === 0) { + API.get("/user/setting/policies", {}) + .then((response) => { + setPolicies(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + } + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const switchTo = (id) => { + if (id === policy.id) { + handleClose(); + return; + } + setLoading(id); + API.post("/webdav/mount", { + path: path, + policy: id, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + t("vas.folderPolicySwitched"), + "success" + ); + RefreshFileList(); + setLoading(null); + handleClose(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + setLoading(null); + handleClose(); + }); + }; + + const open = Boolean(anchorEl); + const id = open ? "simple-popover" : undefined; + + const classes = useStyles(); + return ( + <> + {pathHelper.isHomePage(location.pathname) && !search && ( + + + + + + )} + +
+ + {t("vas.setPolicyForFolder")} + +
+ + + + {policies.map((value, index) => ( + switchTo(value.id)} + > + + {value.id === loading && ( + + )} + {value.id !== loading && ( + <> + {value.id === policy.id && ( + + + + )} + {value.id !== policy.id && ( + + + + )} + + )} + + + + ))} + + +
+ handleClose()} + component={RouterLink} + to={"/connect?tab=1"} + color={"secondary"} + > + {t("vas.manageMount")} + +
+
+ + ); +}; + +export default PolicySwitcher; diff --git a/src/component/Navbar/SearchBar.js b/src/component/Navbar/SearchBar.js new file mode 100644 index 0000000..21a5be5 --- /dev/null +++ b/src/component/Navbar/SearchBar.js @@ -0,0 +1,283 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import SearchIcon from "@material-ui/icons/Search"; +import { fade } from "@material-ui/core/styles/colorManipulator"; +import FileIcon from "@material-ui/icons/InsertDriveFile"; +import ShareIcon from "@material-ui/icons/Share"; +import { connect } from "react-redux"; + +import { + Fade, + InputBase, + ListItemIcon, + ListItemText, + MenuItem, + Paper, + Popper, + Typography, + withStyles, +} from "@material-ui/core"; +import { withRouter } from "react-router"; +import pathHelper from "../../utils/page"; +import { configure, HotKeys } from "react-hotkeys"; +import { searchMyFile } from "../../redux/explorer"; +import FolderIcon from "@material-ui/icons/Folder"; +import { Trans, withTranslation } from "react-i18next"; + +configure({ + ignoreTags: [], +}); + +const mapStateToProps = (state) => { + return { + path: state.navigator.path, + search: state.explorer.search, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + searchMyFile: (keywords, path) => { + dispatch(searchMyFile(keywords, path)); + }, + }; +}; + +const styles = (theme) => ({ + search: { + [theme.breakpoints.down("sm")]: { + display: "none", + }, + position: "relative", + borderRadius: theme.shape.borderRadius, + backgroundColor: fade(theme.palette.common.white, 0.15), + "&:hover": { + backgroundColor: fade(theme.palette.common.white, 0.25), + }, + marginRight: theme.spacing(2), + marginLeft: 0, + width: "100%", + [theme.breakpoints.up("sm")]: { + marginLeft: theme.spacing(7.2), + width: "auto", + }, + }, + searchIcon: { + width: theme.spacing(9), + height: "100%", + position: "absolute", + pointerEvents: "none", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + inputRoot: { + color: "inherit", + width: "100%", + }, + inputInput: { + paddingTop: theme.spacing(1), + paddingRight: theme.spacing(1), + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(7), + transition: theme.transitions.create("width"), + width: "100%", + [theme.breakpoints.up("md")]: { + width: 200, + "&:focus": { + width: 300, + }, + }, + }, + suggestBox: { + zIndex: "9999", + width: 364, + }, +}); + +const keyMap = { + SEARCH: "enter", +}; + +class SearchBarCompoment extends Component { + constructor(props) { + super(props); + this.state = { + anchorEl: null, + input: "", + }; + } + + handlers = { + SEARCH: (e) => { + if (pathHelper.isHomePage(this.props.location.pathname)) { + this.searchMyFile("")(); + } else { + this.searchShare(); + } + e.target.blur(); + }, + }; + + handleChange = (event) => { + const { currentTarget } = event; + this.input = event.target.value; + this.setState({ + anchorEl: currentTarget, + input: event.target.value, + }); + }; + + cancelSuggest = () => { + this.setState({ + input: "", + }); + }; + + searchMyFile = (path) => () => { + this.props.searchMyFile("keywords/" + this.input, path); + }; + + searchShare = () => { + this.props.history.push( + "/search?keywords=" + encodeURIComponent(this.input) + ); + }; + + render() { + const { classes, t } = this.props; + const { anchorEl } = this.state; + const id = this.state.input !== "" ? "simple-popper" : null; + const isHomePage = pathHelper.isHomePage(this.props.location.pathname); + + return ( +
+
+ +
+ + + + + {({ TransitionProps }) => ( + + + {isHomePage && ( + + + + + + , + ]} + /> + + } + /> + + )} + + {isHomePage && + this.props.path !== "/" && + !this.props.search && ( + + + + + + , + ]} + /> + + } + /> + + )} + + + + + + + , + ]} + /> + + } + /> + + + + )} + +
+ ); + } +} + +SearchBarCompoment.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const SearchBar = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(SearchBarCompoment)))); + +export default SearchBar; diff --git a/src/component/Navbar/StorageBar.js b/src/component/Navbar/StorageBar.js new file mode 100644 index 0000000..8973c32 --- /dev/null +++ b/src/component/Navbar/StorageBar.js @@ -0,0 +1,208 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import StorageIcon from "@material-ui/icons/Storage"; +import { connect } from "react-redux"; +import API from "../../middleware/Api"; +import { sizeToString } from "../../utils"; + +import { + Divider, + LinearProgress, + Tooltip, + Typography, + withStyles, +} from "@material-ui/core"; +import ButtonBase from "@material-ui/core/ButtonBase"; +import Link from "@material-ui/core/Link"; +import { withRouter } from "react-router"; +import { toggleSnackbar } from "../../redux/explorer"; +import { Link as RouterLink } from "react-router-dom"; +import { withTranslation } from "react-i18next"; + +const mapStateToProps = (state) => { + return { + refresh: state.viewUpdate.storageRefresh, + isLogin: state.viewUpdate.isLogin, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +const styles = (theme) => ({ + iconFix: { + marginLeft: "32px", + marginRight: "17px", + color: theme.palette.text.secondary, + marginTop: "2px", + }, + textFix: { + padding: " 0 0 0 16px", + }, + storageContainer: { + display: "flex", + marginTop: "15px", + textAlign: "left", + marginBottom: "11px", + }, + detail: { + width: "100%", + marginRight: "35px", + }, + info: { + width: "131px", + overflow: "hidden", + textOverflow: "ellipsis", + [theme.breakpoints.down("xs")]: { + width: "162px", + }, + marginTop: "5px", + }, + bar: { + marginTop: "5px", + }, + stickFooter: { + backgroundColor: theme.palette.background.paper, + }, +}); + +// TODO 使用 hooks 重构 +class StorageBarCompoment extends Component { + state = { + percent: 0, + used: null, + total: null, + showExpand: false, + }; + + firstLoad = true; + + componentDidMount = () => { + if (this.firstLoad && this.props.isLogin) { + this.firstLoad = !this.firstLoad; + this.updateStatus(); + } + }; + + componentWillUnmount() { + this.firstLoad = false; + } + + UNSAFE_componentWillReceiveProps = (nextProps) => { + if ( + (this.props.isLogin && this.props.refresh !== nextProps.refresh) || + (this.props.isLogin !== nextProps.isLogin && nextProps.isLogin) + ) { + this.updateStatus(); + } + }; + + updateStatus = () => { + let percent = 0; + API.get("/user/storage") + .then((response) => { + if (response.data.used / response.data.total >= 1) { + percent = 100; + this.props.toggleSnackbar( + "top", + "right", + this.props.t("vas.exceedQuota"), + "warning" + ); + } else { + percent = (response.data.used / response.data.total) * 100; + } + this.setState({ + percent: percent, + used: sizeToString(response.data.used), + total: sizeToString(response.data.total), + }); + }) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}); + }; + + render() { + const { classes, t } = this.props; + return ( +
this.setState({ showExpand: true })} + onMouseLeave={() => this.setState({ showExpand: false })} + className={classes.stickFooter} + > + + this.props.history.push("/quota")}> +
+ +
+ + {t("navbar.storage") + " "} + {this.state.showExpand && ( + + {t("vas.extendStorage")} + + )} + + + +
+ + + {this.state.used === null + ? " -- " + : this.state.used} + {" / "} + {this.state.total === null + ? " -- " + : this.state.total} + + +
+
+
+
+
+ ); + } +} + +StorageBarCompoment.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const StorageBar = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(StorageBarCompoment)))); + +export default StorageBar; diff --git a/src/component/Navbar/UserAvatar.js b/src/component/Navbar/UserAvatar.js new file mode 100644 index 0000000..789c850 --- /dev/null +++ b/src/component/Navbar/UserAvatar.js @@ -0,0 +1,178 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import SettingIcon from "@material-ui/icons/Settings"; +import UserAvatarPopover from "./UserAvatarPopover"; +import { AccountCircle } from "mdi-material-ui"; +import Auth from "../../middleware/Auth"; +import { + Avatar, + Grow, + IconButton, + Tooltip, + withStyles, +} from "@material-ui/core"; +import { withRouter } from "react-router-dom"; +import pathHelper from "../../utils/page"; +import DarkModeSwitcher from "./DarkModeSwitcher"; +import PolicySwitcher from "./PolicySwitcher"; +import { Home } from "@material-ui/icons"; +import { setUserPopover } from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; + +const mapStateToProps = (state) => { + return { + selected: state.explorer.selected, + isMultiple: state.explorer.selectProps.isMultiple, + withFolder: state.explorer.selectProps.withFolder, + withFile: state.explorer.selectProps.withFile, + isLogin: state.viewUpdate.isLogin, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + setUserPopover: (anchor) => { + dispatch(setUserPopover(anchor)); + }, + }; +}; + +const styles = (theme) => ({ + mobileHidden: { + [theme.breakpoints.down("xs")]: { + display: "none", + }, + whiteSpace: "nowrap", + }, + avatar: { + width: "30px", + height: "30px", + }, + header: { + display: "flex", + padding: "20px 20px 20px 20px", + }, + largeAvatar: { + height: "90px", + width: "90px", + }, + info: { + marginLeft: "10px", + width: "139px", + }, + badge: { + marginTop: "10px", + }, + visitorMenu: { + width: 200, + }, +}); + +class UserAvatarCompoment extends Component { + state = { + anchorEl: null, + }; + + showUserInfo = (e) => { + this.props.setUserPopover(e.currentTarget); + }; + + handleClose = () => { + this.setState({ + anchorEl: null, + }); + }; + + openURL = (url) => { + window.location.href = url; + }; + + returnHome = () => { + window.location.href = "/home"; + }; + + render() { + const { classes, t } = this.props; + const loginCheck = Auth.Check(this.props.isLogin); + const user = Auth.GetUser(this.props.isLogin); + const isAdminPage = pathHelper.isAdminPage( + this.props.location.pathname + ); + + return ( +
+ +
+ {!isAdminPage && ( + <> + + {loginCheck && ( + <> + + + + this.props.history.push( + "/setting?" + ) + } + color="inherit" + > + + + + + )} + + )} + {isAdminPage && ( + + + + + + )} + + {!loginCheck && } + {loginCheck && ( + + )} + {" "} +
+
+ +
+ ); + } +} + +UserAvatarCompoment.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const UserAvatar = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(UserAvatarCompoment)))); + +export default UserAvatar; diff --git a/src/component/Navbar/UserAvatarPopover.js b/src/component/Navbar/UserAvatarPopover.js new file mode 100644 index 0000000..a8ca6e4 --- /dev/null +++ b/src/component/Navbar/UserAvatarPopover.js @@ -0,0 +1,260 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { + AccountArrowRight, + AccountPlus, + DesktopMacDashboard, + HomeAccount, + LogoutVariant, +} from "mdi-material-ui"; +import { withRouter } from "react-router-dom"; +import Auth from "../../middleware/Auth"; +import { + Avatar, + Chip, + Divider, + ListItemIcon, + MenuItem, + Popover, + Typography, + withStyles, +} from "@material-ui/core"; +import API from "../../middleware/Api"; +import pathHelper from "../../utils/page"; +import { + setSessionStatus, + setUserPopover, + toggleSnackbar, +} from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; + +const mapStateToProps = (state) => { + return { + anchorEl: state.viewUpdate.userPopoverAnchorEl, + registerEnabled: state.siteConfig.registerEnabled, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + setUserPopover: (anchor) => { + dispatch(setUserPopover(anchor)); + }, + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + setSessionStatus: (status) => { + dispatch(setSessionStatus(status)); + }, + }; +}; +const styles = () => ({ + avatar: { + width: "30px", + height: "30px", + }, + header: { + display: "flex", + padding: "20px 20px 20px 20px", + }, + largeAvatar: { + height: "90px", + width: "90px", + }, + info: { + marginLeft: "10px", + width: "139px", + }, + badge: { + marginTop: "10px", + }, + visitorMenu: { + width: 200, + }, +}); + +class UserAvatarPopoverCompoment extends Component { + handleClose = () => { + this.props.setUserPopover(null); + }; + + openURL = (url) => { + window.location.href = url; + }; + + sigOut = () => { + API.delete("/user/session/") + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("login.loggedOut"), + "success" + ); + Auth.signout(); + window.location.reload(); + this.props.setSessionStatus(false); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "warning" + ); + }) + .then(() => { + this.handleClose(); + }); + }; + + render() { + const { classes, t } = this.props; + const user = Auth.GetUser(); + const isAdminPage = pathHelper.isAdminPage( + this.props.location.pathname + ); + + return ( + + {!Auth.Check() && ( +
+ + this.props.history.push("/login")} + > + + + + {t("login.signIn")} + + {this.props.registerEnabled && ( + + this.props.history.push("/signup") + } + > + + + + {t("login.signUp")} + + )} +
+ )} + {Auth.Check() && ( +
+
+
+ +
+
+ {user.nickname} + + {user.user_name} + + +
+
+
+ + {!isAdminPage && ( + { + this.handleClose(); + this.props.history.push( + "/profile/" + user.id + ); + }} + > + + + + {t("navbar.myProfile")} + + )} + {user.group.id === 1 && ( + { + this.handleClose(); + this.props.history.push("/admin/home"); + }} + > + + + + {t("navbar.dashboard")} + + )} + + + + + + {t("login.logout")} + +
+
+ )} +
+ ); + } +} + +UserAvatarPopoverCompoment.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const UserAvatarPopover = connect( + mapStateToProps, + mapDispatchToProps +)( + withStyles(styles)( + withRouter(withTranslation()(UserAvatarPopoverCompoment)) + ) +); + +export default UserAvatarPopover; diff --git a/src/component/Navbar/UserInfo.js b/src/component/Navbar/UserInfo.js new file mode 100644 index 0000000..2daeffb --- /dev/null +++ b/src/component/Navbar/UserInfo.js @@ -0,0 +1,152 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { Typography, withStyles } from "@material-ui/core"; +import Auth from "../../middleware/Auth"; +import DarkModeSwitcher from "./DarkModeSwitcher"; +import Avatar from "@material-ui/core/Avatar"; +import { setUserPopover } from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; + +const mapStateToProps = (state) => { + return { + isLogin: state.viewUpdate.isLogin, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + setUserPopover: (anchor) => { + dispatch(setUserPopover(anchor)); + }, + }; +}; + +const styles = (theme) => ({ + userNav: { + height: "170px", + backgroundColor: theme.palette.primary.main, + padding: "20px 20px 2em", + backgroundImage: + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 900'%3E%3Cpolygon fill='" + + theme.palette.primary.light.replace("#", "%23") + + "' points='957 450 539 900 1396 900'/%3E%3Cpolygon fill='" + + theme.palette.primary.dark.replace("#", "%23") + + "' points='957 450 872.9 900 1396 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.main.replace("#", "%23") + + "' points='-60 900 398 662 816 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.dark.replace("#", "%23") + + "' points='337 900 398 662 816 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.light.replace("#", "%23") + + "' points='1203 546 1552 900 876 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.main.replace("#", "%23") + + "' points='1203 546 1552 900 1162 900'/%3E%3Cpolygon fill='" + + theme.palette.primary.dark.replace("#", "%23") + + "' points='641 695 886 900 367 900'/%3E%3Cpolygon fill='" + + theme.palette.primary.main.replace("#", "%23") + + "' points='587 900 641 695 886 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.light.replace("#", "%23") + + "' points='1710 900 1401 632 1096 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.dark.replace("#", "%23") + + "' points='1710 900 1401 632 1365 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.main.replace("#", "%23") + + "' points='1210 900 971 687 725 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.dark.replace("#", "%23") + + "' points='943 900 1210 900 971 687'/%3E%3C/svg%3E\")", + backgroundSize: "cover", + }, + avatar: { + display: "block", + width: "70px", + height: "70px", + border: " 2px solid #fff", + borderRadius: "50%", + overflow: "hidden", + boxShadow: + "0 2px 5px 0 rgba(0,0,0,0.16), 0 2px 10px 0 rgba(0,0,0,0.12)", + }, + avatarImg: { + width: "66px", + height: "66px", + }, + nickName: { + color: "#fff", + marginTop: "15px", + fontSize: "17px", + }, + flexAvatar: { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + }, + groupName: { + color: "#ffffff", + opacity: "0.54", + }, + storageCircle: { + width: "200px", + }, +}); + +class UserInfoCompoment extends Component { + showUserInfo = (e) => { + this.props.setUserPopover(e.currentTarget); + }; + + render() { + const { classes, t } = this.props; + const isLogin = Auth.Check(this.props.isLogin); + const user = Auth.GetUser(this.props.isLogin); + + return ( +
+
+ {/* eslint-disable-next-line */} + + {isLogin && ( + + )} + {!isLogin && ( + + )} + + +
+
+ + {isLogin ? user.nickname : t("navbar.notLoginIn")} + + + {isLogin ? user.group.name : t("navbar.visitor")} + +
+
+ ); + } +} + +UserInfoCompoment.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const UserInfo = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withTranslation()(UserInfoCompoment))); + +export default UserInfo; diff --git a/src/component/Placeholder/Captcha.js b/src/component/Placeholder/Captcha.js new file mode 100644 index 0000000..d17542a --- /dev/null +++ b/src/component/Placeholder/Captcha.js @@ -0,0 +1,20 @@ +import React from "react"; +import ContentLoader from "react-content-loader"; + +const MyLoader = () => ( + + + +); + +function captchaPlacholder() { + return ; +} + +export default captchaPlacholder; diff --git a/src/component/Placeholder/DropFile.js b/src/component/Placeholder/DropFile.js new file mode 100644 index 0000000..bef66c1 --- /dev/null +++ b/src/component/Placeholder/DropFile.js @@ -0,0 +1,33 @@ +import React from "react"; +import Backdrop from "@material-ui/core/Backdrop"; +import { createStyles, makeStyles } from "@material-ui/core/styles"; +import UploadIcon from "@material-ui/icons/CloudUpload"; +import { Typography } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => + createStyles({ + backdrop: { + zIndex: theme.zIndex.drawer + 1, + color: "#fff", + flexDirection: "column", + }, + }) +); + +export function DropFileBackground({ open }) { + const classes = useStyles(); + const { t } = useTranslation(); + return ( + +
+ +
+
+ + {t("uploader.dropFileHere")} + +
+
+ ); +} diff --git a/src/component/Placeholder/ErrorBoundary.js b/src/component/Placeholder/ErrorBoundary.js new file mode 100644 index 0000000..ae4bab9 --- /dev/null +++ b/src/component/Placeholder/ErrorBoundary.js @@ -0,0 +1,62 @@ +import React from "react"; +import { withStyles } from "@material-ui/core"; +import { withTranslation } from "react-i18next"; + +const styles = { + h1: { + color: "#a4a4a4", + margin: "5px 0px", + }, + h2: { + margin: "15px 0px", + }, +}; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null, errorInfo: null }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + this.setState({ + error: error, + errorInfo: errorInfo, + }); + } + + render() { + const { classes, t } = this.props; + if (this.state.hasError) { + return ( + <> +

:(

+

{t("renderError")}

+ {this.state.error && + this.state.errorInfo && + this.state.errorInfo.componentStack && ( +
+ {t("errorDetails")} +
+                                    {this.state.error.toString()}
+                                
+
+                                    
+                                        {this.state.errorInfo.componentStack}
+                                    
+                                
+
+ )} + + ); + } + + return this.props.children; + } +} + +export default withTranslation(["common"])(withStyles(styles)(ErrorBoundary)); diff --git a/src/component/Placeholder/ListLoading.js b/src/component/Placeholder/ListLoading.js new file mode 100644 index 0000000..bbca6d4 --- /dev/null +++ b/src/component/Placeholder/ListLoading.js @@ -0,0 +1,38 @@ +import React from "react"; +import { BulletList } from "react-content-loader"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + loader: { + width: "100%", + // padding: 40, + // [theme.breakpoints.down("md")]: { + // width: "100%", + // padding: 10 + // } + }, +})); + +const MyLoader = (props) => ( + +); + +function ListLoading() { + const theme = useTheme(); + const classes = useStyles(); + + return ( +
+ +
+ ); +} + +export default ListLoading; diff --git a/src/component/Placeholder/Nothing.js b/src/component/Placeholder/Nothing.js new file mode 100644 index 0000000..6ea2e24 --- /dev/null +++ b/src/component/Placeholder/Nothing.js @@ -0,0 +1,50 @@ +import React from "react"; +import { PackageVariant } from "mdi-material-ui"; +import { makeStyles } from "@material-ui/core"; + +const useStyles = makeStyles((theme) => ({ + emptyContainer: { + bottom: "0", + + color: theme.palette.action.disabled, + textAlign: "center", + paddingTop: "20px", + }, + emptyInfoBig: { + fontSize: "25px", + color: theme.palette.action.disabled, + }, + emptyInfoSmall: { + color: theme.palette.action.disabled, + }, +})); + +export default function Nothing({ primary, secondary, top = 20, size = 1 }) { + const classes = useStyles(); + return ( +
+ +
+ {primary} +
+ {secondary !== "" && ( +
{secondary}
+ )} +
+ ); +} diff --git a/src/component/Placeholder/PageLoading.js b/src/component/Placeholder/PageLoading.js new file mode 100644 index 0000000..411f662 --- /dev/null +++ b/src/component/Placeholder/PageLoading.js @@ -0,0 +1,44 @@ +import React from "react"; +import { Facebook } from "react-content-loader"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + loader: { + width: "80%", + [theme.breakpoints.up("md")]: { + width: " 50%", + }, + + marginTop: 30, + }, +})); + +const MyLoader = (props) => { + return ( + + ); +}; + +function PageLoading() { + const theme = useTheme(); + const classes = useStyles(); + + return ( +
+ +
+ ); +} + +export default PageLoading; diff --git a/src/component/Placeholder/TextLoading.js b/src/component/Placeholder/TextLoading.js new file mode 100644 index 0000000..da264ec --- /dev/null +++ b/src/component/Placeholder/TextLoading.js @@ -0,0 +1,38 @@ +import React from "react"; +import { Code } from "react-content-loader"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + loader: { + width: "70%", + padding: 40, + [theme.breakpoints.down("md")]: { + width: "100%", + padding: 10, + }, + }, +})); + +const MyLoader = (props) => ( + +); + +function TextLoading() { + const theme = useTheme(); + const classes = useStyles(); + + return ( +
+ +
+ ); +} + +export default TextLoading; diff --git a/src/component/Setting/AppPromotion.js b/src/component/Setting/AppPromotion.js new file mode 100644 index 0000000..10ff334 --- /dev/null +++ b/src/component/Setting/AppPromotion.js @@ -0,0 +1,210 @@ +import React from "react"; +import { Grid, makeStyles, Typography } from "@material-ui/core"; +import { Trans, useTranslation } from "react-i18next"; +import { useTheme, fade } from "@material-ui/core/styles"; +import Box from "@material-ui/core/Box"; +import { useSelector } from "react-redux"; +import Link from "@material-ui/core/Link"; +import AppQRCode from "./AppQRCode"; + +const PhoneSkeleton = () => { + const theme = useTheme(); + + return ( + + + + + + ); +}; + +const useStyles = makeStyles((theme) => ({ + phoneContainer: { + maxWidth: 450, + position: "relative", + marginX: "auto", + perspective: 1500, + transformStyle: "preserve-3d", + perspectiveOrigin: 0, + }, + phoneFrame: { + position: "relative", + borderRadius: "2.75rem", + boxShadow: 1, + width: "75% !important", + marginX: "auto", + transform: "rotateY(-35deg) rotateX(15deg) translateZ(0)", + }, + phoneImage: { + objectFit: "cover", + borderRadius: "2.5rem", + filter: theme.palette.type === "dark" ? "brightness(0.7)" : "none", + }, + highlight: { + background: `linear-gradient(180deg, transparent 82%, ${fade( + theme.palette.secondary.main, + 0.3 + )} 0%)`, + }, + bold: { + fontWeight: 700, + }, + frameContainer: { + verticalAlign: "middle", + }, + grid: { + padding: theme.spacing(2), + paddingTop: 0, + paddingBottom: theme.spacing(4), + }, + "@global": { + ol: { + paddingInlineStart: 24, + }, + "li::marker": { + fontSize: "1.25rem", + }, + li: { + marginBottom: theme.spacing(2), + }, + }, +})); + +export default function AppPromotion() { + const { t } = useTranslation("application", { keyPrefix: "setting" }); + const theme = useTheme(); + const title = useSelector((state) => state.siteConfig.title); + + const classes = useStyles(); + + return ( + + + + + + , + ]} + /> + + + +
    +
  1. + + {t("downloadOurApp")} + + + + + + +
  2. +
  3. + + {t("fillInEndpoint")} + + + + +
  4. +
  5. + + {t("loginApp")} + +
  6. +
+
+
+
+ + + + + + + + + + + + + + +
+ ); +} diff --git a/src/component/Setting/AppQRCode.js b/src/component/Setting/AppQRCode.js new file mode 100644 index 0000000..a3db9e9 --- /dev/null +++ b/src/component/Setting/AppQRCode.js @@ -0,0 +1,81 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { CircularProgress, makeStyles } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@material-ui/core/styles"; +import Box from "@material-ui/core/Box"; +import { QRCodeSVG } from "qrcode.react"; +import { randomStr } from "../../utils"; +import classNames from "classnames"; +import API from "../../middleware/Api"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../redux/explorer"; + +const useStyles = makeStyles((theme) => ({ + blur: { + filter: "blur(8px)", + }, + container: { + position: "relative", + width: 128, + }, + progress: { + position: "absolute", + top: "50%", + left: "50%", + marginLeft: -12, + marginTop: -12, + zIndex: 1, + }, + qrcode: { + transition: "all .3s ease-in", + }, +})); + +export default function AppQRCode() { + const [content, setContent] = useState(randomStr(32)); + const [loading, setLoading] = useState(true); + const { t } = useTranslation("application", { keyPrefix: "setting" }); + const theme = useTheme(); + const classes = useStyles(); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const refresh = () => { + setLoading(true); + API.get("/user/session") + .then((response) => { + setContent(response.data); + setLoading(false); + }) + .catch((error) => { + setLoading(false); + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + refresh(); + const interval = window.setInterval(refresh, 1000 * 45); + return () => { + window.clearInterval(interval); + }; + }, []); + + return ( + + {loading && ( + + )} + + + ); +} diff --git a/src/component/Setting/Authn.js b/src/component/Setting/Authn.js new file mode 100644 index 0000000..9f9e942 --- /dev/null +++ b/src/component/Setting/Authn.js @@ -0,0 +1,230 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + List, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + makeStyles, + Paper, + Typography, +} from "@material-ui/core"; +import { useDispatch } from "react-redux"; +import RightIcon from "@material-ui/icons/KeyboardArrowRight"; +import { Add, Fingerprint, HighlightOff } from "@material-ui/icons"; +import API from "../../middleware/Api"; +import { bufferDecode, bufferEncode } from "../../utils"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + sectionTitle: { + paddingBottom: "10px", + paddingTop: "30px", + }, + rightIcon: { + marginTop: "4px", + marginRight: "10px", + color: theme.palette.text.secondary, + }, + desenList: { + paddingTop: 0, + paddingBottom: 0, + }, + iconFix: { + marginRight: "11px", + marginLeft: "7px", + minWidth: 40, + }, + flexContainer: { + display: "flex", + }, +})); + +export default function Authn(props) { + const { t } = useTranslation(); + const [selected, setSelected] = useState(""); + const [confirm, setConfirm] = useState(false); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const deleteCredential = (id) => { + API.patch("/user/setting/authn", { + id: id, + }) + .then(() => { + ToggleSnackbar( + "top", + "right", + t("setting.authenticatorRemoved"), + "success" + ); + props.remove(id); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }) + .then(() => { + setConfirm(false); + }); + }; + + const classes = useStyles(); + + const addCredential = () => { + if (!navigator.credentials) { + ToggleSnackbar( + "top", + "right", + t("setting.browserNotSupported"), + "warning" + ); + + return; + } + API.put("/user/authn", {}) + .then((response) => { + const credentialCreationOptions = response.data; + credentialCreationOptions.publicKey.challenge = bufferDecode( + credentialCreationOptions.publicKey.challenge + ); + credentialCreationOptions.publicKey.user.id = bufferDecode( + credentialCreationOptions.publicKey.user.id + ); + if (credentialCreationOptions.publicKey.excludeCredentials) { + for ( + let i = 0; + i < + credentialCreationOptions.publicKey.excludeCredentials + .length; + i++ + ) { + credentialCreationOptions.publicKey.excludeCredentials[ + i + ].id = bufferDecode( + credentialCreationOptions.publicKey + .excludeCredentials[i].id + ); + } + } + + return navigator.credentials.create({ + publicKey: credentialCreationOptions.publicKey, + }); + }) + .then((credential) => { + const attestationObject = credential.response.attestationObject; + const clientDataJSON = credential.response.clientDataJSON; + const rawId = credential.rawId; + return API.put( + "/user/authn/finish", + JSON.stringify({ + id: credential.id, + rawId: bufferEncode(rawId), + type: credential.type, + response: { + attestationObject: bufferEncode(attestationObject), + clientDataJSON: bufferEncode(clientDataJSON), + }, + }) + ); + }) + .then((response) => { + props.add(response.data); + ToggleSnackbar( + "top", + "right", + t("setting.authenticatorAdded"), + "success" + ); + return; + }) + .catch((error) => { + console.log(error); + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + return ( +
+ setConfirm(false)}> + {t("setting.removedAuthenticator")} + + {t("setting.removedAuthenticatorConfirm")} + + + + + + + + + {t("setting.hardwareAuthenticator")} + + + + {props.list.map((v) => ( + <> + { + setConfirm(true); + setSelected(v.id); + }} + > + + + + + + deleteCredential(v.id)} + className={classes.flexContainer} + > + + + + + + ))} + addCredential()}> + + + + + + + + + + + +
+ ); +} diff --git a/src/component/Setting/Profile.js b/src/component/Setting/Profile.js new file mode 100644 index 0000000..81b253d --- /dev/null +++ b/src/component/Setting/Profile.js @@ -0,0 +1,443 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import API from "../../middleware/Api"; + +import { + Avatar, + Grid, + Paper, + Tab, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tabs, + Typography, + withStyles, +} from "@material-ui/core"; +import { withRouter } from "react-router"; +import Pagination from "@material-ui/lab/Pagination"; +import { formatLocalTime } from "../../utils/datetime"; +import { toggleSnackbar } from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; + +const styles = (theme) => ({ + layout: { + width: "auto", + marginTop: "50px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + marginBottom: "30px", + [theme.breakpoints.up("sm")]: { + width: 700, + marginLeft: "auto", + marginRight: "auto", + }, + }, + userNav: { + borderRadius: `${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0 0`, + height: "270px", + backgroundColor: theme.palette.primary.main, + padding: "20px 20px 2em", + backgroundImage: + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 900'%3E%3Cpolygon fill='" + + theme.palette.primary.light.replace("#", "%23") + + "' points='957 450 539 900 1396 900'/%3E%3Cpolygon fill='" + + theme.palette.primary.dark.replace("#", "%23") + + "' points='957 450 872.9 900 1396 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.main.replace("#", "%23") + + "' points='-60 900 398 662 816 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.dark.replace("#", "%23") + + "' points='337 900 398 662 816 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.light.replace("#", "%23") + + "' points='1203 546 1552 900 876 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.main.replace("#", "%23") + + "' points='1203 546 1552 900 1162 900'/%3E%3Cpolygon fill='" + + theme.palette.primary.dark.replace("#", "%23") + + "' points='641 695 886 900 367 900'/%3E%3Cpolygon fill='" + + theme.palette.primary.main.replace("#", "%23") + + "' points='587 900 641 695 886 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.light.replace("#", "%23") + + "' points='1710 900 1401 632 1096 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.dark.replace("#", "%23") + + "' points='1710 900 1401 632 1365 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.main.replace("#", "%23") + + "' points='1210 900 971 687 725 900'/%3E%3Cpolygon fill='" + + theme.palette.secondary.dark.replace("#", "%23") + + "' points='943 900 1210 900 971 687'/%3E%3C/svg%3E\")", + backgroundSize: "cover", + backgroundPosition: "bottom", + }, + avatarContainer: { + height: "80px", + width: "80px", + borderRaidus: "50%", + margin: "auto", + marginTop: "50px", + boxShadow: + "0 2px 5px 0 rgba(0,0,0,0.16), 0 2px 10px 0 rgba(0,0,0,0.12)", + border: "2px solid #fff", + }, + nickName: { + width: "200px", + margin: "auto", + textAlign: "center", + marginTop: "1px", + fontSize: "25px", + color: "#ffffff", + opacity: "0.81", + }, + th: { + minWidth: "106px", + }, + mobileHide: { + [theme.breakpoints.down("md")]: { + display: "none", + }, + }, + tableLink: { + cursor: "pointer", + }, + navigator: { + padding: theme.spacing(2), + }, + pageInfo: { + marginTop: "14px", + marginLeft: "23px", + }, + infoItem: { + paddingLeft: "46px!important", + paddingBottom: "20px!important", + }, + infoContainer: { + marginTop: "30px", + }, + tableContainer: { + overflowX: "auto", + }, +}); +const mapStateToProps = () => { + return {}; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +class ProfileCompoment extends Component { + state = { + listType: 0, + shareList: [], + page: 1, + user: null, + total: 0, + }; + + handleChange = (event, listType) => { + this.setState({ listType }); + if (listType === 1) { + this.loadList(1, "hot"); + } else if (listType === 0) { + this.loadList(1, "default"); + } + }; + + componentDidMount = () => { + this.loadList(1, "default"); + }; + + loadList = (page, order) => { + API.get( + "/user/profile/" + + this.props.match.params.id + + "?page=" + + page + + "&type=" + + order + ) + .then((response) => { + this.setState({ + shareList: response.data.items, + user: response.data.user, + total: response.data.total, + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + loadNext = () => { + this.loadList( + this.state.page + 1, + this.state.listType === 0 ? "default" : "hot" + ); + }; + + loadPrev = () => { + this.loadList( + this.state.page - 1, + this.state.listType === 0 ? "default" : "hot" + ); + }; + + render() { + const { classes, t } = this.props; + + return ( +
+ {this.state.user === null &&
} + {this.state.user !== null && ( + +
+
+ +
+
+ + {this.state.user.nick} + +
+
+ + + + + + {this.state.listType === 2 && ( +
+ + + + {t("setting.uid")} + + + {this.state.user.id} + + + + + {t("setting.nickname")} + + + {this.state.user.nick} + + + + + {t("setting.group")} + + + {this.state.user.group} + + + + + {t("setting.totalShares")} + + + {this.state.total} + + + + + {t("setting.regTime")} + + + {this.state.user.date} + + + +
+ )} + {(this.state.listType === 0 || + this.state.listType === 1) && ( +
+
+ + + + + {t("setting.fileName")} + + + {t("setting.shareDate")} + + + {t( + "setting.downloadNumber" + )} + + + {t("setting.viewNumber")} + + + + + {this.state.shareList.map( + (row, id) => ( + + this.props.history.push( + "/s/" + row.key + ) + } + > + + + {row.source + ? row.source + .name + : "[" + + t( + "share.expired" + ) + + "]"} + + + + {formatLocalTime( + row.create_date + )} + + + {row.downloads} + + + {row.views} + + + ) + )} + +
+
+ {this.state.shareList.length !== 0 && + this.state.listType === 0 && ( +
+ + this.loadList( + v, + this.state.listType === + 0 + ? "default" + : "hot" + ) + } + color="secondary" + /> +
+ )} +
+ )} +
+ )} +
+ ); + } +} + +const Profile = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(ProfileCompoment)))); + +export default Profile; diff --git a/src/component/Setting/Tasks.js b/src/component/Setting/Tasks.js new file mode 100644 index 0000000..8aedf34 --- /dev/null +++ b/src/component/Setting/Tasks.js @@ -0,0 +1,160 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles, Typography } from "@material-ui/core"; +import { useDispatch } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import API from "../../middleware/Api"; +import { getTaskProgress, getTaskStatus, getTaskType } from "../../config"; +import Pagination from "@material-ui/lab/Pagination"; +import { formatLocalTime } from "../../utils/datetime"; +import { toggleSnackbar } from "../../redux/explorer"; +import Nothing from "../Placeholder/Nothing"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "50px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: "50px", + }, + content: { + marginTop: theme.spacing(4), + overflowX: "auto", + }, + cardContent: { + padding: theme.spacing(2), + }, + tableContainer: { + overflowX: "auto", + }, + create: { + marginTop: theme.spacing(2), + }, + noWrap: { + wordBreak: "keepAll", + }, + footer: { + padding: theme.spacing(2), + }, +})); + +export default function Tasks() { + const { t } = useTranslation(); + const [tasks, setTasks] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const loadList = (page) => { + API.get("/user/setting/tasks?page=" + page) + .then((response) => { + setTasks(response.data.tasks); + setTotal(response.data.total); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + useEffect(() => { + loadList(page); + // eslint-disable-next-line + }, [page]); + + const getError = (error) => { + if (error === "") { + return "-"; + } + try { + const res = JSON.parse(error); + return `${res.msg}: ${res.error}`; + } catch (e) { + return t("uploader.unknownStatus"); + } + }; + + const classes = useStyles(); + + return ( +
+ + {t("navbar.taskQueue")} + + + + + + + {t("setting.createdAt")} + + + {t("setting.taskType")} + + + {t("setting.taskStatus")} + + + {t("setting.lastProgress")} + + + {t("setting.errorDetails")} + + + + + {tasks.map((row, id) => ( + + + {formatLocalTime(row.create_date)} + + + {getTaskType(row.type)} + + + {getTaskStatus(row.status)} + + + {getTaskProgress(row.type, row.progress)} + + + {getError(row.error)} + + + ))} + +
+ {tasks.length === 0 && ( + + )} +
+ setPage(v)} + color="secondary" + /> +
+
+
+ ); +} diff --git a/src/component/Setting/UserSetting.js b/src/component/Setting/UserSetting.js new file mode 100644 index 0000000..5677a7d --- /dev/null +++ b/src/component/Setting/UserSetting.js @@ -0,0 +1,1570 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import PhotoIcon from "@material-ui/icons/InsertPhoto"; +import GroupIcon from "@material-ui/icons/Group"; +import DateIcon from "@material-ui/icons/DateRange"; +import EmailIcon from "@material-ui/icons/Email"; +import HomeIcon from "@material-ui/icons/Home"; +import LinkIcon from "@material-ui/icons/Phonelink"; +import AlarmOff from "@material-ui/icons/AlarmOff"; +import InputIcon from "@material-ui/icons/Input"; +import SecurityIcon from "@material-ui/icons/Security"; +import NickIcon from "@material-ui/icons/PermContactCalendar"; +import LockIcon from "@material-ui/icons/Lock"; +import VerifyIcon from "@material-ui/icons/VpnKey"; +import ColorIcon from "@material-ui/icons/Palette"; +import axios from "axios"; +import FingerprintIcon from "@material-ui/icons/Fingerprint"; +import ToggleButton from "@material-ui/lab/ToggleButton"; +import ToggleButtonGroup from "@material-ui/lab/ToggleButtonGroup"; +import RightIcon from "@material-ui/icons/KeyboardArrowRight"; +import { + Avatar, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + List, + ListItem, + ListItemAvatar, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Paper, + Switch, + TextField, + Tooltip, + Typography, + withStyles, +} from "@material-ui/core"; +import SettingsInputHdmi from "@material-ui/icons/SettingsInputHdmi"; +import { blue, green, yellow } from "@material-ui/core/colors"; +import API from "../../middleware/Api"; +import Auth from "../../middleware/Auth"; +import { withRouter } from "react-router"; +import TimeAgo from "timeago-react"; +import { QRCodeSVG } from "qrcode.react"; +import { + Brightness3, + ConfirmationNumber, + ListAlt, + PermContactCalendar, + Schedule, + Translate, +} from "@material-ui/icons"; +import Authn from "./Authn"; +import { formatLocalTime, timeZone } from "../../utils/datetime"; +import TimeZoneDialog from "../Modals/TimeZone"; +import BindPhone from "../Modals/BindPhone"; +import { + applyThemes, + changeViewMethod, + toggleDaylightMode, + toggleSnackbar, +} from "../../redux/explorer"; +import { Trans, withTranslation } from "react-i18next"; +import { selectLanguage } from "../../redux/viewUpdate/action"; +import OptionSelector from "../Modals/OptionSelector"; + +const styles = (theme) => ({ + layout: { + width: "auto", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 700, + marginLeft: "auto", + marginRight: "auto", + }, + }, + sectionTitle: { + paddingBottom: "10px", + paddingTop: "30px", + }, + rightIcon: { + marginTop: "4px", + marginRight: "10px", + color: theme.palette.text.secondary, + }, + uploadFromFile: { + backgroundColor: blue[100], + color: blue[600], + }, + userGravatar: { + backgroundColor: yellow[100], + color: yellow[800], + }, + policySelected: { + backgroundColor: green[100], + color: green[800], + }, + infoText: { + marginRight: "17px", + [theme.breakpoints.down("xs")]: { + maxWidth: 100, + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden", + }, + }, + infoTextWithIcon: { + marginRight: "17px", + marginTop: "1px", + [theme.breakpoints.down("xs")]: { + maxWidth: 100, + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden", + }, + }, + rightIconWithText: { + marginTop: "0px", + marginRight: "10px", + color: theme.palette.text.secondary, + }, + iconFix: { + marginRight: "11px", + marginLeft: "7px", + minWidth: 40, + }, + flexContainer: { + display: "flex", + }, + desenList: { + paddingTop: 0, + paddingBottom: 0, + }, + flexContainerResponse: { + display: "flex", + [theme.breakpoints.down("sm")]: { + display: "initial", + }, + }, + desText: { + marginTop: "10px", + }, + secondColor: { + height: "20px", + width: "20px", + backgroundColor: theme.palette.secondary.main, + borderRadius: "50%", + marginRight: "17px", + }, + firstColor: { + height: "20px", + width: "20px", + backgroundColor: theme.palette.primary.main, + borderRadius: "50%", + marginRight: "6px", + }, + themeBlock: { + height: "20px", + width: "20px", + }, + paddingBottom: { + marginBottom: "30px", + }, + paddingText: { + paddingRight: theme.spacing(2), + }, + qrcode: { + width: 128, + marginTop: 16, + marginRight: 16, + }, +}); + +const mapStateToProps = (state) => { + return { + title: state.siteConfig.title, + authn: state.siteConfig.authn, + viewMethod: state.viewUpdate.explorerViewMethod, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + applyThemes: (color) => { + dispatch(applyThemes(color)); + }, + toggleDaylightMode: () => { + dispatch(toggleDaylightMode()); + }, + changeView: (method) => { + dispatch(changeViewMethod(method)); + }, + selectLanguage: () => { + dispatch(selectLanguage()); + }, + }; +}; + +class UserSettingCompoment extends Component { + constructor(props) { + super(props); + this.fileInput = React.createRef(); + } + + state = { + avatarModal: false, + nickModal: false, + changePassword: false, + loading: "", + oldPwd: "", + newPwd: "", + webdavPwd: "", + newPwdRepeat: "", + twoFactor: false, + authCode: "", + changeTheme: false, + chosenTheme: null, + showWebDavUrl: false, + showWebDavUserName: false, + changeWebDavPwd: false, + groupBackModal: false, + changeTimeZone: false, + bindPhone: false, + settings: { + uid: 0, + group_expires: 0, + qq: "", + phone: "", + homepage: true, + two_factor: "", + two_fa_secret: "", + prefer_theme: "", + themes: {}, + authn: [], + }, + }; + + handleClose = () => { + this.setState({ + avatarModal: false, + nickModal: false, + changePassword: false, + loading: "", + twoFactor: false, + changeTheme: false, + showWebDavUrl: false, + showWebDavUserName: false, + changeWebDavPwd: false, + groupBackModal: false, + bindPhone: false, + }); + }; + + componentDidMount() { + this.loadSetting(); + } + + toggleViewMethod = () => { + const newMethod = + this.props.viewMethod === "icon" + ? "list" + : this.props.viewMethod === "list" + ? "smallIcon" + : "icon"; + Auth.SetPreference("view_method", newMethod); + this.props.changeView(newMethod); + }; + + loadSetting = () => { + API.get("/user/setting") + .then((response) => { + const theme = JSON.parse(response.data.themes); + response.data.themes = theme; + this.setState({ + settings: response.data, + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + doChangeGroup = () => { + API.patch("/user/setting/vip", {}) + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("vas.cancelSubscription"), + "success" + ); + this.handleClose(); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + useGravatar = () => { + this.setState({ + loading: "gravatar", + }); + API.put("/user/setting/avatar") + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("setting.avatarUpdated"), + "success" + ); + this.setState({ + loading: "", + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.setState({ + loading: "", + }); + }); + }; + + changeNick = () => { + this.setState({ + loading: "nick", + }); + API.patch("/user/setting/nick", { + nick: this.state.nick, + }) + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("setting.nickChanged"), + "success" + ); + this.setState({ + loading: "", + }); + this.handleClose(); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.setState({ + loading: "", + }); + }); + }; + + bindQQ = () => { + this.setState({ + loading: "nick", + }); + API.patch("/user/setting/qq", {}) + .then((response) => { + if (response.data === "") { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("vas.qqUnlinked"), + "success" + ); + this.setState({ + settings: { + ...this.state.settings, + qq: false, + }, + }); + } else { + window.location.href = response.data; + } + this.handleClose(); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }) + .then(() => { + this.setState({ + loading: "", + }); + }); + }; + + uploadAvatar = () => { + this.setState({ + loading: "avatar", + }); + const formData = new FormData(); + formData.append("avatar", this.fileInput.current.files[0]); + API.post("/user/setting/avatar", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("setting.avatarUpdated"), + "success" + ); + this.setState({ + loading: "", + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.setState({ + loading: "", + }); + }); + }; + + handleToggle = () => { + API.patch("/user/setting/homepage", { + status: !this.state.settings.homepage, + }) + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("setting.settingSaved"), + "success" + ); + this.setState({ + settings: { + ...this.state.settings, + homepage: !this.state.settings.homepage, + }, + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + changhePwd = () => { + if (this.state.newPwd !== this.state.newPwdRepeat) { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("login.passwordNotMatch"), + "warning" + ); + return; + } + this.setState({ + loading: "changePassword", + }); + API.patch("/user/setting/password", { + old: this.state.oldPwd, + new: this.state.newPwd, + }) + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("login.passwordReset"), + "success" + ); + this.setState({ + loading: "", + }); + this.handleClose(); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.setState({ + loading: "", + }); + }); + }; + + changeTheme = () => { + this.setState({ + loading: "changeTheme", + }); + API.patch("/user/setting/theme", { + theme: this.state.chosenTheme, + }) + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("setting.themeColorChanged"), + "success" + ); + this.props.applyThemes(this.state.chosenTheme); + this.setState({ + loading: "", + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.setState({ + loading: "", + }); + }); + }; + + changheWebdavPwd = () => { + this.setState({ + loading: "changheWebdavPwd", + }); + axios + .post("/Member/setWebdavPwd", { + pwd: this.state.webdavPwd, + }) + .then((response) => { + if (response.data.error === "1") { + this.props.toggleSnackbar( + "top", + "right", + response.data.msg, + "error" + ); + this.setState({ + loading: "", + }); + } else { + this.props.toggleSnackbar( + "top", + "right", + response.data.msg, + "success" + ); + this.setState({ + loading: "", + changeWebDavPwd: false, + }); + } + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.setState({ + loading: "", + }); + }); + }; + + init2FA = () => { + if (this.state.settings.two_factor) { + this.setState({ twoFactor: true }); + return; + } + API.get("/user/setting/2fa") + .then((response) => { + this.setState({ + two_fa_secret: response.data, + twoFactor: true, + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + twoFactor = () => { + this.setState({ + loading: "twoFactor", + }); + API.patch("/user/setting/2fa", { + code: this.state.authCode, + }) + .then(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("setting.settingSaved"), + "success" + ); + this.setState({ + loading: "", + settings: { + ...this.state.settings, + two_factor: !this.state.settings.two_factor, + }, + }); + this.handleClose(); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + this.setState({ + loading: "", + }); + }); + }; + + handleChange = (name) => (event) => { + this.setState({ [name]: event.target.value }); + }; + + handleAlignment = (event, chosenTheme) => this.setState({ chosenTheme }); + + toggleThemeMode = (current) => { + const newMode = + current === null ? "light" : current === "light" ? "dark" : null; + this.props.toggleDaylightMode(); + Auth.SetPreference("theme_mode", newMode); + }; + + bindPhone = () => { + this.setState({ bindPhone: true }); + }; + + render() { + const { classes, t } = this.props; + const user = Auth.GetUser(); + const dark = Auth.GetPreference("theme_mode"); + + return ( +
+
+ + {t("setting.profile")} + + + + + this.setState({ avatarModal: true }) + } + > + + + + + + + + + + + + + + + + + + {this.state.settings.uid} + + + + + + this.setState({ nickModal: true }) + } + > + + + + + + + this.setState({ nickModal: true }) + } + className={classes.flexContainer} + > + + {user.nickname} + + + + + + + + + + + + + + {user.user_name} + + + + {/**/} + {/* this.bindPhone()}>*/} + {/* */} + {/* */} + {/* */} + {/* */} + + {/* */} + {/* */} + {/* {this.state.settings.phone*/} + {/* ? this.state.settings.phone*/} + {/* : "绑定"}*/} + {/* */} + {/* */} + {/* */} + {/**/} + + + this.props.history.push("/buy?tab=1") + } + > + + + + + + + + {user.group.name} + {this.state.settings.group_expires && ( + + + , + ]} + /> + + + )} + + + + {this.state.settings.group_expires && ( +
+ + + this.setState({ + groupBackModal: true, + }) + } + > + + + + + + + + + +
+ )} + + this.bindQQ()}> + + + + + + + + {this.state.settings.qq + ? t("vas.unlink") + : t("vas.connect")} + + + + + + + + + + + + + + {user.score} + + + + + + + + + + + + + {formatLocalTime(user.created_at)} + + + +
+
+ + {t("setting.privacyAndSecurity")} + + + + + + + + + + + + + + + + this.setState({ changePassword: true }) + } + > + + + + + + + + + + + this.init2FA()}> + + + + + + + + {!this.state.settings.two_factor + ? t("setting.disabled") + : t("setting.enabled")} + + + + + + + + { + this.setState({ + settings: { + ...this.state.settings, + authn: [ + ...this.state.settings.authn, + credential, + ], + }, + }); + }} + remove={(id) => { + let credentials = [...this.state.settings.authn]; + credentials = credentials.filter((v) => { + return v.id !== id; + }); + this.setState({ + settings: { + ...this.state.settings, + authn: credentials, + }, + }); + }} + /> + + + {t("setting.appearance")} + + + + + this.setState({ changeTheme: true }) + } + > + + + + + + +
+
+ + + + this.toggleThemeMode(dark)} + > + + + + + + + + {dark && + (dark === "dark" + ? t("setting.enabled") + : t("setting.disabled"))} + {dark === null && + t("setting.syncWithSystem")} + + + + + + this.toggleViewMethod()} + > + + + + + + + + {this.props.viewMethod === "icon" && + t("fileManager.gridViewLarge")} + {this.props.viewMethod === "list" && + t("fileManager.listView")} + {this.props.viewMethod === + "smallIcon" && + t("fileManager.gridViewSmall")} + + + + + + + this.setState({ changeTimeZone: true }) + } + button + > + + + + + + + + {timeZone} + + + + + + this.props.selectLanguage()} + button + > + + + + + + + + + + + + {user.group.webdav && ( +
+ + WebDAV + + + + + this.setState({ + showWebDavUrl: true, + }) + } + > + + + + + + + + + + + + this.setState({ + showWebDavUserName: true, + }) + } + > + + + + + + + + + + + + this.props.history.push("/connect?") + } + > + + + + + + + + + + + +
+ )} +
+
+ + this.setState({ changeTimeZone: false })} + open={this.state.changeTimeZone} + /> + + {t("setting.avatar")} + + + + + + + + + + + + + + + + + + + + + + + + + {t("setting.changeNick")} + + + + + + + + + + + {t("vas.cancelSubscriptionTitle")} + + + {t("vas.cancelSubscriptionWarning")} + + + + + + + + {t("login.resetPassword")} + +
+ +
+
+ +
+
+ +
+
+ + + + +
+ + + {this.state.settings.two_factor + ? t("setting.disable2FA") + : t("setting.enable2FA")} + + +
+ {!this.state.settings.two_factor && ( +
+ + )} + {this.state.settings.two_factor && ( + + {t("setting.inputCurrent2FACode")} + + )} + +
+
+
+ + + + +
+ + {t("setting.themeColor")} + + + {Object.keys(this.state.settings.themes).map( + (value, key) => ( + +
+ + ) + )} + + + + + + +
+ + {t("setting.webdavServer")} + + + + + + + + + {t("setting.userName")} + + + + + + + + +
+ ); + } +} + +const UserSetting = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(UserSettingCompoment)))); + +export default UserSetting; diff --git a/src/component/Setting/WebDAV.js b/src/component/Setting/WebDAV.js new file mode 100644 index 0000000..6f5e570 --- /dev/null +++ b/src/component/Setting/WebDAV.js @@ -0,0 +1,494 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles, Typography } from "@material-ui/core"; +import { useDispatch, useSelector } from "react-redux"; +import Paper from "@material-ui/core/Paper"; +import Tabs from "@material-ui/core/Tabs"; +import Tab from "@material-ui/core/Tab"; +import Button from "@material-ui/core/Button"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableCell from "@material-ui/core/TableCell"; +import TableBody from "@material-ui/core/TableBody"; +import Alert from "@material-ui/lab/Alert"; +import Auth from "../../middleware/Auth"; +import API from "../../middleware/Api"; +import IconButton from "@material-ui/core/IconButton"; +import { Cloud, CloudOff, Delete } from "@material-ui/icons"; +import CreateWebDAVAccount from "../Modals/CreateWebDAVAccount"; +import TimeAgo from "timeago-react"; +import CreateWebDAVMount from "../Modals/CreateWebDAVMount"; +import Link from "@material-ui/core/Link"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useLocation } from "react-router"; +import Nothing from "../Placeholder/Nothing"; +import { useTranslation } from "react-i18next"; +import AppPromotion from "./AppPromotion"; +import Tooltip from "@material-ui/core/Tooltip"; +import ToggleIcon from "material-ui-toggle-icon"; +import { Pencil, PencilOff } from "mdi-material-ui"; + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "50px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: "50px", + }, + content: { + marginTop: theme.spacing(4), + }, + cardContent: { + padding: theme.spacing(2), + }, + tableContainer: { + overflowX: "auto", + }, + create: { + marginTop: theme.spacing(2), + }, + copy: { + marginLeft: 10, + }, +})); + +export default function WebDAV() { + const { t } = useTranslation(); + const query = parseInt( + new URLSearchParams(useLocation().search).get("tab") + ); + const [tab, setTab] = useState(query ? query : 0); + const [create, setCreate] = useState(false); + const [mount, setMount] = useState(false); + const [accounts, setAccounts] = useState([]); + const [folders, setFolders] = useState([]); + + const appPromotion = useSelector((state) => state.siteConfig.app_promotion); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const copyToClipboard = (text) => { + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + ToggleSnackbar("top", "center", t("setting.copied"), "success"); + } else { + ToggleSnackbar( + "top", + "center", + t("setting.pleaseManuallyCopy"), + "warning" + ); + } + }; + + const loadList = () => { + API.get("/webdav/accounts") + .then((response) => { + setAccounts(response.data.accounts); + setFolders(response.data.folders); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + useEffect(() => { + loadList(); + // eslint-disable-next-line + }, []); + + const deleteAccount = (id) => { + const account = accounts[id]; + API.delete("/webdav/accounts/" + account.ID) + .then(() => { + let accountCopy = [...accounts]; + accountCopy = accountCopy.filter((v, i) => { + return i !== id; + }); + setAccounts(accountCopy); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const deleteMount = (id) => { + const folder = folders[id]; + API.delete("/webdav/mount/" + folder.id) + .then(() => { + let folderCopy = [...folders]; + folderCopy = folderCopy.filter((v, i) => { + return i !== id; + }); + setFolders(folderCopy); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const toggleAccountReadonly = (id) => { + const account = accounts[id]; + API.patch("/webdav/accounts", { + id: account.ID, + readonly: !account.Readonly, + }) + .then((response) => { + account.Readonly = response.data.readonly; + const accountCopy = [...accounts]; + setAccounts(accountCopy); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const toggleAccountUseProxy = (id) => { + const account = accounts[id]; + API.patch("/webdav/accounts", { + id: account.ID, + use_proxy: !account.UseProxy, + }) + .then((response) => { + account.UseProxy = response.data.use_proxy; + const accountCopy = [...accounts]; + setAccounts(accountCopy); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const addAccount = (account) => { + setCreate(false); + API.post("/webdav/accounts", { + path: account.path, + name: account.name, + }) + .then((response) => { + setAccounts([ + { + ID: response.data.id, + Password: response.data.password, + CreatedAt: response.data.created_at, + Name: account.name, + Root: account.path, + Readonly: account.Readonly, + }, + ...accounts, + ]); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const addMount = (mountInfo) => { + setMount(false); + API.post("/webdav/mount", { + path: mountInfo.path, + policy: mountInfo.policy, + }) + .then(() => { + loadList(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const classes = useStyles(); + const user = Auth.GetUser(); + + return ( +
+ setCreate(false)} + /> + setMount(false)} + /> + + {t("navbar.connect")} + + + setTab(newValue)} + aria-label="disabled tabs example" + > + + + {appPromotion && } + +
+ {tab === 0 && ( +
+ + {t("setting.webdavHint", { + url: window.location.origin + "/dav", + name: user.user_name, + })} + + + + + + + {t("setting.annotation")} + + + {t("login.password")} + + + {t("setting.rootFolder")} + + + {t("setting.createdAt")} + + + {t("setting.action")} + + + + + {accounts.map((row, id) => ( + + + {row.Name} + + + {row.Password} + + copyToClipboard( + row.Password + ) + } + href={"javascript:void"} + > + {t("copyToClipboard", { + ns: "common", + })} + + + + {row.Root} + + + + + + + toggleAccountReadonly( + id + ) + } + > + + + } + offIcon={ + + } + /> + + + {user.group.allowWebDAVProxy && ( + toggleAccountUseProxy( + id + ) + } + > + + + } + offIcon={ + + } + /> + + )} + + deleteAccount(id) + } + > + + + + + + + ))} + +
+ {accounts.length === 0 && ( + + )} +
+ +
+ )} + {tab === 1 && ( +
+ + {t("vas.mountDescription")} + + + + + + + {t("fileManager.folders")} + + + {t("fileManager.storagePolicy")} + + + {t("setting.action")} + + + + + {folders.map((row, id) => ( + + + {row.name} + + + {row.policy_name} + + + + deleteMount(id) + } + > + + + + + ))} + +
+ {folders.length === 0 && ( + + )} +
+ +
+ )} + {tab === 2 && } +
+
+
+ ); +} diff --git a/src/component/Share/Creator.js b/src/component/Share/Creator.js new file mode 100644 index 0000000..900f8f4 --- /dev/null +++ b/src/component/Share/Creator.js @@ -0,0 +1,107 @@ +import React from "react"; +import { makeStyles } from "@material-ui/core/styles"; +import { Avatar, Typography } from "@material-ui/core"; +import { useHistory } from "react-router"; +import Link from "@material-ui/core/Link"; +import { formatLocalTime } from "../../utils/datetime"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + boxHeader: { + textAlign: "center", + padding: 24, + }, + avatar: { + backgroundColor: theme.palette.secondary.main, + margin: "0 auto", + width: 50, + height: 50, + cursor: "pointer", + }, + shareDes: { + marginTop: 12, + }, + shareInfo: { + color: theme.palette.text.disabled, + fontSize: 14, + }, +})); + +export default function Creator(props) { + const { t } = useTranslation("application", { keyPrefix: "share" }); + const classes = useStyles(); + const history = useHistory(); + + const getSecondDes = () => { + if (props.share.expire > 0) { + if (props.share.expire >= 24 * 3600) { + return t("expireInXDays", { + num: Math.round(props.share.expire / (24 * 3600)), + }); + } + + return t("expireInXHours", { + num: Math.round(props.share.expire / 3600), + }); + } + return formatLocalTime(props.share.create_date); + }; + + const userProfile = () => { + history.push("/profile/" + props.share.creator.key); + props.onClose && props.onClose(); + }; + + return ( +
+ userProfile()} + /> + + {props.isFolder && ( + userProfile()} + href={"javascript:void(0)"} + color="inherit" + />, + ]} + /> + )} + {!props.isFolder && ( + userProfile()} + href={"javascript:void(0)"} + color="inherit" + />, + ]} + /> + )} + + + {t("statistics", { + views: props.share.views, + downloads: props.share.downloads, + time: getSecondDes(), + })} + +
+ ); +} diff --git a/src/component/Share/LockedFile.js b/src/component/Share/LockedFile.js new file mode 100644 index 0000000..b8a1f2e --- /dev/null +++ b/src/component/Share/LockedFile.js @@ -0,0 +1,145 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; + +import { + Avatar, + Button, + Card, + CardActions, + CardContent, + CardHeader, + Divider, + TextField, + withStyles, +} from "@material-ui/core"; +import { withRouter } from "react-router"; +import { formatLocalTime } from "../../utils/datetime"; +import { toggleSnackbar } from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; + +const styles = (theme) => ({ + card: { + maxWidth: 400, + margin: "0 auto", + }, + actions: { + display: "flex", + }, + layout: { + width: "auto", + marginTop: "110px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + }, + continue: { + marginLeft: "auto", + marginRight: "10px", + marginRottom: "10px", + }, +}); +const mapStateToProps = () => { + return {}; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +class LockedFileCompoment extends Component { + constructor(props) { + super(props); + const query = new URLSearchParams(this.props.location.search); + this.state = { + pwd: query.get("password"), + }; + } + + handleChange = (name) => (event) => { + this.setState({ [name]: event.target.value }); + }; + + submit = (e) => { + e.preventDefault(); + if (this.state.pwd === "") { + return; + } + this.props.setPassowrd(this.state.pwd); + }; + + render() { + const { classes, t } = this.props; + + return ( +
+ + + } + title={t("share.privateShareTitle", { + nick: this.props.share.creator.nick, + })} + subheader={formatLocalTime( + this.props.share.create_date + )} + /> + + +
+ + +
+ + + +
+
+ ); + } +} + +const LockedFile = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(LockedFileCompoment)))); + +export default LockedFile; diff --git a/src/component/Share/MyShare.js b/src/component/Share/MyShare.js new file mode 100644 index 0000000..5b0da66 --- /dev/null +++ b/src/component/Share/MyShare.js @@ -0,0 +1,515 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import OpenIcon from "@material-ui/icons/OpenInNew"; +import Pagination from "@material-ui/lab/Pagination"; +import FolderIcon from "@material-ui/icons/Folder"; +import LockIcon from "@material-ui/icons/Lock"; +import UnlockIcon from "@material-ui/icons/LockOpen"; +import EyeIcon from "@material-ui/icons/RemoveRedEye"; +import DeleteIcon from "@material-ui/icons/Delete"; + +import { + Avatar, + Button, + Card, + CardActions, + CardHeader, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + IconButton, + TextField, + Tooltip, + Typography, + withStyles, +} from "@material-ui/core"; +import API from "../../middleware/Api"; +import TypeIcon from "../FileManager/TypeIcon"; +import Chip from "@material-ui/core/Chip"; +import Divider from "@material-ui/core/Divider"; +import { VisibilityOff, VpnKey } from "@material-ui/icons"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import FormControl from "@material-ui/core/FormControl"; +import { withRouter } from "react-router-dom"; +import ToggleIcon from "material-ui-toggle-icon"; +import { formatLocalTime } from "../../utils/datetime"; +import { toggleSnackbar } from "../../redux/explorer"; +import Nothing from "../Placeholder/Nothing"; +import { withTranslation } from "react-i18next"; + +const styles = (theme) => ({ + cardContainer: { + padding: theme.spacing(1), + }, + card: { + maxWidth: 400, + margin: "0 auto", + }, + actions: { + display: "flex", + }, + layout: { + width: "auto", + marginTop: "50px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + }, + shareTitle: { + maxWidth: "200px", + }, + avatarFile: { + backgroundColor: theme.palette.primary.light, + }, + avatarFolder: { + backgroundColor: theme.palette.secondary.light, + }, + gird: { + marginTop: "30px", + }, + loadMore: { + textAlign: "right", + marginTop: "20px", + marginBottom: "40px", + }, + badge: { + marginLeft: theme.spacing(1), + height: 17, + }, + orderSelect: { + textAlign: "right", + marginTop: 5, + }, +}); +const mapStateToProps = () => { + return {}; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +class MyShareCompoment extends Component { + state = { + page: 1, + total: 0, + shareList: [], + showPwd: null, + orderBy: "created_at DESC", + }; + + componentDidMount = () => { + this.loadList(1, this.state.orderBy); + }; + + showPwd = (pwd) => { + this.setState({ showPwd: pwd }); + }; + + handleClose = () => { + this.setState({ showPwd: null }); + }; + + removeShare = (id) => { + API.delete("/share/" + id) + .then(() => { + let oldList = this.state.shareList; + oldList = oldList.filter((value) => { + return value.key !== id; + }); + this.setState({ + shareList: oldList, + total: this.state.total - 1, + }); + this.props.toggleSnackbar( + "top", + "right", + this.props.t("share.shareCanceled"), + "success" + ); + if (oldList.length === 0) { + this.loadList(1, this.state.orderBy); + } + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + changePermission = (id) => { + const newPwd = Math.random().toString(36).substr(2).slice(2, 8); + const oldList = this.state.shareList; + const shareIndex = oldList.findIndex((value) => { + return value.key === id; + }); + API.patch("/share/" + id, { + prop: "password", + value: oldList[shareIndex].password === "" ? newPwd : "", + }) + .then((response) => { + oldList[shareIndex].password = response.data; + this.setState({ + shareList: oldList, + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + changePreviewOption = (id) => { + const oldList = this.state.shareList; + const shareIndex = oldList.findIndex((value) => { + return value.key === id; + }); + API.patch("/share/" + id, { + prop: "preview_enabled", + value: oldList[shareIndex].preview ? "false" : "true", + }) + .then((response) => { + oldList[shareIndex].preview = response.data; + this.setState({ + shareList: oldList, + }); + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + loadList = (page, orderBy) => { + const order = orderBy.split(" "); + API.get( + "/share?page=" + + page + + "&order_by=" + + order[0] + + "&order=" + + order[1] + ) + .then((response) => { + this.setState({ + total: response.data.total, + shareList: response.data.items, + }); + }) + .catch(() => { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("share.listLoadingError"), + "error" + ); + }); + }; + + handlePageChange = (event, value) => { + this.setState({ + page: value, + }); + this.loadList(value, this.state.orderBy); + }; + + handleOrderChange = (event) => { + this.setState({ + orderBy: event.target.value, + }); + this.loadList(this.state.page, event.target.value); + }; + + isExpired = (share) => { + return share.expire < -1 || share.remain_downloads === 0; + }; + + render() { + const { classes, t } = this.props; + + return ( +
+ + + + {t("share.sharedFiles")} + + + + + + + + + + {this.state.shareList.length === 0 && ( + + )} + {this.state.shareList.map((value) => ( + + + + {!value.is_dir && ( + + )}{" "} + {value.is_dir && ( + + + + )} +
+ } + title={ + + + {value.source + ? value.source.name + : t("share.sourceNotFound")} + + + } + subheader={ + + {formatLocalTime(value.create_date)} + {this.isExpired(value) && ( + + )} + + } + /> + + + + + this.props.history.push( + "/s/" + + value.key + + (value.password === "" + ? "" + : "?password=" + + value.password) + ) + } + > + + + {" "} + {value.password !== "" && ( + <> + + this.changePermission( + value.key + ) + } + > + + + + + + this.showPwd(value.password) + } + > + + + + + + )} + {value.password === "" && ( + + this.changePermission(value.key) + } + > + + + + + )} + + this.changePreviewOption(value.key) + } + > + + + } + offIcon={ + + } + /> + + + + this.removeShare(value.key) + } + > + + + + + + + + ))} + +
+ +
{" "} + + {t("share.sharePassword")} {" "} + + + {" "} + + {" "} + {" "} + {" "} +
+ ); + } +} + +const MyShare = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(MyShareCompoment)))); + +export default MyShare; diff --git a/src/component/Share/NotFound.js b/src/component/Share/NotFound.js new file mode 100644 index 0000000..0ce52ae --- /dev/null +++ b/src/component/Share/NotFound.js @@ -0,0 +1,32 @@ +import React from "react"; +import SentimentVeryDissatisfiedIcon from "@material-ui/icons/SentimentVeryDissatisfied"; +import { lighten, makeStyles } from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + icon: { + fontSize: "160px", + }, + emptyContainer: { + bottom: "0", + height: "300px", + margin: "50px auto", + width: "300px", + color: lighten(theme.palette.text.disabled, 0.4), + textAlign: "center", + paddingTop: "20px", + }, + emptyInfoBig: { + fontSize: "25px", + color: lighten(theme.palette.text.disabled, 0.4), + }, +})); + +export default function Notice(props) { + const classes = useStyles(); + return ( +
+ +
{props.msg}
+
+ ); +} diff --git a/src/component/Share/ReadMe.js b/src/component/Share/ReadMe.js new file mode 100644 index 0000000..d4b0d12 --- /dev/null +++ b/src/component/Share/ReadMe.js @@ -0,0 +1,143 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; +import { MenuBook } from "@material-ui/icons"; +import { Typography } from "@material-ui/core"; +import Divider from "@material-ui/core/Divider"; +import Paper from "@material-ui/core/Paper"; +import TextLoading from "../Placeholder/TextLoading"; +import API from "../../middleware/Api"; +import { useDispatch } from "react-redux"; +import Editor from "for-editor"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + readMeContainer: { + marginTop: 30, + [theme.breakpoints.down("sm")]: { + marginTop: theme.spacing(2), + }, + }, + readMeHeader: { + padding: "10px 16px", + display: "flex", + color: theme.palette.text.secondary, + }, + readMeIcon: { + marginRight: 8, + }, + content: {}, + "@global": { + //如果嵌套主题,则应该定位[class * =“MuiButton-root”]。 + ".for-container": { + border: "none!important", + }, + ".for-container .for-editor .for-editor-edit": { + height: "0!important", + }, + ".for-container > div:first-child": { + borderTopLeftRadius: "0!important", + borderTopRightRadius: "0!important", + }, + ".for-container .for-editor .for-panel .for-preview": { + backgroundColor: theme.palette.background.paper + "!important", + color: theme.palette.text.primary + "!important", + }, + ".for-container .for-markdown-preview pre": { + backgroundColor: theme.palette.background.default + "!important", + color: + theme.palette.type === "dark" + ? "#fff !important" + : "rgba(0, 0, 0, 0.87);!important", + }, + + ".for-container .for-markdown-preview code": { + backgroundColor: theme.palette.background.default + "!important", + }, + ".for-container .for-markdown-preview a": { + color: + theme.palette.type === "dark" + ? "#67aeff !important" + : "#0366d6 !important", + }, + ".for-container .for-markdown-preview table th": { + backgroundColor: theme.palette.background.default + "!important", + }, + }, +})); + +export default function ReadMe(props) { + const { t } = useTranslation(); + const classes = useStyles(); + const theme = useTheme(); + + const [loading, setLoading] = useState(true); + const [content, setContent] = useState(""); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const $vm = React.createRef(); + + useEffect(() => { + setLoading(true); + const previewPath = + props.file.path === "/" + ? props.file.path + props.file.name + : props.file.path + "/" + props.file.name; + API.get( + "/share/readme/" + + props.share.key + + "?path=" + + encodeURIComponent(previewPath) + ) + .then((response) => { + setContent(response.rawData.toString()); + }) + .catch((error) => { + ToggleSnackbar( + "top", + "right", + t("share.readmeError", { msg: error.message }), + "error" + ); + }) + .then(() => { + setLoading(false); + }); + // eslint-disable-next-line + }, [props.share, props.file]); + + return ( + +
+ + {props.file.name} +
+ + +
+ {loading && } + {!loading && ( + setContent(value)} + preview + toolbar={{}} + /> + )} +
+
+ ); +} diff --git a/src/component/Share/SearchResult.js b/src/component/Share/SearchResult.js new file mode 100644 index 0000000..5ae6fe5 --- /dev/null +++ b/src/component/Share/SearchResult.js @@ -0,0 +1,287 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import OpenIcon from "@material-ui/icons/OpenInNew"; +import Pagination from "@material-ui/lab/Pagination"; +import FolderIcon from "@material-ui/icons/Folder"; + +import { + Avatar, + Card, + CardHeader, + Grid, + IconButton, + Tooltip, + Typography, +} from "@material-ui/core"; +import API from "../../middleware/Api"; +import TypeIcon from "../FileManager/TypeIcon"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import FormControl from "@material-ui/core/FormControl"; +import { useHistory } from "react-router-dom"; +import { makeStyles } from "@material-ui/core/styles"; +import { useLocation } from "react-router"; +import TimeAgo from "timeago-react"; +import { toggleSnackbar } from "../../redux/explorer"; +import Nothing from "../Placeholder/Nothing"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + cardContainer: { + padding: theme.spacing(1), + }, + card: { + maxWidth: 400, + margin: "0 auto", + }, + actions: { + display: "flex", + }, + layout: { + width: "auto", + marginTop: "50px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + }, + shareTitle: { + maxWidth: "200px", + }, + avatarFile: { + backgroundColor: theme.palette.primary.light, + }, + avatarFolder: { + backgroundColor: theme.palette.secondary.light, + }, + gird: { + marginTop: "30px", + }, + loadMore: { + textAlign: "right", + marginTop: "20px", + marginBottom: "40px", + }, + badge: { + marginLeft: theme.spacing(1), + height: 17, + }, + orderSelect: { + textAlign: "right", + marginTop: 5, + }, + cardAction: { + marginTop: 0, + }, +})); + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +export default function SearchResult() { + const { t } = useTranslation("application", { keyPrefix: "share" }); + const { t: tGlobal } = useTranslation(); + const classes = useStyles(); + const dispatch = useDispatch(); + + const query = useQuery(); + const location = useLocation(); + const history = useHistory(); + + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [shareList, setShareList] = useState([]); + const [orderBy, setOrderBy] = useState("created_at DESC"); + + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + const search = (keywords, page, orderBy) => { + const order = orderBy.split(" "); + API.get( + "/share/search?page=" + + page + + "&order_by=" + + order[0] + + "&order=" + + order[1] + + "&keywords=" + + encodeURIComponent(keywords) + ) + .then((response) => { + setTotal(response.data.total); + setShareList(response.data.items); + }) + .catch(() => { + ToggleSnackbar("top", "right", t("listLoadingError"), "error"); + }); + }; + + useEffect(() => { + const keywords = query.get("keywords"); + if (keywords) { + search(keywords, page, orderBy); + } else { + ToggleSnackbar("top", "right", t("enterKeywords"), "warning"); + } + }, [location]); + + const handlePageChange = (event, value) => { + setPage(value); + const keywords = query.get("keywords"); + search(keywords, value, orderBy); + }; + + const handleOrderChange = (event) => { + setOrderBy(event.target.value); + const keywords = query.get("keywords"); + search(keywords, page, event.target.value); + }; + + return ( +
+ + + + {t("searchResult")} + + + + + + + + + + {shareList.length === 0 && } + {shareList.map((value) => ( + + + + {!value.is_dir && ( + + )}{" "} + {value.is_dir && ( + + + + )} +
+ } + action={ + + + history.push("/s/" + value.key) + } + > + + + + } + title={ + + + {value.source + ? value.source.name + : t("sourceNotFound")} + + + } + subheader={ + + , + ]} + /> + + } + /> + + + ))} + +
+ +
{" "} +
+ ); +} diff --git a/src/component/Share/SharePreload.js b/src/component/Share/SharePreload.js new file mode 100644 index 0000000..dea490c --- /dev/null +++ b/src/component/Share/SharePreload.js @@ -0,0 +1,104 @@ +import React, { Suspense, useCallback, useEffect, useState } from "react"; +import PageLoading from "../Placeholder/PageLoading"; +import { useParams } from "react-router"; +import API from "../../middleware/Api"; +import { changeSubTitle } from "../../redux/viewUpdate/action"; +import { useDispatch } from "react-redux"; +import Notice from "./NotFound"; +import LockedFile from "./LockedFile"; +import SharedFile from "./SharedFile"; +import SharedFolder from "./SharedFolder"; +import { toggleSnackbar } from "../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +export default function SharePreload() { + const { t } = useTranslation("application", { keyPrefix: "share" }); + const dispatch = useDispatch(); + const { id } = useParams(); + + const [share, setShare] = useState(undefined); + const [loading, setLoading] = useState(false); + const [password, setPassword] = useState(""); + + const SetSubTitle = useCallback( + (title) => dispatch(changeSubTitle(title)), + [dispatch] + ); + + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + if (share) { + if (share.locked) { + SetSubTitle( + t("privateShareTitle", { nick: share.creator.nick }) + ); + if (password !== "") { + ToggleSnackbar( + "top", + "right", + t("incorrectPassword"), + "warning" + ); + } + } else { + SetSubTitle(share.source.name); + } + } else { + SetSubTitle(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [share, SetSubTitle, ToggleSnackbar]); + + useEffect(() => { + return () => { + SetSubTitle(); + }; + // eslint-disable-next-line + }, []); + + useEffect(() => { + setLoading(true); + let withPassword = ""; + if (password !== "") { + withPassword = "?password=" + password; + } + API.get("/share/info/" + id + withPassword) + .then((response) => { + setShare(response.data); + setLoading(false); + }) + .catch((error) => { + setLoading(false); + if (error.code === 404) { + setShare(null); + } else { + ToggleSnackbar("top", "right", error.message, "error"); + } + }); + }, [id, password, ToggleSnackbar]); + + return ( + }> + {share === undefined && } + {share === null && } + {share && share.locked && ( + + )} + {share && !share.locked && !share.is_dir && ( + + )} + {share && !share.locked && share.is_dir && ( + + )} + + ); +} diff --git a/src/component/Share/SharedFile.js b/src/component/Share/SharedFile.js new file mode 100644 index 0000000..b0d71b8 --- /dev/null +++ b/src/component/Share/SharedFile.js @@ -0,0 +1,334 @@ +import React, { Component } from "react"; +import { connect, useSelector } from "react-redux"; +import { sizeToString, vhCheck } from "../../utils"; +import { isPreviewable } from "../../config"; +import { Button, Typography, withStyles } from "@material-ui/core"; +import Divider from "@material-ui/core/Divider"; +import TypeIcon from "../FileManager/TypeIcon"; +import Auth from "../../middleware/Auth"; +import PurchaseShareDialog from "../Modals/PurchaseShare"; +import { withRouter } from "react-router-dom"; +import Creator from "./Creator"; +import pathHelper from "../../utils/page"; +import Report from "../Modals/Report"; +import { + openMusicDialog, + openResaveDialog, + setSelectedTarget, + showAudioPreview, + showImgPreivew, + toggleSnackbar, +} from "../../redux/explorer"; +import { startDownload } from "../../redux/explorer/action"; +import { trySharePurchase } from "../../redux/explorer/async"; +import { withTranslation } from "react-i18next"; + +vhCheck(); +const styles = (theme) => ({ + layout: { + width: "auto", + marginTop: "90px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginTop: "90px", + marginLeft: "auto", + marginRight: "auto", + }, + [theme.breakpoints.down("sm")]: { + marginTop: 0, + marginLeft: 0, + marginRight: 0, + }, + justifyContent: "center", + display: "flex", + }, + player: { + borderRadius: "4px", + }, + fileCotainer: { + width: "200px", + margin: "0 auto", + }, + buttonCotainer: { + width: "400px", + margin: "0 auto", + textAlign: "center", + marginTop: "20px", + }, + paper: { + padding: theme.spacing(2), + }, + icon: { + borderRadius: "10%", + marginTop: 2, + }, + + box: { + width: "100%", + maxWidth: 490, + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + boxShadow: "0 8px 16px rgba(29,39,55,.25)", + [theme.breakpoints.down("sm")]: { + height: "calc(var(--vh, 100vh) - 56px)", + borderRadius: 0, + maxWidth: 1000, + }, + display: "flex", + flexDirection: "column", + }, + boxContent: { + padding: 24, + display: "flex", + flex: "1", + }, + fileName: { + marginLeft: 20, + }, + fileSize: { + color: theme.palette.text.disabled, + fontSize: 14, + }, + boxFooter: { + display: "flex", + padding: "20px 16px", + justifyContent: "space-between", + }, + downloadButton: { + marginLeft: 8, + }, +}); +const mapStateToProps = () => { + return {}; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + openMusicDialog: (first) => { + dispatch(showAudioPreview(first)); + }, + setSelectedTarget: (targets) => { + dispatch(setSelectedTarget(targets)); + }, + showImgPreivew: (first) => { + dispatch(showImgPreivew(first)); + }, + openResave: (key) => { + dispatch(openResaveDialog(key)); + }, + startDownload: (share, file) => { + dispatch(startDownload(share, file)); + }, + trySharePurchase: (share) => dispatch(trySharePurchase(share)), + }; +}; + +const Modals = React.lazy(() => import("../FileManager/Modals")); +const ImgPreview = React.lazy(() => import("../FileManager/ImgPreview")); + +class SharedFileCompoment extends Component { + state = { + anchorEl: null, + open: false, + purchaseCallback: null, + loading: false, + openReport: false, + }; + + downloaded = false; + + // TODO merge into react thunk + preview = () => { + if (pathHelper.isSharePage(this.props.location.pathname)) { + const user = Auth.GetUser(); + if (!Auth.Check() && user && !user.group.shareDownload) { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("share.pleaseLogin"), + "warning" + ); + return; + } + } + + switch (isPreviewable(this.props.share.source.name)) { + case "img": + this.props.showImgPreivew({ + key: this.props.share.key, + name: this.props.share.source.name, + }); + return; + case "msDoc": + this.props.history.push( + this.props.share.key + + "/doc?name=" + + encodeURIComponent(this.props.share.source.name) + ); + return; + case "audio": + this.props.openMusicDialog({ + key: this.props.share.key, + name: this.props.share.source.name, + type: "share", + }); + return; + case "video": + this.props.history.push( + this.props.share.key + + "/video?name=" + + encodeURIComponent(this.props.share.source.name) + ); + return; + case "edit": + this.props.history.push( + this.props.share.key + + "/text?name=" + + encodeURIComponent(this.props.share.source.name) + ); + return; + case "pdf": + this.props.history.push( + this.props.share.key + + "/pdf?name=" + + encodeURIComponent(this.props.share.source.name) + ); + return; + case "code": + this.props.history.push( + this.props.share.key + + "/code?name=" + + encodeURIComponent(this.props.share.source.name) + ); + return; + case "epub": + this.props.history.push( + this.props.share.key + + "/epub?name=" + + encodeURIComponent(this.props.share.source.name) + ); + return; + default: + this.props.toggleSnackbar( + "top", + "right", + this.props.t("share.cannotShare"), + "warning" + ); + return; + } + }; + + scoreHandler = (callback) => (event) => { + this.props.trySharePurchase(this.props.share).then(() => callback()); + }; + + componentWillUnmount() { + this.props.setSelectedTarget([]); + } + + download = () => { + this.props.startDownload(this.props.share, null); + }; + + render() { + const { classes, t } = this.props; + const user = Auth.GetUser(); + const isLogin = Auth.Check(); + + return ( +
+ + + + this.setState({ openReport: false })} + /> +
+ + +
+ +
+ + {this.props.share.source.name} + + + {sizeToString(this.props.share.source.size)} + +
+
+ +
+
+ + +
+
+ {this.props.share.preview && ( + + )} + +
+
+
+
+ ); + } +} + +const SharedFile = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(SharedFileCompoment)))); + +export default SharedFile; diff --git a/src/component/Share/SharedFolder.js b/src/component/Share/SharedFolder.js new file mode 100644 index 0000000..3316740 --- /dev/null +++ b/src/component/Share/SharedFolder.js @@ -0,0 +1,153 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { Typography, withStyles } from "@material-ui/core"; +import { withRouter } from "react-router-dom"; +import FileManager from "../FileManager/FileManager"; +import Paper from "@material-ui/core/Paper"; +import Popover from "@material-ui/core/Popover"; +import Creator from "./Creator"; +import ClickAwayListener from "@material-ui/core/ClickAwayListener"; +import pathHelper from "../../utils/page"; +import { + openMusicDialog, + openResaveDialog, + setSelectedTarget, + setShareUserPopover, + showImgPreivew, + toggleSnackbar, +} from "../../redux/explorer"; +import { setShareInfo } from "../../redux/viewUpdate/action"; + +const styles = (theme) => ({ + layout: { + width: "auto", + marginTop: 30, + marginBottom: 30, + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + [theme.breakpoints.down("sm")]: { + marginTop: theme.spacing(2), + marginLeft: theme.spacing(1), + marginRight: theme.spacing(1), + }, + }, + managerContainer: { + overflowY: "auto", + }, +}); + +const ReadMe = React.lazy(() => import("./ReadMe")); + +const mapStateToProps = (state) => { + return { + anchorEl: state.viewUpdate.shareUserPopoverAnchorEl, + fileList: state.explorer.fileList, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + openMusicDialog: () => { + dispatch(openMusicDialog()); + }, + setSelectedTarget: (targets) => { + dispatch(setSelectedTarget(targets)); + }, + showImgPreivew: (first) => { + dispatch(showImgPreivew(first)); + }, + openResave: (key) => { + dispatch(openResaveDialog(key)); + }, + setShareUserPopover: (e) => { + dispatch(setShareUserPopover(e)); + }, + setShareInfo: (s) => { + dispatch(setShareInfo(s)); + }, + }; +}; + +class SharedFolderComponent extends Component { + state = {}; + + UNSAFE_componentWillMount() { + this.props.setShareInfo(this.props.share); + } + + componentWillUnmount() { + this.props.setShareInfo(null); + this.props.setSelectedTarget([]); + } + + handleClickAway = (e) => { + const ignore = e && e.clientY && e.clientY <= 64; + if (!pathHelper.isMobile() && !ignore) { + this.props.setSelectedTarget([]); + } + }; + + render() { + const { classes } = this.props; + let readmeShowed = false; + const id = this.props.anchorEl !== null ? "simple-popover" : undefined; + + return ( +
+ + + + + + {/* eslint-disable-next-line */} + {this.props.fileList.map((value) => { + if ( + (value.name.toLowerCase() === "readme.md" || + value.name.toLowerCase() === "readme.txt") && + !readmeShowed + ) { + readmeShowed = true; + return ; + } + })} + this.props.setShareUserPopover(null)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "center", + }} + transformOrigin={{ + vertical: "top", + horizontal: "center", + }} + > + + this.props.setShareUserPopover(null)} + share={this.props.share} + /> + + +
+ ); + } +} + +const SharedFolder = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(SharedFolderComponent))); + +export default SharedFolder; diff --git a/src/component/Uploader/Popup/MoreActions.js b/src/component/Uploader/Popup/MoreActions.js new file mode 100644 index 0000000..13b5089 --- /dev/null +++ b/src/component/Uploader/Popup/MoreActions.js @@ -0,0 +1,210 @@ +import { + Icon, + ListItemIcon, + makeStyles, + Menu, + MenuItem, + Tooltip, +} from "@material-ui/core"; +import React, { useCallback, useMemo, useState } from "react"; +import { useDispatch } from "react-redux"; +import API from "../../../middleware/Api"; +import { TaskType } from "../core/types"; +import { refreshStorage, toggleSnackbar } from "../../../redux/explorer"; +import Divider from "@material-ui/core/Divider"; +import CheckIcon from "@material-ui/icons/Check"; +import { DeleteEmpty } from "mdi-material-ui"; +import DeleteIcon from "@material-ui/icons/Delete"; +import ConcurrentOptionDialog from "../../Modals/ConcurrentOption"; +import Auth from "../../../middleware/Auth"; +import { ClearAll, Replay } from "@material-ui/icons"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + icon: { + minWidth: 38, + }, +})); + +export default function MoreActions({ + anchorEl, + onClose, + uploadManager, + deleteTask, + useAvgSpeed, + setUseAvgSpeed, + filter, + setFilter, + sorter, + setSorter, + cleanFinished, + retryFailed, +}) { + const { t } = useTranslation("application", { keyPrefix: "uploader" }); + const classes = useStyles(); + const dispatch = useDispatch(); + const [concurrentDialog, setConcurrentDialog] = useState(false); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const RefreshStorage = useCallback( + () => dispatch(refreshStorage()), + [dispatch] + ); + + const actionClicked = (next) => () => { + onClose(); + next(); + }; + + const cleanupSessions = () => { + uploadManager.cleanupSessions(); + API.delete("/file/upload") + .then((response) => { + if (response.rawData.code === 0) { + ToggleSnackbar( + "top", + "right", + t("uploadSessionCleaned"), + "success" + ); + } else { + ToggleSnackbar( + "top", + "right", + response.rawData.msg, + "warning" + ); + } + deleteTask((u) => u.task.type !== TaskType.resumeHint); + RefreshStorage(); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const open = Boolean(anchorEl); + const id = open ? "uploader-action-popover" : undefined; + + const listItems = useMemo( + () => [ + { + tooltip: t("hideCompletedTooltip"), + onClick: () => + setFilter(filter === "default" ? "ongoing" : "default"), + icon: filter !== "default" ? : , + text: t("hideCompleted"), + divider: true, + }, + { + tooltip: t("addTimeAscTooltip"), + onClick: () => setSorter("default"), + icon: sorter === "default" ? : , + text: t("addTimeAsc"), + divider: false, + }, + { + tooltip: t("addTimeDescTooltip"), + onClick: () => setSorter("reverse"), + icon: sorter === "reverse" ? : , + text: t("addTimeDesc"), + divider: true, + }, + { + tooltip: t("showInstantSpeedTooltip"), + onClick: () => setUseAvgSpeed(false), + icon: useAvgSpeed ? : , + text: t("showInstantSpeed"), + divider: false, + }, + { + tooltip: t("showAvgSpeedTooltip"), + onClick: () => setUseAvgSpeed(true), + icon: !useAvgSpeed ? : , + text: t("showAvgSpeed"), + divider: true, + }, + { + tooltip: t("cleanAllSessionTooltip"), + onClick: () => cleanupSessions(), + icon: , + text: t("cleanAllSession"), + divider: false, + }, + { + tooltip: t("cleanCompletedTooltip"), + onClick: () => cleanFinished(), + icon: , + text: t("cleanCompleted"), + divider: false, + }, + { + tooltip: t("retryFailedTasksTooltip"), + onClick: () => retryFailed(), + icon: , + text: t("retryFailedTasks"), + divider: true, + }, + { + tooltip: t("setConcurrentTooltip"), + onClick: () => setConcurrentDialog(true), + icon: , + text: t("setConcurrent"), + divider: false, + }, + ], + [ + useAvgSpeed, + setUseAvgSpeed, + sorter, + setSorter, + filter, + setFilter, + cleanFinished, + ] + ); + + const onConcurrentLimitSave = (val) => { + val = parseInt(val); + if (val > 0) { + Auth.SetPreference("concurrent_limit", val); + uploadManager.changeConcurrentLimit(parseInt(val)); + } + setConcurrentDialog(false); + }; + + return ( + <> + + {listItems.map((item) => ( + <> + + + + {item.icon} + + {item.text} + + + {item.divider && } + + ))} + + setConcurrentDialog(false)} + onSave={onConcurrentLimitSave} + /> + + ); +} diff --git a/src/component/Uploader/Popup/TaskDetail.js b/src/component/Uploader/Popup/TaskDetail.js new file mode 100644 index 0000000..db92912 --- /dev/null +++ b/src/component/Uploader/Popup/TaskDetail.js @@ -0,0 +1,107 @@ +import React from "react"; +import { makeStyles } from "@material-ui/core"; +import Grid from "@material-ui/core/Grid"; +import { sizeToString } from "../../../utils"; +import Link from "@material-ui/core/Link"; +import TimeAgo from "timeago-react"; +import { Status } from "../core/uploader/base"; +import { Trans, useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + infoTitle: { + fontWeight: 700, + }, + infoValue: { + color: theme.palette.text.secondary, + wordBreak: "break-all", + }, +})); + +export default function TaskDetail({ uploader, navigateToDst, error }) { + const { t } = useTranslation(); + const classes = useStyles(); + const items = [ + { + name: t("uploader.fileName"), + value: uploader.task.name, + }, + { + name: t("uploader.fileSize"), + value: `${sizeToString(uploader.task.size)} ${ + uploader.task.session && uploader.task.session.chunkSize > 0 + ? t("uploader.chunkDescription", { + total: uploader.task.chunkProgress.length, + size: sizeToString(uploader.task.session.chunkSize), + }) + : t("uploader.noChunks") + }`, + }, + { + name: t("fileManager.storagePolicy"), + value: uploader.task.policy.name, + }, + { + name: t("uploader.destination"), + value: ( + navigateToDst(uploader.task.dst)} + > + {uploader.task.dst === "/" + ? t("uploader.rootFolder") + : uploader.task.dst} + + ), + }, + uploader.task.session + ? { + name: t("uploader.uploadSession"), + value: ( + <> + , + ]} + /> + + ), + } + : null, + uploader.status === Status.error + ? { + name: t("uploader.errorDetails"), + value: error, + } + : null, + ]; + return ( + + {items.map((i) => ( + <> + {i && ( + + + {i.name} + + + {i.value} + + + )} + + ))} + + ); +} diff --git a/src/component/Uploader/Popup/TaskList.js b/src/component/Uploader/Popup/TaskList.js new file mode 100644 index 0000000..ac7a00b --- /dev/null +++ b/src/component/Uploader/Popup/TaskList.js @@ -0,0 +1,359 @@ +import React, { useMemo, useState } from "react"; +import { + Accordion, + AccordionDetails, + AppBar, + Dialog, + DialogContent, + Fade, + IconButton, + makeStyles, + Slide, + Toolbar, + Tooltip, + Typography, +} from "@material-ui/core"; +import { useTheme } from "@material-ui/core/styles"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; +import CloseIcon from "@material-ui/icons/Close"; +import ExpandMoreIcon from "@material-ui/icons/ExpandLess"; +import AddIcon from "@material-ui/icons/Add"; +import classnames from "classnames"; +import UploadTask from "./UploadTask"; +import { MoreHoriz } from "@material-ui/icons"; +import MoreActions from "./MoreActions"; +import { useSelector } from "react-redux"; +import { Virtuoso } from "react-virtuoso"; +import Nothing from "../../Placeholder/Nothing"; +import { lighten } from "@material-ui/core/styles/colorManipulator"; +import { Status } from "../core/uploader/base"; +import Auth from "../../../middleware/Auth"; +import { useTranslation } from "react-i18next"; +import { useUpload } from "../UseUpload"; + +const Transition = React.forwardRef(function Transition(props, ref) { + return ; +}); + +const useStyles = makeStyles((theme) => ({ + rootOverwrite: { + top: "auto!important", + left: "auto!important", + }, + appBar: { + position: "relative", + }, + flex: { + flex: 1, + }, + popup: { + alignItems: "flex-end", + justifyContent: "flex-end", + }, + dialog: { + margin: 0, + right: 10, + bottom: 10, + zIndex: 9999, + position: "fixed", + inset: "-1!important", + }, + paddingZero: { + padding: 0, + }, + dialogContent: { + [theme.breakpoints.up("md")]: { + width: 500, + minHeight: 300, + maxHeight: "calc(100vh - 140px)", + }, + padding: 0, + paddingTop: "0!important", + }, + virtualList: { + height: "100%", + maxHeight: "calc(100vh - 56px)", + [theme.breakpoints.up("md")]: { + minHeight: 300, + maxHeight: "calc(100vh - 140px)", + }, + }, + expandIcon: { + transform: "rotate(0deg)", + transition: theme.transitions.create("transform", { + duration: theme.transitions.duration.shortest, + }), + }, + expandIconExpanded: { + transform: "rotate(180deg)", + }, + toolbar: { + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + progress: { + transition: "width .4s linear", + zIndex: -1, + height: "100%", + position: "absolute", + left: 0, + top: 0, + }, +})); + +const sorters = { + default: (a, b) => a.id - b.id, + reverse: (a, b) => b.id - a.id, +}; + +const filters = { + default: (u) => true, + ongoing: (u) => u.status < Status.finished, +}; + +export default function TaskList({ + open, + onClose, + selectFile, + taskList, + onCancel, + uploadManager, + progress, + setUploaders, +}) { + const { t } = useTranslation("application", { keyPrefix: "uploader" }); + const classes = useStyles(); + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const path = useSelector((state) => state.navigator.path); + const [expanded, setExpanded] = useState(true); + const [useAvgSpeed, setUseAvgSpeed] = useState( + Auth.GetPreferenceWithDefault("use_avg_speed", true) + ); + const [anchorEl, setAnchorEl] = useState(null); + const [filter, setFilter] = useState( + Auth.GetPreferenceWithDefault("task_filter", "default") + ); + const [sorter, setSorter] = useState( + Auth.GetPreferenceWithDefault("task_sorter", "default") + ); + const [refreshList, setRefreshList] = useState(false); + + const handleActionClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleActionClose = () => { + setAnchorEl(null); + }; + + const close = (e, reason) => { + if (reason !== "backdropClick") { + onClose(); + } else { + setExpanded(false); + } + }; + const handlePanelChange = (event, isExpanded) => { + setExpanded(isExpanded); + }; + + useMemo(() => { + if (open) { + setExpanded(true); + } + }, [taskList]); + + const progressBar = useMemo( + () => + progress.totalSize > 0 ? ( + 0 && !expanded}> +
+
+
+ + ) : null, + [progress, expanded, classes, theme] + ); + + const list = useMemo(() => { + const currentList = taskList + .filter(filters[filter]) + .sort(sorters[sorter]); + if (currentList.length === 0) { + return ; + } + + return ( + ( + setRefreshList((r) => !r)} + /> + )} + /> + ); + }, [ + classes, + taskList, + useAvgSpeed, + fullScreen, + filter, + sorter, + refreshList, + ]); + + const retryFailed = () => { + taskList.forEach((task) => { + if (task.status === Status.error) { + task.reset(); + task.start(); + } + }); + }; + + return ( + <> + { + Auth.SetPreference("use_avg_speed", v); + setUseAvgSpeed(v); + }} + filter={filter} + sorter={sorter} + setFilter={(v) => { + Auth.SetPreference("task_filter", v); + setFilter(v); + }} + setSorter={(v) => { + Auth.SetPreference("task_sorter", v); + setSorter(v); + }} + retryFailed={retryFailed} + cleanFinished={() => + setUploaders((u) => u.filter(filters["ongoing"])) + } + /> + + + + {progressBar} + + + + + + + + {t("uploadTasks")} + + + + + + + + selectFile(path)} + > + + + + {!fullScreen && ( + + setExpanded(!expanded)} + > + + + + )} + + + + + {list} + + + + + + ); +} diff --git a/src/component/Uploader/Popup/UploadTask.js b/src/component/Uploader/Popup/UploadTask.js new file mode 100644 index 0000000..9cfb817 --- /dev/null +++ b/src/component/Uploader/Popup/UploadTask.js @@ -0,0 +1,428 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Divider, + Grow, + IconButton, + ListItem, + ListItemText, + makeStyles, + Tooltip, +} from "@material-ui/core"; +import TypeIcon from "../../FileManager/TypeIcon"; +import { useUpload } from "../UseUpload"; +import { Status } from "../core/uploader/base"; +import { UploaderError } from "../core/errors"; +import { filename, sizeToString } from "../../../utils"; +import { darken, lighten } from "@material-ui/core/styles/colorManipulator"; +import { useTheme } from "@material-ui/core/styles"; +import Chip from "@material-ui/core/Chip"; +import DeleteIcon from "@material-ui/icons/Delete"; +import RefreshIcon from "@material-ui/icons/Refresh"; +import useMediaQuery from "@material-ui/core/useMediaQuery"; +import { useDispatch } from "react-redux"; +import Link from "@material-ui/core/Link"; +import PlayArrow from "@material-ui/icons/PlayArrow"; +import withStyles from "@material-ui/core/styles/withStyles"; +import MuiExpansionPanel from "@material-ui/core/ExpansionPanel"; +import MuiExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary"; +import MuiExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails"; +import TaskDetail from "./TaskDetail"; +import { SelectType } from "../core"; +import { navigateTo } from "../../../redux/explorer"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + progressContent: { + position: "relative", + zIndex: 9, + }, + progress: { + transition: "width .4s linear", + zIndex: 1, + height: "100%", + position: "absolute", + left: 0, + top: 0, + }, + progressContainer: { + position: "relative", + }, + listAction: { + marginLeft: 20, + marginRight: 20, + }, + wordBreak: { + wordBreak: "break-all", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, + successStatus: { + color: theme.palette.success.main, + }, + errorStatus: { + color: theme.palette.warning.main, + wordBreak: "break-all", + [theme.breakpoints.up("sm")]: { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, + }, + disabledBadge: { + marginLeft: theme.spacing(1), + height: 18, + }, + delete: { + zIndex: 9, + }, + dstLink: { + color: theme.palette.success.main, + fontWeight: 600, + }, + fileNameContainer: { + display: "flex", + alignItems: "center", + }, +})); + +const ExpansionPanel = withStyles({ + root: { + maxWidth: "100%", + boxShadow: "none", + "&:not(:last-child)": { + borderBottom: 0, + }, + "&:before": { + display: "none", + }, + "&$expanded": { + margin: 0, + }, + }, + expanded: {}, +})(MuiExpansionPanel); + +const ExpansionPanelSummary = withStyles({ + root: { + minHeight: 0, + padding: 0, + display: "block", + "&$expanded": {}, + }, + content: { + margin: 0, + display: "block", + "&$expanded": { + margin: "0", + }, + }, + expanded: {}, +})(MuiExpansionPanelSummary); + +const ExpansionPanelDetails = withStyles((theme) => ({ + root: { + paddingLeft: 16, + paddingRight: 16, + paddingTop: 8, + paddingBottom: 8, + display: "block", + backgroundColor: theme.palette.background.default, + }, +}))(MuiExpansionPanelDetails); + +const getSpeedText = (speed, speedAvg, useSpeedAvg) => { + let displayedSpeed = speedAvg; + if (!useSpeedAvg) { + displayedSpeed = speed; + } + + return `${sizeToString(displayedSpeed ? displayedSpeed : 0)} /s`; +}; + +const getErrMsg = (error) => { + let errMsg = error.message; + if (error instanceof UploaderError) { + errMsg = error.Message(""); + } + + return errMsg; +}; + +export default function UploadTask({ + uploader, + useAvgSpeed, + onCancel, + onClose, + selectFile, + onRefresh, +}) { + const { t } = useTranslation("application", { keyPrefix: "uploader" }); + const classes = useStyles(); + const theme = useTheme(); + const [taskHover, setTaskHover] = useState(false); + const [expanded, setExpanded] = useState(false); + const { status, error, progress, speed, speedAvg, retry } = useUpload( + uploader + ); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const [loading, setLoading] = useState(false); + const dispatch = useDispatch(); + const NavigateTo = useCallback((k) => dispatch(navigateTo(k)), [dispatch]); + const navigateToDst = (path) => { + onClose(null, "backdropClick"); + NavigateTo(path); + }; + + const toggleDetail = (event, newExpanded) => { + setExpanded(!!newExpanded); + }; + + useEffect(() => { + if (status >= Status.finished) { + onRefresh(); + } + }, [status]); + + const statusText = useMemo(() => { + const parent = filename(uploader.task.dst); + switch (status) { + case Status.added: + case Status.initialized: + case Status.queued: + return
{t("pendingInQueue")}
; + case Status.preparing: + return
{t("preparing")}
; + case Status.error: + return ( +
+ {getErrMsg(error)} +
+
+ ); + case Status.finishing: + return
{t("processing")}
; + case Status.resumable: + return ( +
+ {t("progressDescription", { + uploaded: sizeToString(progress.total.loaded), + total: sizeToString(progress.total.size), + percentage: progress.total.percent.toFixed(2), + })} +
+ ); + case Status.processing: + if (progress) { + return ( +
+ {t("progressDescriptionFull", { + speed: getSpeedText( + speed, + speedAvg, + useAvgSpeed + ), + uploaded: sizeToString(progress.total.loaded), + total: sizeToString(progress.total.size), + percentage: progress.total.percent.toFixed(2), + })} +
+ ); + } + return
{t("progressDescriptionPlaceHolder")}
; + case Status.finished: + return ( +
+ {t("uploadedTo")} + + navigateToDst(uploader.task.dst)} + > + {parent === "" ? t("rootFolder") : parent} + + +
+
+ ); + default: + return
{t("unknownStatus")}
; + } + }, [status, error, progress, speed, speedAvg, useAvgSpeed]); + + const resumeLabel = useMemo( + () => + uploader.task.resumed && !fullScreen ? ( + + ) : null, + [status, fullScreen] + ); + + const continueLabel = useMemo( + () => + status === Status.resumable && !fullScreen ? ( + + ) : null, + [status, fullScreen] + ); + + const progressBar = useMemo( + () => + (status === Status.processing || + status === Status.finishing || + status === Status.resumable) && + progress ? ( +
+ ) : null, + [status, progress, theme] + ); + + const taskDetail = useMemo(() => { + return ( + + ); + }, [uploader, expanded]); + + const cancel = () => { + setLoading(true); + uploader.cancel().then(() => { + setLoading(false); + onCancel((u) => u.id != uploader.id); + }); + }; + + const stopRipple = (e) => { + e.stopPropagation(); + }; + + const secondaryAction = useMemo(() => { + if (!taskHover && !fullScreen) { + return <>; + } + + const actions = [ + { + show: status === Status.error, + title: t("retry"), + click: retry, + icon: , + loading: false, + }, + { + show: true, + title: + status === Status.finished + ? t("deleteTask") + : t("cancelAndDelete"), + click: cancel, + icon: , + loading: loading, + }, + { + show: status === Status.resumable, + title: t("selectAndResume"), + click: () => + selectFile(uploader.task.dst, SelectType.File, uploader), + icon: , + loading: false, + }, + ]; + + return ( + <> + {actions.map((a) => ( + <> + {a.show && ( + + + { + e.stopPropagation(); + a.click(); + }} + > + {a.icon} + + + + )} + + ))} + + ); + }, [status, loading, taskHover, fullScreen, uploader]); + + const fileIcon = useMemo(() => { + if (!fullScreen) { + return ; + } + }, [uploader, fullScreen]); + + return ( + <> + + +
setTaskHover(false)} + onMouseOver={() => setTaskHover(true)} + > + {progressBar} + + {fileIcon} + +
+ {uploader.task.name} +
+
{resumeLabel}
+
{continueLabel}
+
+ } + secondary={ +
+ {statusText} +
+ } + /> + {secondaryAction} + +
+ + {taskDetail} + + + + ); +} diff --git a/src/component/Uploader/Uploader.js b/src/component/Uploader/Uploader.js new file mode 100644 index 0000000..6af0e43 --- /dev/null +++ b/src/component/Uploader/Uploader.js @@ -0,0 +1,242 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import UploadManager, { SelectType } from "./core"; +import { useDispatch, useSelector } from "react-redux"; +import UploadButton from "../Dial/Create"; +import pathHelper from "../../utils/page"; +import { useLocation } from "react-router-dom"; +import { UploaderError } from "./core/errors"; +import TaskList from "./Popup/TaskList"; +import { Status } from "./core/uploader/base"; +import { DropFileBackground } from "../Placeholder/DropFile"; +import { + refreshFileList, + refreshStorage, + toggleSnackbar, +} from "../../redux/explorer"; +import Auth from "../../middleware/Auth"; +import { useTranslation } from "react-i18next"; + +let totalProgressCollector = null; +let lastProgressStart = -1; +let dragCounter = 0; + +export default function Uploader() { + const { t } = useTranslation("application", { keyPrefix: "uploader" }); + const [uploaders, setUploaders] = useState([]); + const [taskListOpen, setTaskListOpen] = useState(false); + const [dropBgOpen, setDropBgOpen] = useState(0); + const [totalProgress, setTotalProgress] = useState({ + totalSize: 0, + processedSize: 0, + total: 0, + processed: 0, + }); + const search = useSelector((state) => state.explorer.search); + const policy = useSelector((state) => state.explorer.currentPolicy); + const isLogin = useSelector((state) => state.viewUpdate.isLogin); + const path = useSelector((state) => state.navigator.path); + const fileSelectCounter = useSelector( + (state) => state.viewUpdate.openFileSelector + ); + const folderSelectCounter = useSelector( + (state) => state.viewUpdate.openFolderSelector + ); + const location = useLocation(); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const RefreshFileList = useCallback(() => dispatch(refreshFileList()), [ + dispatch, + ]); + const RefreshStorage = useCallback(() => dispatch(refreshStorage()), [ + dispatch, + ]); + + const enableUploader = useMemo( + () => pathHelper.isHomePage(location.pathname) && isLogin && !search, + [location.pathname, isLogin, search] + ); + + const taskAdded = (original = null) => (tasks) => { + if (original !== null) { + if (tasks.length !== 1 || tasks[0].key() !== original.key()) { + ToggleSnackbar( + "top", + "right", + t("fileNotMatchError"), + "warning" + ); + return; + } + } + + tasks.forEach((t) => t.start()); + + setTaskListOpen(true); + setUploaders((uploaders) => { + if (original !== null) { + uploaders = uploaders.filter((u) => u.key() !== original.key()); + } + + return [...uploaders, ...tasks]; + }); + }; + + const uploadManager = useMemo(() => { + return new UploadManager({ + logLevel: "INFO", + concurrentLimit: parseInt( + Auth.GetPreferenceWithDefault("concurrent_limit", "5") + ), + dropZone: document.querySelector("body"), + onToast: (type, msg) => { + ToggleSnackbar("top", "right", msg, type); + }, + onDropOver: (e) => { + dragCounter++; + setDropBgOpen((value) => !value); + }, + onDropLeave: (e) => { + dragCounter--; + setDropBgOpen((value) => !value); + }, + onDropFileAdded: taskAdded(), + }); + }, []); + + useEffect(() => { + uploadManager.setPolicy(policy, path); + }, [policy]); + + useEffect(() => { + const unfinished = uploadManager.resumeTasks(); + setUploaders((uploaders) => [...uploaders, ...unfinished]); + if (!totalProgressCollector) { + totalProgressCollector = setInterval(() => { + const progress = { + totalSize: 0, + processedSize: 0, + total: 0, + processed: 0, + }; + setUploaders((uploaders) => { + uploaders.forEach((u) => { + if (u.id <= lastProgressStart) { + return; + } + + progress.totalSize += u.task.size; + progress.total += 1; + + if ( + u.status === Status.finished || + u.status === Status.canceled || + u.status === Status.error + ) { + progress.processedSize += u.task.size; + progress.processed += 1; + } + + if ( + u.status === Status.added || + u.status === Status.initialized || + u.status === Status.queued || + u.status === Status.preparing || + u.status === Status.processing || + u.status === Status.finishing + ) { + progress.processedSize += u.progress + ? u.progress.total.loaded + : 0; + } + }); + + if ( + progress.total > 0 && + progress.processed === progress.total + ) { + lastProgressStart = uploaders[uploaders.length - 1].id; + } + return uploaders; + }); + + if ( + progress.total > 0 && + progress.total === progress.processed + ) { + RefreshFileList(); + RefreshStorage(); + } + + setTotalProgress(progress); + }, 2000); + } + }, []); + + const selectFile = (path, type = SelectType.File, original = null) => { + setTaskListOpen(true); + + // eslint-disable-next-line no-unreachable + uploadManager + .select(path, type) + .then(taskAdded(original)) + .catch((e) => { + if (e instanceof UploaderError) { + ToggleSnackbar("top", "right", e.Message(), "warning"); + } else { + ToggleSnackbar( + "top", + "right", + t("unknownError", { msg: e.message }), + "error" + ); + } + }); + }; + + useEffect(() => { + if (fileSelectCounter > 0) { + selectFile(path); + } + }, [fileSelectCounter]); + + useEffect(() => { + if (folderSelectCounter > 0) { + selectFile(path, SelectType.Directory); + } + }, [folderSelectCounter]); + + const deleteTask = (filter) => { + setUploaders((uploaders) => uploaders.filter(filter)); + }; + + return ( + <> + {enableUploader && ( + <> + 0} /> + setTaskListOpen(true)} + /> + setTaskListOpen(false)} + setUploaders={setUploaders} + /> + + )} + + ); +} diff --git a/src/component/Uploader/UseUpload.js b/src/component/Uploader/UseUpload.js new file mode 100644 index 0000000..90e4b66 --- /dev/null +++ b/src/component/Uploader/UseUpload.js @@ -0,0 +1,70 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useDispatch } from "react-redux"; +import { toggleSnackbar } from "../../redux/explorer"; + +export function useUpload(uploader) { + const startLoadedRef = useRef(0); + const [status, setStatus] = useState(uploader.status); + const [error, setError] = useState(uploader.error); + const [progress, setProgress] = useState(uploader.progress); + const dispatch = useDispatch(); + + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + /* eslint-disable @typescript-eslint/no-empty-function */ + uploader.subscribe({ + onTransition: (newStatus) => { + setStatus(newStatus); + }, + onError: (err) => { + setError(err); + setStatus(uploader.status); + }, + onProgress: (data) => { + setProgress(data); + }, + onMsg: (msg, color) => { + ToggleSnackbar("top", "right", msg, color); + }, + }); + }, []); + + // 获取上传速度 + const [speed, speedAvg] = React.useMemo(() => { + if ( + progress == null || + progress.total == null || + progress.total.loaded == null + ) + return [0, 0]; + const duration = (Date.now() - (uploader.lastTime || 0)) / 1000; + const durationTotal = (Date.now() - (uploader.startTime || 0)) / 1000; + const res = + progress.total.loaded > startLoadedRef.current + ? Math.floor( + (progress.total.loaded - startLoadedRef.current) / + duration + ) + : 0; + const resAvg = + progress.total.loaded > 0 + ? Math.floor(progress.total.loaded / durationTotal) + : 0; + + startLoadedRef.current = progress.total.loaded; + uploader.lastTime = Date.now(); + return [res, resAvg]; + }, [progress]); + + const retry = () => { + uploader.reset(); + uploader.start(); + }; + + return { status, error, progress, speed, speedAvg, retry }; +} diff --git a/src/component/Uploader/core/api/index.ts b/src/component/Uploader/core/api/index.ts new file mode 100644 index 0000000..a896073 --- /dev/null +++ b/src/component/Uploader/core/api/index.ts @@ -0,0 +1,444 @@ +import { + OneDriveChunkResponse, + QiniuChunkResponse, + QiniuFinishUploadRequest, + QiniuPartsInfo, + S3Part, + UploadCredential, + UploadSessionRequest, +} from "../types"; +import { OBJtoXML, request, requestAPI } from "../utils"; +import { + COSUploadCallbackError, + COSUploadError, + CreateUploadSessionError, + DeleteUploadSessionError, + HTTPError, + LocalChunkUploadError, + OneDriveChunkError, + OneDriveFinishUploadError, + QiniuChunkError, + QiniuFinishUploadError, + S3LikeChunkError, + S3LikeFinishUploadError, + S3LikeUploadCallbackError, + SlaveChunkUploadError, + UpyunUploadError, +} from "../errors"; +import { ChunkInfo, ChunkProgress } from "../uploader/chunk"; +import { Progress } from "../uploader/base"; +import { CancelToken } from "axios"; + +export async function createUploadSession( + req: UploadSessionRequest, + cancel: CancelToken +): Promise { + const res = await requestAPI("file/upload", { + method: "put", + data: req, + cancelToken: cancel, + }); + + if (res.data.code != 0) { + throw new CreateUploadSessionError(res.data); + } + + return res.data.data; +} + +export async function deleteUploadSession(id: string): Promise { + const res = await requestAPI(`file/upload/${id}`, { + method: "delete", + }); + + if (res.data.code != 0) { + throw new DeleteUploadSessionError(res.data); + } + + return res.data.data; +} + +export async function localUploadChunk( + sessionID: string, + chunk: ChunkInfo, + onProgress: (p: Progress) => void, + cancel: CancelToken +): Promise { + const res = await requestAPI( + `file/upload/${sessionID}/${chunk.index}`, + { + method: "post", + headers: { "content-type": "application/octet-stream" }, + data: chunk.chunk, + onUploadProgress: (progressEvent) => { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }); + }, + cancelToken: cancel, + } + ); + + if (res.data.code != 0) { + throw new LocalChunkUploadError(res.data, chunk.index); + } + + return res.data.data; +} + +export async function slaveUploadChunk( + url: string, + credential: string, + chunk: ChunkInfo, + onProgress: (p: Progress) => void, + cancel: CancelToken +): Promise { + const res = await request(`${url}?chunk=${chunk.index}`, { + method: "post", + headers: { + "content-type": "application/octet-stream", + Authorization: credential, + }, + data: chunk.chunk, + onUploadProgress: (progressEvent) => { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }); + }, + cancelToken: cancel, + }); + + if (res.data.code != 0) { + throw new SlaveChunkUploadError(res.data, chunk.index); + } + + return res.data.data; +} + +export async function oneDriveUploadChunk( + url: string, + range: string, // if range is empty, this will be an request to query the session status + chunk: ChunkInfo, + onProgress: (p: Progress) => void, + cancel: CancelToken +): Promise { + const res = await request(url, { + method: range === "" ? "get" : "put", + headers: { + "content-type": "application/octet-stream", + ...(range !== "" && { "content-range": range }), + }, + data: chunk.chunk, + onUploadProgress: (progressEvent) => { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }); + }, + cancelToken: cancel, + }).catch((e) => { + if (e instanceof HTTPError && e.response) { + throw new OneDriveChunkError(e.response.data); + } + + throw e; + }); + + return res.data; +} + +export async function finishOneDriveUpload( + sessionID: string, + cancel: CancelToken +): Promise { + const res = await requestAPI( + `callback/onedrive/finish/${sessionID}`, + { + method: "post", + data: "{}", + cancelToken: cancel, + } + ); + + if (res.data.code != 0) { + throw new OneDriveFinishUploadError(res.data); + } + + return res.data.data; +} + +export async function s3LikeUploadChunk( + url: string, + chunk: ChunkInfo, + onProgress: (p: Progress) => void, + cancel: CancelToken +): Promise { + const res = await request(decodeURIComponent(url), { + method: "put", + headers: { + "content-type": "application/octet-stream", + }, + data: chunk.chunk, + onUploadProgress: (progressEvent) => { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }); + }, + cancelToken: cancel, + responseType: "document", + transformResponse: undefined, + }).catch((e) => { + if (e instanceof HTTPError && e.response) { + throw new S3LikeChunkError(e.response.data); + } + + throw e; + }); + + return res.headers.etag; +} + +export async function s3LikeFinishUpload( + url: string, + isOss: boolean, + chunks: ChunkProgress[], + cancel: CancelToken +): Promise { + let body = ""; + if (!isOss) { + body += ""; + chunks.forEach((chunk) => { + body += ""; + const part: S3Part = { + PartNumber: chunk.index + 1, + ETag: chunk.etag!, + }; + body += OBJtoXML(part); + body += ""; + }); + body += ""; + } + + const res = await request(url, { + method: "post", + cancelToken: cancel, + responseType: "document", + transformResponse: undefined, + data: body, + headers: isOss + ? { + "content-type": "application/octet-stream", + "x-oss-forbid-overwrite": "true", + "x-oss-complete-all": "yes", + } + : { + "content-type": "application/xhtml+xml", + }, + validateStatus: function (status) { + return status == 200; + }, + }).catch((e) => { + if (e instanceof HTTPError && e.response) { + throw new S3LikeFinishUploadError(e.response.data); + } + + throw e; + }); + + return res.data; +} + +export async function qiniuDriveUploadChunk( + url: string, + upToken: string, + chunk: ChunkInfo, + onProgress: (p: Progress) => void, + cancel: CancelToken +): Promise { + const res = await request(`${url}/${chunk.index + 1}`, { + method: "put", + headers: { + "content-type": "application/octet-stream", + authorization: "UpToken " + upToken, + }, + data: chunk.chunk, + onUploadProgress: (progressEvent) => { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }); + }, + cancelToken: cancel, + }).catch((e) => { + if (e instanceof HTTPError && e.response) { + throw new QiniuChunkError(e.response.data); + } + + throw e; + }); + + return res.data; +} + +export async function qiniuFinishUpload( + url: string, + upToken: string, + chunks: ChunkProgress[], + cancel: CancelToken +): Promise { + const content: QiniuFinishUploadRequest = { + parts: chunks.map( + (chunk): QiniuPartsInfo => { + return { + etag: chunk.etag!, + partNumber: chunk.index + 1, + }; + } + ), + }; + + const res = await request(`${url}`, { + method: "post", + headers: { + "content-type": "application/json", + authorization: "UpToken " + upToken, + }, + data: content, + cancelToken: cancel, + }).catch((e) => { + if (e instanceof HTTPError && e.response) { + throw new QiniuFinishUploadError(e.response.data); + } + + throw e; + }); + + return res.data; +} + +export async function cosFormUploadChunk( + url: string, + file: File, + policy: string, + path: string, + callback: string, + sessionID: string, + keyTime: string, + credential: string, + ak: string, + onProgress: (p: Progress) => void, + cancel: CancelToken +): Promise { + const bodyFormData = new FormData(); + bodyFormData.append("policy", policy); + bodyFormData.append("key", path); + bodyFormData.append("x-cos-meta-callback", callback); + bodyFormData.append("x-cos-meta-key", sessionID); + bodyFormData.append("q-sign-algorithm", "sha1"); + bodyFormData.append("q-key-time", keyTime); + bodyFormData.append("q-ak", ak); + bodyFormData.append("q-signature", credential); + bodyFormData.append("name", file.name); + // File must be the last element in the form + bodyFormData.append("file", file); + + const res = await request(`${url}`, { + method: "post", + headers: { + "content-type": "multipart/form-data", + }, + data: bodyFormData, + onUploadProgress: (progressEvent) => { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }); + }, + cancelToken: cancel, + responseType: "document", + transformResponse: undefined, + }).catch((e) => { + if (e instanceof HTTPError && e.response) { + throw new COSUploadError(e.response.data); + } + + throw e; + }); + + return res.data; +} + +export async function cosUploadCallback( + sessionID: string, + cancel: CancelToken +): Promise { + const res = await requestAPI(`callback/cos/${sessionID}`, { + method: "get", + data: "{}", + cancelToken: cancel, + }); + + if (res.data.code != 0) { + throw new COSUploadCallbackError(res.data); + } + + return res.data.data; +} + +export async function upyunFormUploadChunk( + url: string, + file: File, + policy: string, + credential: string, + onProgress: (p: Progress) => void, + cancel: CancelToken +): Promise { + const bodyFormData = new FormData(); + bodyFormData.append("policy", policy); + bodyFormData.append("authorization", credential); + // File must be the last element in the form + bodyFormData.append("file", file); + + const res = await request(`${url}`, { + method: "post", + headers: { + "content-type": "multipart/form-data", + }, + data: bodyFormData, + onUploadProgress: (progressEvent) => { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }); + }, + cancelToken: cancel, + }).catch((e) => { + if (e instanceof HTTPError && e.response) { + throw new UpyunUploadError(e.response.data); + } + + throw e; + }); + + return res.data; +} + +export async function s3LikeUploadCallback( + sessionID: string, + cancel: CancelToken +): Promise { + const res = await requestAPI(`callback/s3/${sessionID}`, { + method: "get", + data: "{}", + cancelToken: cancel, + }); + + if (res.data.code != 0) { + throw new S3LikeUploadCallbackError(res.data); + } + + return res.data.data; +} diff --git a/src/component/Uploader/core/errors/index.ts b/src/component/Uploader/core/errors/index.ts new file mode 100644 index 0000000..fc5a4ac --- /dev/null +++ b/src/component/Uploader/core/errors/index.ts @@ -0,0 +1,399 @@ +import { + OneDriveError, + Policy, + QiniuError, + Response, + UpyunError, +} from "../types"; +import { sizeToString } from "../utils"; +import i18next from "../../../../i18n"; +import { AppError } from "../../../../middleware/Api"; + +export enum UploaderErrorName { + InvalidFile = "InvalidFile", + NoPolicySelected = "NoPolicySelected", + UnknownPolicyType = "UnknownPolicyType", + FailedCreateUploadSession = "FailedCreateUploadSession", + FailedDeleteUploadSession = "FailedDeleteUploadSession", + HTTPRequestFailed = "HTTPRequestFailed", + LocalChunkUploadFailed = "LocalChunkUploadFailed", + SlaveChunkUploadFailed = "SlaveChunkUploadFailed", + WriteCtxFailed = "WriteCtxFailed", + RemoveCtxFailed = "RemoveCtxFailed", + ReadCtxFailed = "ReadCtxFailed", + InvalidCtxData = "InvalidCtxData", + CtxExpired = "CtxExpired", + RequestCanceled = "RequestCanceled", + ProcessingTaskDuplicated = "ProcessingTaskDuplicated", + OneDriveChunkUploadFailed = "OneDriveChunkUploadFailed", + OneDriveEmptyFile = "OneDriveEmptyFile", + FailedFinishOneDriveUpload = "FailedFinishOneDriveUpload", + S3LikeChunkUploadFailed = "S3LikeChunkUploadFailed", + S3LikeUploadCallbackFailed = "S3LikeUploadCallbackFailed", + COSUploadCallbackFailed = "COSUploadCallbackFailed", + COSPostUploadFailed = "COSPostUploadFailed", + UpyunPostUploadFailed = "UpyunPostUploadFailed", + QiniuChunkUploadFailed = "QiniuChunkUploadFailed", + FailedFinishOSSUpload = "FailedFinishOSSUpload", + FailedFinishQiniuUpload = "FailedFinishQiniuUpload", + FailedTransformResponse = "FailedTransformResponse", +} + +const RETRY_ERROR_LIST = [ + UploaderErrorName.FailedCreateUploadSession, + UploaderErrorName.HTTPRequestFailed, + UploaderErrorName.LocalChunkUploadFailed, + UploaderErrorName.SlaveChunkUploadFailed, + UploaderErrorName.RequestCanceled, + UploaderErrorName.ProcessingTaskDuplicated, + UploaderErrorName.FailedTransformResponse, +]; + +const RETRY_CODE_LIST = [-1]; + +export class UploaderError implements Error { + public stack: string | undefined; + constructor(public name: UploaderErrorName, public message: string) { + this.stack = new Error().stack; + } + + public Message(): string { + return this.message; + } + + public Retryable(): boolean { + return RETRY_ERROR_LIST.includes(this.name); + } +} + +// 文件未通过存储策略验证 +export class FileValidateError extends UploaderError { + // 未通过验证的文件属性 + public field: "size" | "suffix"; + + // 对应的存储策略 + public policy: Policy; + + constructor(message: string, field: "size" | "suffix", policy: Policy) { + super(UploaderErrorName.InvalidFile, message); + this.field = field; + this.policy = policy; + } + + public Message(): string { + if (this.field == "size") { + return i18next.t(`uploader.sizeExceedLimitError`, { + max: sizeToString(this.policy.maxSize), + }); + } + + return i18next.t(`uploader.suffixNotAllowedError`, { + supported: this.policy.allowedSuffix + ? this.policy.allowedSuffix.join(",") + : "*", + }); + } +} + +// 未知存储策略 +export class UnknownPolicyError extends UploaderError { + // 对应的存储策略 + public policy: Policy; + + constructor(message: string, policy: Policy) { + super(UploaderErrorName.UnknownPolicyType, message); + this.policy = policy; + } +} + +// 后端 API 出错 +export class APIError extends UploaderError { + private appError: AppError; + constructor( + name: UploaderErrorName, + message: string, + protected response: Response + ) { + super(name, message); + this.appError = new AppError(response.msg, response.code, response.msg); + } + + public Message(): string { + return `${this.message}: ${this.appError.message}`; + } + + public Retryable(): boolean { + return ( + super.Retryable() && RETRY_CODE_LIST.includes(this.response.code) + ); + } +} + +// 无法创建上传会话 +export class CreateUploadSessionError extends APIError { + constructor(response: Response) { + super(UploaderErrorName.FailedCreateUploadSession, "", response); + } + + public Message(): string { + this.message = i18next.t(`uploader.createUploadSessionError`); + return super.Message(); + } +} + +// 无法删除上传会话 +export class DeleteUploadSessionError extends APIError { + constructor(response: Response) { + super(UploaderErrorName.FailedDeleteUploadSession, "", response); + } + + public Message(): string { + this.message = i18next.t(`uploader.deleteUploadSessionError`); + return super.Message(); + } +} + +// HTTP 请求出错 +export class HTTPError extends UploaderError { + public response?: any; + constructor(public axiosErr: any, protected url: string) { + super(UploaderErrorName.HTTPRequestFailed, axiosErr.message); + this.response = axiosErr.response; + } + + public Message(): string { + return i18next.t(`uploader.requestError`, { + msg: this.axiosErr, + url: this.url, + }); + } +} + +// 本地分块上传失败 +export class LocalChunkUploadError extends APIError { + constructor(response: Response, protected chunkIndex: number) { + super(UploaderErrorName.LocalChunkUploadFailed, "", response); + } + + public Message(): string { + this.message = i18next.t(`uploader.chunkUploadError`, { + index: this.chunkIndex, + }); + return super.Message(); + } +} + +// 无法创建上传会话 +export class RequestCanceledError extends UploaderError { + constructor() { + super(UploaderErrorName.RequestCanceled, "Request canceled"); + } +} + +// 从机分块上传失败 +export class SlaveChunkUploadError extends APIError { + constructor(response: Response, protected chunkIndex: number) { + super(UploaderErrorName.SlaveChunkUploadFailed, "", response); + } + + public Message(): string { + this.message = i18next.t(`uploader.chunkUploadError`, { + index: this.chunkIndex, + }); + return super.Message(); + } +} + +// 上传任务冲突 +export class ProcessingTaskDuplicatedError extends UploaderError { + constructor() { + super( + UploaderErrorName.ProcessingTaskDuplicated, + "Processing task duplicated" + ); + } + + public Message(): string { + return i18next.t(`uploader.conflictError`); + } +} + +// OneDrive 分块上传失败 +export class OneDriveChunkError extends UploaderError { + constructor(public response: OneDriveError) { + super( + UploaderErrorName.OneDriveChunkUploadFailed, + response.error.message + ); + } + + public Message(): string { + let msg = i18next.t(`uploader.chunkUploadErrorWithMsg`, { + msg: this.message, + }); + + if (this.response.error.retryAfterSeconds != undefined){ + msg += " "+i18next.t(`uploader.chunkUploadErrorWithRetryAfter`, { + retryAfter: this.response.error.retryAfterSeconds, + }) + } + + return msg; + } + + public Retryable(): boolean { + return ( + super.Retryable() || this.response.error.retryAfterSeconds != undefined + ); + } +} + +// OneDrive 选择了空文件上传 +export class OneDriveEmptyFileSelected extends UploaderError { + constructor() { + super(UploaderErrorName.OneDriveEmptyFile, "empty file not supported"); + } + + public Message(): string { + return i18next.t("uploader.emptyFileError"); + } +} + +// OneDrive 无法完成文件上传 +export class OneDriveFinishUploadError extends APIError { + constructor(response: Response) { + super(UploaderErrorName.FailedFinishOneDriveUpload, "", response); + } + + public Message(): string { + this.message = i18next.t("uploader.finishUploadError"); + return super.Message(); + } +} + +// S3 类策略分块上传失败 +export class S3LikeChunkError extends UploaderError { + constructor(public response: Document) { + super( + UploaderErrorName.S3LikeChunkUploadFailed, + response.getElementsByTagName("Message")[0].innerHTML + ); + } + + public Message(): string { + return i18next.t(`uploader.chunkUploadErrorWithMsg`, { + msg: this.message, + }); + } +} + +// OSS 完成传失败 +export class S3LikeFinishUploadError extends UploaderError { + constructor(public response: Document) { + super( + UploaderErrorName.S3LikeChunkUploadFailed, + response.getElementsByTagName("Message")[0].innerHTML + ); + } + + public Message(): string { + return i18next.t(`uploader.ossFinishUploadError`, { + msg: this.message, + code: this.response.getElementsByTagName("Code")[0].innerHTML, + }); + } +} + +// qiniu 分块上传失败 +export class QiniuChunkError extends UploaderError { + constructor(public response: QiniuError) { + super(UploaderErrorName.QiniuChunkUploadFailed, response.error); + } + + public Message(): string { + return i18next.t(`uploader.chunkUploadErrorWithMsg`, { + msg: this.message, + }); + } +} + +// qiniu 完成传失败 +export class QiniuFinishUploadError extends UploaderError { + constructor(public response: QiniuError) { + super(UploaderErrorName.FailedFinishQiniuUpload, response.error); + } + + public Message(): string { + return i18next.t(`uploader.finishUploadErrorWithMsg`, { + msg: this.message, + }); + } +} + +// COS 上传失败 +export class COSUploadError extends UploaderError { + constructor(public response: Document) { + super( + UploaderErrorName.COSPostUploadFailed, + response.getElementsByTagName("Message")[0].innerHTML + ); + } + + public Message(): string { + return i18next.t(`uploader.cosUploadFailed`, { + msg: this.message, + code: this.response.getElementsByTagName("Code")[0].innerHTML, + }); + } +} + +// COS 无法完成上传回调 +export class COSUploadCallbackError extends APIError { + constructor(response: Response) { + super(UploaderErrorName.COSUploadCallbackFailed, "", response); + } + + public Message(): string { + this.message = i18next.t("uploader.finishUploadError"); + return super.Message(); + } +} + +// Upyun 上传失败 +export class UpyunUploadError extends UploaderError { + constructor(public response: UpyunError) { + super(UploaderErrorName.UpyunPostUploadFailed, response.message); + } + + public Message(): string { + return i18next.t("uploader.upyunUploadFailed", { + msg: this.message, + }); + } +} + +// S3 无法完成上传回调 +export class S3LikeUploadCallbackError extends APIError { + constructor(response: Response) { + super(UploaderErrorName.S3LikeUploadCallbackFailed, "", response); + } + + public Message(): string { + this.message = i18next.t("uploader.finishUploadError"); + return super.Message(); + } +} + +// 无法解析响应 +export class TransformResponseError extends UploaderError { + constructor(private response: string, parseError: Error) { + super(UploaderErrorName.FailedTransformResponse, parseError.message); + } + + public Message(): string { + return i18next.t("uploader.parseResponseError", { + msg: this.message, + content: this.response, + }); + } +} diff --git a/src/component/Uploader/core/index.ts b/src/component/Uploader/core/index.ts new file mode 100644 index 0000000..11ef391 --- /dev/null +++ b/src/component/Uploader/core/index.ts @@ -0,0 +1,236 @@ +import { Policy, PolicyType, Task, TaskType } from "./types"; +import Logger, { LogLevel } from "./logger"; +import { UnknownPolicyError, UploaderError, UploaderErrorName } from "./errors"; +import Base from "./uploader/base"; +import Local from "./uploader/local"; +import { Pool } from "./utils/pool"; +import { + cleanupResumeCtx, + getAllFileEntries, + getDirectoryUploadDst, + getFileInput, + isFileDrop, + listResumeCtx, +} from "./utils"; +import Remote from "./uploader/remote"; +import OneDrive from "./uploader/onedrive"; +import OSS from "./uploader/oss"; +import Qiniu from "./uploader/qiniu"; +import COS from "./uploader/cos"; +import Upyun from "./uploader/upyun"; +import S3 from "./uploader/s3"; +import ResumeHint from "./uploader/placeholder"; + +export interface Option { + logLevel: LogLevel; + concurrentLimit: number; + dropZone?: HTMLElement; + onDropOver?: (e: DragEvent) => void; + onDropLeave?: (e: DragEvent) => void; + onToast: (type: string, msg: string) => void; + onDropFileAdded?: (uploaders: Base[]) => void; +} + +export enum SelectType { + File, + Directory, +} + +export default class UploadManager { + public logger: Logger; + public pool: Pool; + private static id = 0; + private policy?: Policy; + private fileInput: HTMLInputElement; + private directoryInput: HTMLInputElement; + private id = ++UploadManager.id; + // used for proactive upload (drop, paste) + private currentPath = "/"; + + constructor(private o: Option) { + this.logger = new Logger(o.logLevel, "MANAGER"); + this.logger.info(`Initialized with log level: ${o.logLevel}`); + + this.pool = new Pool(o.concurrentLimit); + this.fileInput = getFileInput(this.id, false); + this.directoryInput = getFileInput(this.id, true); + + if (o.dropZone) { + this.logger.info(`Drag and drop container set to:`, o.dropZone); + o.dropZone.addEventListener("dragenter", (e) => { + if (isFileDrop(e)) { + e.preventDefault(); + if (o.onDropOver) { + o.onDropOver(e); + } + } + }); + + o.dropZone.addEventListener("dragleave", (e) => { + if (isFileDrop(e)) { + e.preventDefault(); + if (o.onDropLeave) { + o.onDropLeave(e); + } + } + }); + + o.dropZone.addEventListener("drop", this.onFileDroppedIn); + } + } + + changeConcurrentLimit = (newLimit: number) => { + this.pool.limit = newLimit; + }; + + dispatchUploader(task: Task): Base { + if (task.type == TaskType.resumeHint) { + return new ResumeHint(task, this); + } + + switch (task.policy.type) { + case PolicyType.local: + return new Local(task, this); + case PolicyType.remote: + return new Remote(task, this); + case PolicyType.onedrive: + return new OneDrive(task, this); + case PolicyType.oss: + return new OSS(task, this); + case PolicyType.qiniu: + return new Qiniu(task, this); + case PolicyType.cos: + return new COS(task, this); + case PolicyType.upyun: + return new Upyun(task, this); + case PolicyType.s3: + return new S3(task, this); + default: + throw new UnknownPolicyError( + "Unknown policy type.", + task.policy + ); + } + } + + // 设定当前存储策略 + public setPolicy(p: Policy, path: string) { + this.policy = p; + this.currentPath = path; + if (p == undefined) { + this.logger.info(`Currently no policy selected`); + return; + } + + this.logger.info(`Switching policy to:`, p); + + if (p.allowedSuffix != undefined && p.allowedSuffix.length > 0) { + const acceptVal = p.allowedSuffix + .map((v) => { + return "." + v; + }) + .join(","); + this.logger.info(`Set allowed file suffix to ${acceptVal}`); + this.fileInput.setAttribute("accept", acceptVal); + } else { + this.logger.info(`Set allowed file suffix to *`); + this.fileInput.removeAttribute("accept"); + } + } + + // 选择文件 + public select = (dst: string, type = SelectType.File): Promise => { + return new Promise((resolve) => { + if (this.policy == undefined) { + this.logger.warn( + `Calling file selector while no policy is set` + ); + throw new UploaderError( + UploaderErrorName.NoPolicySelected, + "No policy selected." + ); + } + + this.fileInput.onchange = (ev: Event) => + this.fileSelectCallback(ev, dst, resolve); + this.directoryInput.onchange = (ev: Event) => + this.fileSelectCallback(ev, dst, resolve); + this.fileInput.value = ""; + this.directoryInput.value = ""; + type == SelectType.File + ? this.fileInput.click() + : this.directoryInput.click(); + }); + }; + + public resumeTasks = (): Base[] => { + const tasks = listResumeCtx(this.logger); + if (tasks.length > 0) { + this.logger.info( + `Resumed ${tasks.length} unfinished task(s) from local storage:`, + tasks + ); + } + return tasks + .filter( + (t) => + t.chunkProgress.length > 0 && t.chunkProgress[0].loaded > 0 + ) + .map((t) => + this.dispatchUploader({ ...t, type: TaskType.resumeHint }) + ); + }; + + public cleanupSessions = () => { + cleanupResumeCtx(this.logger); + }; + + private fileSelectCallback = ( + ev: Event | File[], + dst: string, + resolve: (value?: Base[] | PromiseLike | undefined) => void + ) => { + let files: File[] = []; + if (ev instanceof Event) { + const target = ev.target as HTMLInputElement; + if (!ev || !target || !target.files) return; + if (target.files.length > 0) { + files = Array.from(target.files); + } + } else { + files = ev as File[]; + } + + if (files.length > 0) { + resolve( + files.map( + (file): Base => + this.dispatchUploader({ + type: TaskType.file, + policy: this.policy as Policy, + dst: getDirectoryUploadDst(dst, file), + file: file, + size: file.size, + name: file.name, + chunkProgress: [], + resumed: false, + }) + ) + ); + } + }; + + private onFileDroppedIn = async (e: DragEvent) => { + const containFile = + e.dataTransfer && e.dataTransfer.types.includes("Files"); + if (containFile) { + this.o.onDropLeave && this.o.onDropLeave(e); + const items = await getAllFileEntries(e.dataTransfer!.items); + console.log(items); + const uploaders = await new Promise((resolve) => + this.fileSelectCallback(items, this.currentPath, resolve) + ); + this.o.onDropFileAdded && this.o.onDropFileAdded(uploaders); + } + }; +} diff --git a/src/component/Uploader/core/logger.ts b/src/component/Uploader/core/logger.ts new file mode 100644 index 0000000..af39d6f --- /dev/null +++ b/src/component/Uploader/core/logger.ts @@ -0,0 +1,37 @@ +export type LogLevel = "INFO" | "WARN" | "ERROR" | "OFF"; + +export default class Logger { + constructor( + public level: LogLevel = "OFF", + private prefix = "UPLOAD", + private id: number = 1 + ) {} + + private getPrintPrefix(level: LogLevel) { + return `Cloudreve-Uploader [${level}][${this.prefix}#${this.id}]:`; + } + + info(...args: unknown[]) { + const allowLevel: LogLevel[] = ["INFO"]; + if (allowLevel.includes(this.level)) { + // eslint-disable-next-line no-console + console.log(this.getPrintPrefix("INFO"), ...args); + } + } + + warn(...args: unknown[]) { + const allowLevel: LogLevel[] = ["INFO", "WARN"]; + if (allowLevel.includes(this.level)) { + // eslint-disable-next-line no-console + console.warn(this.getPrintPrefix("WARN"), ...args); + } + } + + error(...args: unknown[]) { + const allowLevel: LogLevel[] = ["INFO", "WARN", "ERROR"]; + if (allowLevel.includes(this.level)) { + // eslint-disable-next-line no-console + console.error(this.getPrintPrefix("ERROR"), ...args); + } + } +} diff --git a/src/component/Uploader/core/types.ts b/src/component/Uploader/core/types.ts new file mode 100644 index 0000000..7ab6051 --- /dev/null +++ b/src/component/Uploader/core/types.ts @@ -0,0 +1,116 @@ +import { ChunkProgress } from "./uploader/chunk"; + +export enum PolicyType { + local = "local", + remote = "remote", + oss = "oss", + qiniu = "qiniu", + onedrive = "onedrive", + cos = "cos", + upyun = "upyun", + s3 = "s3", +} + +export interface Policy { + id: number; + name: string; + allowedSuffix: Nullable; + maxSize: number; + type: PolicyType; +} + +export enum TaskType { + file, + resumeHint, +} + +export interface Task { + type: TaskType; + name: string; + size: number; + policy: Policy; + dst: string; + file: File; + child?: Task[]; + session?: UploadCredential; + chunkProgress: ChunkProgress[]; + resumed: boolean; +} + +type Nullable = T | null; + +export interface Response { + code: number; + data: T; + msg: string; + error: string; +} + +export interface UploadSessionRequest { + path: string; + size: number; + name: string; + policy_id: number; + last_modified?: number; + + mime_type?: string; +} + +export interface UploadCredential { + sessionID: string; + expires: number; + chunkSize: number; + uploadURLs: string[]; + credential: string; + uploadID: string; + callback: string; + policy: string; + ak: string; + keyTime: string; + path: string; + completeURL: string; +} + +export interface OneDriveError { + error: { + code: string; + message: string; + innererror?: { + code: string; + }; + retryAfterSeconds?: number; + }; +} + +export interface OneDriveChunkResponse { + expirationDateTime: string; + nextExpectedRanges: string[]; +} + +export interface QiniuChunkResponse { + etag: string; + md5: string; +} + +export interface QiniuError { + error: string; +} + +export interface QiniuPartsInfo { + etag: string; + partNumber: number; +} + +export interface QiniuFinishUploadRequest { + parts: QiniuPartsInfo[]; +} + +export interface UpyunError { + message: string; + code: number; +} + +export interface S3Part { + ETag: string; + PartNumber: number; +} diff --git a/src/component/Uploader/core/uploader/base.ts b/src/component/Uploader/core/uploader/base.ts new file mode 100644 index 0000000..275445d --- /dev/null +++ b/src/component/Uploader/core/uploader/base.ts @@ -0,0 +1,235 @@ +// 所有 Uploader 的基类 +import { PolicyType, Task } from "../types"; +import UploadManager from "../index"; +import Logger from "../logger"; +import { validate } from "../utils/validator"; +import { CancelToken } from "../utils/request"; +import axios, { CancelTokenSource } from "axios"; +import { createUploadSession, deleteUploadSession } from "../api"; +import * as utils from "../utils"; +import { RequestCanceledError, UploaderError } from "../errors"; + +export enum Status { + added, + resumable, + initialized, + queued, + preparing, + processing, + finishing, + finished, + error, + canceled, +} + +export interface UploadHandlers { + onTransition: (newStatus: Status) => void; + onError: (err: Error) => void; + onProgress: (data: UploadProgress) => void; + onMsg: (msg: string, color: string) => void; +} + +export interface UploadProgress { + total: ProgressCompose; + chunks?: ProgressCompose[]; +} + +export interface ProgressCompose { + size: number; + loaded: number; + percent: number; + fromCache?: boolean; +} + +export interface Progress { + total: number; + loaded: number; +} + +const resumePolicy = [ + PolicyType.local, + PolicyType.remote, + PolicyType.qiniu, + PolicyType.oss, + PolicyType.onedrive, + PolicyType.s3, +]; +const deleteUploadSessionDelay = 500; + +export default abstract class Base { + public child?: Base[]; + public status: Status = Status.added; + public error?: Error; + + public id = ++Base.id; + private static id = 0; + + protected logger: Logger; + protected subscriber: UploadHandlers; + // 用于取消请求 + protected cancelToken: CancelTokenSource = CancelToken.source(); + protected progress: UploadProgress; + + public lastTime = Date.now(); + public startTime = Date.now(); + + constructor(public task: Task, protected manager: UploadManager) { + this.logger = new Logger( + this.manager.logger.level, + "UPLOADER", + this.id + ); + this.logger.info("Initialize new uploader for task: ", task); + this.subscriber = { + /* eslint-disable @typescript-eslint/no-empty-function */ + onTransition: (newStatus: Status) => {}, + onError: (err: Error) => {}, + onProgress: (data: UploadProgress) => {}, + onMsg: (msg, color: string) => {}, + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + } + + public subscribe = (handlers: UploadHandlers) => { + this.subscriber = handlers; + }; + + public start = async () => { + this.logger.info("Activate uploading task"); + this.transit(Status.initialized); + this.lastTime = this.startTime = Date.now(); + + try { + validate(this.task.file, this.task.policy); + } catch (e) { + this.logger.error("File validate failed with error:", e); + this.setError(e); + return; + } + + this.logger.info("Enqueued in manager pool"); + this.transit(Status.queued); + this.manager.pool.enqueue(this).catch((e) => { + this.logger.info("Upload task failed with error:", e); + this.setError(e); + }); + }; + + public run = async () => { + this.logger.info("Start upload task, create upload session..."); + this.transit(Status.preparing); + const cachedInfo = utils.getResumeCtx(this.task, this.logger); + if (cachedInfo == null) { + this.task.session = await createUploadSession( + { + path: this.task.dst, + size: this.task.file.size, + name: this.task.file.name, + policy_id: this.task.policy.id, + last_modified: this.task.file.lastModified, + mime_type: this.task.file.type, + }, + this.cancelToken.token + ); + this.logger.info("Upload session created:", this.task.session); + } else { + this.task.session = cachedInfo.session; + this.task.resumed = true; + this.task.chunkProgress = cachedInfo.chunkProgress; + this.logger.info("Resume upload from cached ctx:", cachedInfo); + } + + this.transit(Status.processing); + await this.upload(); + await this.afterUpload(); + utils.removeResumeCtx(this.task, this.logger); + this.transit(Status.finished); + this.logger.info("Upload task completed"); + }; + + public abstract async upload(): Promise; + protected async afterUpload(): Promise { + return; + } + + public cancel = async () => { + if (this.status === Status.finished) { + return; + } + + this.cancelToken.cancel(); + await this.cancelUploadSession(); + this.transit(Status.canceled); + }; + + public reset = () => { + this.cancelToken = axios.CancelToken.source(); + this.progress = { + total: { + size: 0, + loaded: 0, + percent: 0, + }, + }; + }; + + protected setError(e: Error) { + if ( + !(e instanceof UploaderError && e.Retryable()) || + !resumePolicy.includes(this.task.policy.type) + ) { + this.logger.warn("Non-resume error occurs, clean resume ctx cache"); + this.cancelUploadSession(); + } + + if (!(e instanceof RequestCanceledError)) { + this.status = Status.error; + this.error = e; + this.subscriber.onError(e); + } + } + + protected cancelUploadSession = (): Promise => { + return new Promise((resolve) => { + utils.removeResumeCtx(this.task, this.logger); + if (this.task.session) { + setTimeout(() => { + deleteUploadSession(this.task.session!?.sessionID) + .catch((e) => { + this.logger.warn( + "Failed to cancel upload session: ", + e + ); + }) + .finally(() => { + resolve(); + }); + }, deleteUploadSessionDelay); + } else { + resolve(); + } + }); + }; + + protected transit(status: Status) { + this.status = status; + this.subscriber.onTransition(status); + } + + public getProgressInfoItem( + loaded: number, + size: number, + fromCache?: boolean + ): ProgressCompose { + return { + size, + loaded, + percent: (loaded / size) * 100, + ...(fromCache == null ? {} : { fromCache }), + }; + } + + public key(): string { + return utils.getResumeCtxKey(this.task); + } +} diff --git a/src/component/Uploader/core/uploader/chunk.ts b/src/component/Uploader/core/uploader/chunk.ts new file mode 100644 index 0000000..02cb302 --- /dev/null +++ b/src/component/Uploader/core/uploader/chunk.ts @@ -0,0 +1,84 @@ +import Base, { Status, UploadProgress } from "./base"; +import * as utils from "../utils"; +import { Task, TaskType } from "../types"; +import UploadManager from "../index"; +import Logger from "../logger"; + +export interface ChunkProgress { + loaded: number; + index: number; + etag?: string; +} + +export interface ChunkInfo { + chunk: Blob; + index: number; +} + +export default abstract class Chunk extends Base { + protected chunks: Blob[]; + + public upload = async () => { + this.logger.info("Preparing uploading file chunks."); + this.initBeforeUploadChunks(); + + this.logger.info("Starting uploading file chunks:", this.chunks); + this.updateLocalCache(); + for (let i = 0; i < this.chunks.length; i++) { + if ( + this.task.chunkProgress[i].loaded < this.chunks[i].size || + this.chunks[i].size == 0 + ) { + await this.uploadChunk({ chunk: this.chunks[i], index: i }); + this.logger.info(`Chunk [${i}] uploaded.`); + this.updateLocalCache(); + } + } + }; + + private initBeforeUploadChunks() { + this.chunks = utils.getChunks( + this.task.file, + this.task.session?.chunkSize + ); + const cachedInfo = utils.getResumeCtx(this.task, this.logger); + if (cachedInfo == null) { + this.task.chunkProgress = this.chunks.map( + (value, index): ChunkProgress => ({ + loaded: 0, + index, + }) + ); + } + + this.notifyResumeProgress(); + } + + protected abstract async uploadChunk(chunkInfo: ChunkInfo): Promise; + + protected updateChunkProgress(loaded: number, index: number) { + this.task.chunkProgress[index].loaded = loaded; + this.notifyResumeProgress(); + } + + private notifyResumeProgress() { + this.progress = { + total: this.getProgressInfoItem( + utils.sumChunk(this.task.chunkProgress), + this.task.file.size + 1 + ), + chunks: this.chunks.map((chunk, index) => { + return this.getProgressInfoItem( + this.task.chunkProgress[index].loaded, + chunk.size, + false + ); + }), + }; + this.subscriber.onProgress(this.progress); + } + + private updateLocalCache() { + utils.setResumeCtx(this.task, this.logger); + } +} diff --git a/src/component/Uploader/core/uploader/cos.ts b/src/component/Uploader/core/uploader/cos.ts new file mode 100644 index 0000000..57e0aaa --- /dev/null +++ b/src/component/Uploader/core/uploader/cos.ts @@ -0,0 +1,38 @@ +import Base, { Status } from "./base"; +import { cosFormUploadChunk, cosUploadCallback } from "../api"; + +export default class COS extends Base { + public upload = async () => { + this.logger.info("Starting uploading file stream:", this.task.file); + await cosFormUploadChunk( + this.task.session?.uploadURLs[0]!, + this.task.file, + this.task.session?.policy!, + this.task.session?.path!, + this.task.session?.callback!, + this.task.session?.sessionID!, + this.task.session?.keyTime!, + this.task.session?.credential!, + this.task.session?.ak!, + (p) => { + this.subscriber.onProgress({ + total: this.getProgressInfoItem(p.loaded, p.total), + }); + }, + this.cancelToken.token + ); + }; + + protected async afterUpload(): Promise { + this.transit(Status.finishing); + this.logger.info(`Sending COS upload callback...`); + try { + await cosUploadCallback( + this.task.session!.sessionID, + this.cancelToken.token + ); + } catch (e) { + this.logger.warn(`Failed to finish COS upload:`, e); + } + } +} diff --git a/src/component/Uploader/core/uploader/local.ts b/src/component/Uploader/core/uploader/local.ts new file mode 100644 index 0000000..7917d15 --- /dev/null +++ b/src/component/Uploader/core/uploader/local.ts @@ -0,0 +1,15 @@ +import Chunk, { ChunkInfo } from "./chunk"; +import { localUploadChunk } from "../api"; + +export default class Local extends Chunk { + protected async uploadChunk(chunkInfo: ChunkInfo) { + return localUploadChunk( + this.task.session?.sessionID!, + chunkInfo, + (p) => { + this.updateChunkProgress(p.loaded, chunkInfo.index); + }, + this.cancelToken.token + ); + } +} diff --git a/src/component/Uploader/core/uploader/onedrive.ts b/src/component/Uploader/core/uploader/onedrive.ts new file mode 100644 index 0000000..026fd38 --- /dev/null +++ b/src/component/Uploader/core/uploader/onedrive.ts @@ -0,0 +1,100 @@ +import Chunk, { ChunkInfo } from "./chunk"; +import { finishOneDriveUpload, oneDriveUploadChunk } from "../api"; +import { OneDriveChunkError, OneDriveEmptyFileSelected } from "../errors"; +import { Status } from "./base"; + +export default class OneDrive extends Chunk { + protected async uploadChunk(chunkInfo: ChunkInfo) { + if (chunkInfo.chunk.size === 0) { + throw new OneDriveEmptyFileSelected(); + } + + const rangeEnd = this.progress.total.loaded + chunkInfo.chunk.size - 1; + return this.sendRange( + chunkInfo, + this.progress.total.loaded, + rangeEnd, + 0 + ).catch((e) => { + if ( + e instanceof OneDriveChunkError && + e.response.error.innererror && + e.response.error.innererror.code == "fragmentOverlap" + ) { + return this.alignChunkOffset(chunkInfo); + } + + throw e; + }); + } + + private async sendRange( + chunkInfo: ChunkInfo, + start: number, + end: number, + chunkOffset: number + ) { + const range = `bytes ${start}-${end}/${this.task.file.size}`; + return oneDriveUploadChunk( + `${this.task.session?.uploadURLs[0]!}`, + range, + chunkInfo, + (p) => { + this.updateChunkProgress( + chunkOffset + p.loaded, + chunkInfo.index + ); + }, + this.cancelToken.token + ); + } + + private async alignChunkOffset(chunkInfo: ChunkInfo) { + this.logger.info( + `Chunk [${chunkInfo.index}] overlapped, checking next expected range...` + ); + const rangeStatus = await oneDriveUploadChunk( + `${this.task.session?.uploadURLs[0]!}`, + "", + chunkInfo, + (p) => { + return null; + }, + this.cancelToken.token + ); + const expectedStart = parseInt( + rangeStatus.nextExpectedRanges[0].split("-")[0] + ); + this.logger.info( + `Next expected range start from OneDrive is ${expectedStart}.` + ); + + if (expectedStart >= this.progress.total.loaded) { + this.logger.info(`This whole chunk is overlapped, skipping...`); + this.updateChunkProgress(chunkInfo.chunk.size, chunkInfo.index); + return; + } else { + this.updateChunkProgress(0, chunkInfo.index); + const rangeEnd = + this.progress.total.loaded + chunkInfo.chunk.size - 1; + const newChunkOffset = expectedStart - this.progress.total.loaded; + chunkInfo.chunk = chunkInfo.chunk.slice(newChunkOffset); + this.updateChunkProgress(newChunkOffset, chunkInfo.index); + return this.sendRange( + chunkInfo, + expectedStart, + rangeEnd, + newChunkOffset + ); + } + } + + protected async afterUpload(): Promise { + this.logger.info(`Finishing upload...`); + this.transit(Status.finishing); + return finishOneDriveUpload( + this.task.session!.sessionID, + this.cancelToken.token + ); + } +} diff --git a/src/component/Uploader/core/uploader/oss.ts b/src/component/Uploader/core/uploader/oss.ts new file mode 100644 index 0000000..305fd36 --- /dev/null +++ b/src/component/Uploader/core/uploader/oss.ts @@ -0,0 +1,27 @@ +import Chunk, { ChunkInfo } from "./chunk"; +import { s3LikeFinishUpload, s3LikeUploadChunk } from "../api"; +import { Status } from "./base"; + +export default class OSS extends Chunk { + protected async uploadChunk(chunkInfo: ChunkInfo) { + return s3LikeUploadChunk( + this.task.session?.uploadURLs[chunkInfo.index]!, + chunkInfo, + (p) => { + this.updateChunkProgress(p.loaded, chunkInfo.index); + }, + this.cancelToken.token + ); + } + + protected async afterUpload(): Promise { + this.logger.info(`Finishing multipart upload...`); + this.transit(Status.finishing); + return s3LikeFinishUpload( + this.task.session!.completeURL, + true, + this.task.chunkProgress, + this.cancelToken.token + ); + } +} diff --git a/src/component/Uploader/core/uploader/placeholder.ts b/src/component/Uploader/core/uploader/placeholder.ts new file mode 100644 index 0000000..8821c5b --- /dev/null +++ b/src/component/Uploader/core/uploader/placeholder.ts @@ -0,0 +1,24 @@ +import Chunk, { ChunkInfo } from "./chunk"; +import { qiniuDriveUploadChunk, qiniuFinishUpload } from "../api"; +import { Status } from "./base"; +import { Task } from "../types"; +import UploadManager from "../index"; +import * as utils from "../utils"; + +export default class ResumeHint extends Chunk { + constructor(task: Task, manager: UploadManager) { + super(task, manager); + this.status = Status.resumable; + this.progress = { + total: this.getProgressInfoItem( + utils.sumChunk(this.task.chunkProgress), + this.task.size + 1 + ), + }; + this.subscriber.onProgress(this.progress); + } + + protected async uploadChunk(chunkInfo: ChunkInfo) { + return null; + } +} diff --git a/src/component/Uploader/core/uploader/qiniu.ts b/src/component/Uploader/core/uploader/qiniu.ts new file mode 100644 index 0000000..4b19dc5 --- /dev/null +++ b/src/component/Uploader/core/uploader/qiniu.ts @@ -0,0 +1,30 @@ +import Chunk, { ChunkInfo } from "./chunk"; +import { qiniuDriveUploadChunk, qiniuFinishUpload } from "../api"; +import { Status } from "./base"; + +export default class Qiniu extends Chunk { + protected async uploadChunk(chunkInfo: ChunkInfo) { + const chunkRes = await qiniuDriveUploadChunk( + this.task.session?.uploadURLs[0]!, + this.task.session?.credential!, + chunkInfo, + (p) => { + this.updateChunkProgress(p.loaded, chunkInfo.index); + }, + this.cancelToken.token + ); + + this.task.chunkProgress[chunkInfo.index].etag = chunkRes.etag; + } + + protected async afterUpload(): Promise { + this.logger.info(`Finishing multipart upload...`); + this.transit(Status.finishing); + return qiniuFinishUpload( + this.task.session?.uploadURLs[0]!, + this.task.session?.credential!, + this.task.chunkProgress, + this.cancelToken.token + ); + } +} diff --git a/src/component/Uploader/core/uploader/remote.ts b/src/component/Uploader/core/uploader/remote.ts new file mode 100644 index 0000000..8f13243 --- /dev/null +++ b/src/component/Uploader/core/uploader/remote.ts @@ -0,0 +1,16 @@ +import Chunk, { ChunkInfo } from "./chunk"; +import { slaveUploadChunk } from "../api"; + +export default class Remote extends Chunk { + protected async uploadChunk(chunkInfo: ChunkInfo) { + return slaveUploadChunk( + `${this.task.session?.uploadURLs[0]!}`, + this.task.session?.credential!, + chunkInfo, + (p) => { + this.updateChunkProgress(p.loaded, chunkInfo.index); + }, + this.cancelToken.token + ); + } +} diff --git a/src/component/Uploader/core/uploader/s3.ts b/src/component/Uploader/core/uploader/s3.ts new file mode 100644 index 0000000..4011628 --- /dev/null +++ b/src/component/Uploader/core/uploader/s3.ts @@ -0,0 +1,39 @@ +import Chunk, { ChunkInfo } from "./chunk"; +import { + s3LikeFinishUpload, + s3LikeUploadCallback, + s3LikeUploadChunk, +} from "../api"; +import { Status } from "./base"; + +export default class OSS extends Chunk { + protected async uploadChunk(chunkInfo: ChunkInfo) { + const etag = await s3LikeUploadChunk( + this.task.session?.uploadURLs[chunkInfo.index]!, + chunkInfo, + (p) => { + this.updateChunkProgress(p.loaded, chunkInfo.index); + }, + this.cancelToken.token + ); + + this.task.chunkProgress[chunkInfo.index].etag = etag; + } + + protected async afterUpload(): Promise { + this.logger.info(`Finishing multipart upload...`); + this.transit(Status.finishing); + await s3LikeFinishUpload( + this.task.session!.completeURL, + false, + this.task.chunkProgress, + this.cancelToken.token + ); + + this.logger.info(`Sending S3-like upload callback...`); + return s3LikeUploadCallback( + this.task.session!.sessionID, + this.cancelToken.token + ); + } +} diff --git a/src/component/Uploader/core/uploader/upyun.ts b/src/component/Uploader/core/uploader/upyun.ts new file mode 100644 index 0000000..c72ee45 --- /dev/null +++ b/src/component/Uploader/core/uploader/upyun.ts @@ -0,0 +1,20 @@ +import Base from "./base"; +import { upyunFormUploadChunk } from "../api"; + +export default class Upyun extends Base { + public upload = async () => { + this.logger.info("Starting uploading file stream:", this.task.file); + await upyunFormUploadChunk( + this.task.session?.uploadURLs[0]!, + this.task.file, + this.task.session?.policy!, + this.task.session?.credential!, + (p) => { + this.subscriber.onProgress({ + total: this.getProgressInfoItem(p.loaded, p.total), + }); + }, + this.cancelToken.token + ); + }; +} diff --git a/src/component/Uploader/core/utils/helper.ts b/src/component/Uploader/core/utils/helper.ts new file mode 100644 index 0000000..a339043 --- /dev/null +++ b/src/component/Uploader/core/utils/helper.ts @@ -0,0 +1,320 @@ +import { Task } from "../types"; +import Logger from "../logger"; +import { UploaderError, UploaderErrorName } from "../errors"; +import { ChunkProgress } from "../uploader/chunk"; + +export const sizeToString = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i]; +}; + +// 文件分块 +export function getChunks( + file: File, + chunkByteSize: number | undefined +): Blob[] { + // 如果 chunkByteSize 比文件大或为0,则直接取文件的大小 + if (!chunkByteSize || chunkByteSize > file.size || chunkByteSize == 0) { + chunkByteSize = file.size; + } + + const chunks: Blob[] = []; + const count = Math.ceil(file.size / chunkByteSize); + for (let i = 0; i < count; i++) { + const chunk = file.slice( + chunkByteSize * i, + i === count - 1 ? file.size : chunkByteSize * (i + 1) + ); + chunks.push(chunk); + } + + if (chunks.length == 0) { + chunks.push(file.slice(0)); + } + return chunks; +} + +export function sumChunk(list: ChunkProgress[]) { + return list.reduce((data, loaded) => data + loaded.loaded, 0); +} + +const resumeKeyPrefix = "cd_upload_ctx_"; + +function isTask(toBeDetermined: Task | string): toBeDetermined is Task { + return !!(toBeDetermined as Task).name; +} + +export function getResumeCtxKey(task: Task | string): string { + if (isTask(task)) { + return `${resumeKeyPrefix}name_${task.name}_dst_${task.dst}_size_${task.size}_policy_${task.policy.id}`; + } + + return task; +} + +export function setResumeCtx(task: Task, logger: Logger) { + const ctxKey = getResumeCtxKey(task); + try { + localStorage.setItem(ctxKey, JSON.stringify(task)); + } catch (err) { + logger.warn( + new UploaderError( + UploaderErrorName.WriteCtxFailed, + `setResumeCtx failed: ${ctxKey}` + ) + ); + } +} + +export function removeResumeCtx(task: Task | string, logger: Logger) { + const ctxKey = getResumeCtxKey(task); + try { + localStorage.removeItem(ctxKey); + } catch (err) { + logger.warn( + new UploaderError( + UploaderErrorName.RemoveCtxFailed, + `removeResumeCtx failed. key: ${ctxKey}` + ) + ); + } +} + +export function cleanupResumeCtx(logger: Logger) { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(resumeKeyPrefix)) { + try { + localStorage.removeItem(key); + } catch (err) { + logger.warn( + new UploaderError( + UploaderErrorName.RemoveCtxFailed, + `removeResumeCtx failed. key: ${key}` + ) + ); + } + } + } +} + +export function getResumeCtx(task: Task | string, logger: Logger): Task | null { + const ctxKey = getResumeCtxKey(task); + let localInfoString: string | null = null; + try { + localInfoString = localStorage.getItem(ctxKey); + } catch { + logger.warn( + new UploaderError( + UploaderErrorName.ReadCtxFailed, + `getResumeCtx failed. key: ${ctxKey}` + ) + ); + } + + if (localInfoString == null) { + return null; + } + + let localInfo: Task | null = null; + try { + localInfo = JSON.parse(localInfoString); + } catch { + // 本地信息已被破坏,直接删除 + removeResumeCtx(task, logger); + logger.warn( + new UploaderError( + UploaderErrorName.InvalidCtxData, + `getResumeCtx failed to parse. key: ${ctxKey}` + ) + ); + } + + if ( + localInfo && + localInfo.session && + localInfo.session.expires < Math.floor(Date.now() / 1000) + ) { + removeResumeCtx(task, logger); + logger.warn( + new UploaderError( + UploaderErrorName.CtxExpired, + `upload session already expired at ${localInfo.session.expires}. key: ${ctxKey}` + ) + ); + return null; + } + + return localInfo; +} + +export function listResumeCtx(logger: Logger): Task[] { + const res: Task[] = []; + for (let i = 0, len = localStorage.length; i < len; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(resumeKeyPrefix)) { + const value = getResumeCtx(key, logger); + if (value) { + res.push(value); + } + } + } + + return res; +} + +export function OBJtoXML(obj: any): string { + let xml = ""; + for (const prop in obj) { + xml += "<" + prop + ">"; + if (Array.isArray(obj[prop])) { + for (const array of obj[prop]) { + // A real botch fix here + xml += ""; + xml += "<" + prop + ">"; + + xml += OBJtoXML(new Object(array)); + } + } else if (typeof obj[prop] == "object") { + xml += OBJtoXML(new Object(obj[prop])); + } else { + xml += obj[prop]; + } + xml += ""; + } + return xml.replace(/<\/?[0-9]{1,}>/g, ""); +} + +export function getFileInput(id: number, isFolder: boolean): HTMLInputElement { + const input = document.createElement("input"); + input.type = "file"; + input.id = `upload-file-input-${id}`; + if (isFolder) { + input.id = `upload-folder-input-${id}`; + input.setAttribute("webkitdirectory", "true"); + input.setAttribute("mozdirectory", "true"); + } else { + input.id = `upload-file-input-${id}`; + input.multiple = true; + } + input.hidden = true; + document.body.appendChild(input); + return input; +} + +export function pathJoin(parts: string[], sep = "/"): string { + parts = parts.map((part, index) => { + if (index) { + part = part.replace(new RegExp("^" + sep), ""); + } + if (index !== parts.length - 1) { + part = part.replace(new RegExp(sep + "$"), ""); + } + return part; + }); + return parts.join(sep); +} + +function basename(path: string): string { + const pathList = path.split("/"); + pathList.pop(); + return pathList.join("/") === "" ? "/" : pathList.join("/"); +} + +export function trimPrefix(src: string, prefix: string): string { + if (src.startsWith(prefix)) { + return src.slice(prefix.length); + } + return src; +} + +export function getDirectoryUploadDst(dst: string, file: any): string { + let relPath = file.webkitRelativePath; + if (!relPath || relPath == "") { + relPath = file.fsPath; + if (!relPath || relPath == "") { + return dst; + } + } + + relPath = trimPrefix(relPath, "/"); + + return basename(pathJoin([dst, relPath])); +} + +// Wrap readEntries in a promise to make working with readEntries easier +async function readEntriesPromise(directoryReader: any): Promise { + try { + return await new Promise((resolve, reject) => { + directoryReader.readEntries(resolve, reject); + }); + } catch (err) { + console.log(err); + } +} + +async function readFilePromise(fileReader: any, path: string): Promise { + try { + return await new Promise((resolve, reject) => { + fileReader.file((file: any) => { + file.fsPath = path; + resolve(file); + }); + }); + } catch (err) { + console.log(err); + } +} + +// Get all the entries (files or sub-directories) in a directory by calling readEntries until it returns empty array +async function readAllDirectoryEntries(directoryReader: any): Promise { + const entries: any[] = []; + let readEntries = await readEntriesPromise(directoryReader); + while (readEntries.length > 0) { + entries.push(...readEntries); + readEntries = await readEntriesPromise(directoryReader); + } + return entries; +} + +// Drop handler function to get all files +export async function getAllFileEntries( + dataTransferItemList: DataTransferItemList +): Promise { + const fileEntries: any[] = []; + // Use BFS to traverse entire directory/file structure + const queue: any[] = []; + // Unfortunately dataTransferItemList is not iterable i.e. no forEach + for (let i = 0; i < dataTransferItemList.length; i++) { + const fileEntry = dataTransferItemList[i].webkitGetAsEntry(); + if (!fileEntry) { + const file = dataTransferItemList[i].getAsFile(); + if (file) { + fileEntries.push(file); + } + } + + queue.push(dataTransferItemList[i].webkitGetAsEntry()); + } + while (queue.length > 0) { + const entry = queue.shift(); + if (!entry) { + continue; + } + if (entry.isFile) { + fileEntries.push(await readFilePromise(entry, entry.fullPath)); + } else if (entry.isDirectory) { + const reader = entry.createReader(); + const entries: any = await readAllDirectoryEntries(reader); + queue.push(...entries); + } + } + return fileEntries; +} + +export function isFileDrop(e: DragEvent): boolean { + return !!e.dataTransfer && e.dataTransfer.types.includes("Files"); +} diff --git a/src/component/Uploader/core/utils/index.ts b/src/component/Uploader/core/utils/index.ts new file mode 100644 index 0000000..7ca10e3 --- /dev/null +++ b/src/component/Uploader/core/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./pool"; +export * from "./helper"; +export * from "./validator"; +export * from "./request"; diff --git a/src/component/Uploader/core/utils/pool.ts b/src/component/Uploader/core/utils/pool.ts new file mode 100644 index 0000000..3d000e2 --- /dev/null +++ b/src/component/Uploader/core/utils/pool.ts @@ -0,0 +1,67 @@ +import Base from "../uploader/base"; +import { ProcessingTaskDuplicatedError } from "../errors"; + +export interface QueueContent { + uploader: Base; + resolve: () => void; + reject: (err?: any) => void; +} + +export class Pool { + queue: Array = []; + processing: Array = []; + + constructor(public limit: number) {} + + enqueue(uploader: Base) { + return new Promise((resolve, reject) => { + this.queue.push({ + uploader, + resolve, + reject, + }); + this.check(); + }); + } + + release(item: QueueContent) { + this.processing = this.processing.filter((v) => v !== item); + this.check(); + } + + run(item: QueueContent) { + this.queue = this.queue.filter((v) => v !== item); + if ( + this.processing.findIndex( + (v) => + v.uploader.task.dst == item.uploader.task.dst && + v.uploader.task.file.name == item.uploader.task.name + ) > -1 + ) { + // 找到重名任务 + item.reject(new ProcessingTaskDuplicatedError()); + this.release(item); + return; + } + + this.processing.push(item); + item.uploader.run().then( + () => { + item.resolve(); + this.release(item); + }, + (err) => { + item.reject(err); + this.release(item); + } + ); + } + + check() { + const processingNum = this.processing.length; + const availableNum = Math.max(0, this.limit - processingNum); + this.queue.slice(0, availableNum).forEach((item) => { + this.run(item); + }); + } +} diff --git a/src/component/Uploader/core/utils/request.ts b/src/component/Uploader/core/utils/request.ts new file mode 100644 index 0000000..17904b0 --- /dev/null +++ b/src/component/Uploader/core/utils/request.ts @@ -0,0 +1,46 @@ +import axios, { AxiosRequestConfig } from "axios"; +import { Response } from "../types"; +import { + HTTPError, + RequestCanceledError, + TransformResponseError, +} from "../errors"; + +export const { CancelToken } = axios; +export { CancelTokenSource } from "axios"; + +const baseConfig = { + transformResponse: [ + (response: any) => { + try { + return JSON.parse(response); + } catch (e) { + throw new TransformResponseError(response, e); + } + }, + ], +}; + +const cdBackendConfig = { + ...baseConfig, + baseURL: "/api/v3", + withCredentials: true, +}; + +export function request(url: string, config?: AxiosRequestConfig) { + return axios.request({ ...baseConfig, ...config, url }).catch((err) => { + if (axios.isCancel(err)) { + throw new RequestCanceledError(); + } + + if (err instanceof TransformResponseError) { + throw err; + } + + throw new HTTPError(err, url); + }); +} + +export function requestAPI(url: string, config?: AxiosRequestConfig) { + return request>(url, { ...cdBackendConfig, ...config }); +} diff --git a/src/component/Uploader/core/utils/validator.ts b/src/component/Uploader/core/utils/validator.ts new file mode 100644 index 0000000..6fb32e7 --- /dev/null +++ b/src/component/Uploader/core/utils/validator.ts @@ -0,0 +1,43 @@ +import { Policy } from "../types"; +import { FileValidateError } from "../errors"; + +interface Validator { + (file: File, policy: Policy): void; +} + +// validators +const checkers: Array = [ + function checkExt(file: File, policy: Policy) { + if ( + policy.allowedSuffix != undefined && + policy.allowedSuffix.length > 0 + ) { + const ext = file?.name.split(".").pop(); + if (ext === null || !ext || !policy.allowedSuffix.includes(ext)) + throw new FileValidateError( + "File suffix not allowed in policy.", + "suffix", + policy + ); + } + }, + + function checkSize(file: File, policy: Policy) { + if (policy.maxSize > 0) { + if (file.size > policy.maxSize) { + throw new FileValidateError( + "File size exceeds maximum limit.", + "size", + policy + ); + } + } + }, +]; + +/* 将每个 Validator 执行 + 失败返回 Error + */ +export function validate(file: File, policy: Policy) { + checkers.forEach((c) => c(file, policy)); +} diff --git a/src/component/VAS/BuyQuota.js b/src/component/VAS/BuyQuota.js new file mode 100644 index 0000000..d0c231c --- /dev/null +++ b/src/component/VAS/BuyQuota.js @@ -0,0 +1,978 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import SdStorage from "@material-ui/icons/SdStorage"; +import ShopIcon from "@material-ui/icons/ShoppingCart"; +import PackSelect from "./PackSelect"; +import SupervisedUserCircle from "@material-ui/icons/SupervisedUserCircle"; +import StarIcon from "@material-ui/icons/StarBorder"; +import LocalPlay from "@material-ui/icons/LocalPlay"; +import API from "../../middleware/Api"; + +import { + AppBar, + Button, + Card, + CardActions, + CardContent, + CardHeader, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, + Paper, + Tab, + Tabs, + TextField, + Typography, + withStyles, +} from "@material-ui/core"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import FormLabel from "@material-ui/core/FormLabel"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import { AccountBalanceWallet } from "@material-ui/icons"; +import { withRouter } from "react-router"; +import { toggleSnackbar } from "../../redux/explorer"; +import { withTranslation } from "react-i18next"; +import PaymentDialog from "./PaymentDialog"; + +const styles = (theme) => ({ + layout: { + width: "auto", + marginTop: "50px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: "50px", + }, + + gird: { + marginTop: "30px", + }, + paper: { + padding: theme.spacing(2), + color: theme.palette.text.secondary, + }, + title: { + marginTop: "30px", + marginBottom: "30px", + }, + button: { + margin: theme.spacing(1), + }, + action: { + textAlign: "right", + marginTop: "20px", + }, + textField: { + marginLeft: theme.spacing(1), + marginRight: theme.spacing(1), + width: 70, + textAlign: "center!important", + }, + priceShow: { + color: theme.palette.secondary.main, + fontSize: "30px", + }, + cardHeader: { + backgroundColor: + theme.palette.type === "dark" + ? theme.palette.background.default + : theme.palette.grey[200], + }, + cardPricing: { + display: "flex", + justifyContent: "center", + alignItems: "baseline", + marginBottom: theme.spacing(2), + }, + cardActions: { + [theme.breakpoints.up("sm")]: { + paddingBottom: theme.spacing(2), + }, + }, + redeemContainer: { + [theme.breakpoints.up("sm")]: { + marginLeft: "50px", + marginRight: "50px", + width: "auto", + }, + marginTop: "50px", + marginBottom: "50px", + }, + doRedeem: { + textAlign: "right", + }, + payMethod: { + marginTop: theme.spacing(4), + padding: theme.spacing(2), + }, +}); +const mapStateToProps = () => { + return {}; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +class BuyQuotaCompoment extends Component { + IntervalId = null; + constructor(props) { + super(props); + + const tab = new URLSearchParams(this.props.location.search); + const index = tab.get("tab"); + + this.state = { + value: index ? parseInt(index) : 0, + selectedPack: -1, + selectedGroup: -1, + times: 1, + scoreNum: 1, + loading: false, + redeemCode: "", + dialog: null, + payment: { + type: "", + img: "", + }, + scorePrice: 0, + redeemInfo: null, + packs: [], + groups: [], + alipay: false, + payjs: false, + wechat: false, + packPayMethod: null, + }; + } + + componentWillUnmount() { + window.clearInterval(this.IntervalId); + } + + componentDidMount() { + API.get("/vas/product") + .then((response) => { + this.setState({ + packs: response.data.packs, + groups: response.data.groups, + alipay: response.data.alipay, + payjs: response.data.payjs, + wechat: response.data.wechat, + custom: response.data.custom + ? response.data.custom_name + : null, + scorePrice: response.data.score_price, + }); + }) + .catch((error) => { + this.setState({ + loading: false, + }); + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + } + + confirmRedeem = () => { + this.setState({ + loading: true, + }); + API.post("/vas/redeem/" + this.state.redeemCode) + .then(() => { + this.setState({ + loading: false, + dialog: "success", + }); + }) + .catch((error) => { + this.setState({ + loading: false, + }); + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + doRedeem = () => { + if (this.state.redeemCode === "") { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("vas.pleaseInputGiftCode"), + "warning" + ); + return; + } + this.setState({ + loading: true, + }); + API.get("/vas/redeem/" + this.state.redeemCode) + .then((response) => { + this.setState({ + loading: false, + dialog: "redeem", + redeemInfo: response.data, + }); + }) + .catch((error) => { + this.setState({ + loading: false, + }); + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + buyPack = (packType) => { + if (packType === "pack" && this.state.selectedPack === -1) { + this.props.toggleSnackbar( + "top", + "right", + this.props.t("vas.pleaseSelectAStoragePack"), + "warning" + ); + return; + } + this.setState({ + loading: true, + }); + API.post("/vas/order", { + action: packType, + method: this.state.packPayMethod, + id: + packType === "score" + ? 1 + : packType === "pack" + ? this.state.packs[this.state.selectedPack].id + : this.state.groups[this.state.selectedGroup].id, + num: + packType === "score" + ? parseInt(this.state.scoreNum) + : parseInt(this.state.times), + }) + .then((response) => { + if (!response.data.payment) { + this.setState({ + loading: false, + dialog: "success", + }); + return; + } + if (response.data.qr_code) { + this.setState({ + loading: false, + dialog: "qr", + payment: { + type: this.state.packPayMethod, + img: response.data.qr_code, + }, + }); + this.IntervalId = window.setInterval(() => { + this.querryLoop(response.data.id); + }, 3000); + } + }) + .catch((error) => { + this.setState({ + loading: false, + }); + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + }; + + querryLoop = (id) => { + API.get("/vas/order/" + id) + .then((response) => { + if (response.data === 1) { + this.setState({ + dialog: "success", + }); + window.clearInterval(this.IntervalId); + } + }) + .catch((error) => { + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + window.clearInterval(this.IntervalId); + }); + }; + + handleChange = (event, value) => { + this.setState({ + packPayMethod: + this.state.packPayMethod === "score" + ? null + : this.state.packPayMethod, + }); + this.setState({ value }); + }; + + handleChangeIndex = (index) => { + this.setState({ value: index }); + }; + + handleClose = () => { + window.clearInterval(this.IntervalId); + this.setState({ + dialog: null, + }); + }; + + handleTexyChange = (name) => (event) => { + this.setState({ [name]: event.target.value }); + }; + + selectPack = (id) => { + this.setState({ + selectedPack: id, + packPayMethod: + this.state.packPayMethod === "score" + ? null + : this.state.packPayMethod, + }); + }; + + selectGroup = (id) => { + this.setState({ + selectedGroup: id, + dialog: "buyGroup", + packPayMethod: + this.state.packPayMethod === "score" + ? null + : this.state.packPayMethod, + }); + }; + + selectPackPayMethod = (event) => { + this.setState({ + packPayMethod: event.target.value, + }); + }; + + render() { + const { classes, t } = this.props; + + const methodSelect = ( +
+ {t("vas.selectPaymentMethod")} + + {!this.state.alipay && + !this.state.payjs && + this.state.value === 0 && + (this.state.selectedPack === -1 || + this.state.packs[this.state.selectedPack].score === + 0) && + this.state.value === 1 && + (this.state.selectedGroup === -1 || + this.state.groups[this.state.selectedGroup] + .score === 0) && ( + + {t("vas.noAvailableMethod")} + + )} + {this.state.alipay && ( + } + label={t("vas.alipay")} + /> + )} + {this.state.payjs && ( + } + label={t("vas.wechatPay")} + /> + )} + {this.state.wechat && ( + } + label={t("vas.wechatPay")} + /> + )} + {this.state.custom && ( + } + label={this.state.custom} + /> + )} + {this.state.value === 0 && + this.state.selectedPack !== -1 && + this.state.packs[this.state.selectedPack].score !== + 0 && ( + } + label={t("vas.payByCredits")} + /> + )} + {this.state.value === 1 && + this.state.selectedGroup !== -1 && + this.state.groups[this.state.selectedGroup].score !== + 0 && ( + } + label={t("vas.payByCredits")} + /> + )} + +
+ {this.state.value !== 2 && ( + {t("vas.purchaseDuration")} + )} + {this.state.value === 2 && ( + {t("vas.creditsNum")} + )} +
+ {this.state.value !== 2 && ( + + )} + {this.state.value === 2 && ( + + )} +
+ ); + + return ( +
+ + {t("vas.store")} + + + + } + /> + } + /> + {this.state.scorePrice > 0 && ( + } + /> + )} + } + /> + + + {this.state.value === 0 && ( + + + {this.state.packs.map((value, id) => ( + + this.selectPack(id)} + active={this.state.selectedPack === id} + /> + + ))} + + + + {methodSelect} + + +
+
+ {t("vas.subtotal")} + {this.state.packPayMethod !== + "score" && ( + + ¥ + {this.state.selectedPack === + -1 && --} + {this.state.selectedPack !== + -1 && + this.state.times <= 99 && + this.state.times >= 1 && ( + + {( + (this.state + .packs[ + this.state + .selectedPack + ].price / + 100) * + this.state.times + ).toFixed(2)} + + )} + + )} + {this.state.packPayMethod === + "score" && ( + + {this.state.selectedPack === + -1 && --} + {this.state.selectedPack !== + -1 && + this.state.times <= 99 && + this.state.times >= 1 && ( + + {t( + "vas.creditsTotalNum", + { + num: + this + .state + .packs[ + this + .state + .selectedPack + ] + .score * + this + .state + .times, + } + )} + + )} + + )} +
+
+ +
+
+
+
+
+ )} + {this.state.value === 1 && ( + + + {this.state.groups.map((tier, id) => ( + // Enterprise card is full width at sm breakpoint + + + + ) : null + } + className={classes.cardHeader} + /> + +
+ + ¥{tier.price / 100} + + + / + {t("vas.days", { + num: Math.ceil( + tier.time / 86400 + ), + })} + +
+ {tier.des.map((line) => ( + + {line} + + ))} +
+ + + +
+
+ ))} +
+
+ )} + + {this.state.value === 2 && ( + + + + {methodSelect} + + +
+
+ {t("vas.subtotal")} + {this.state.packPayMethod !== + "score" && ( + + ¥ + + {( + (this.state.scorePrice / + 100) * + this.state.scoreNum + ).toFixed(2)} + + + )} +
+
+ +
+
+
+
+
+ )} + + {this.state.value === 3 && ( + +
+ +
+ +
+
+
+ )} + + + + + {t("vas.paymentCompleted")} + + + + {t("vas.productDelivered")} + + + + + + + + + + {t("vas.confirmRedeem")} + + + {this.state.redeemInfo !== null && ( + + + {t("vas.productName")} + + + {this.state.redeemInfo.name} + + + {this.state.redeemInfo.type === 2 + ? t("vas.qyt") + : t("vas.duration")} + + + {this.state.redeemInfo.type === 2 && ( + <>{this.state.redeemInfo.num} + )} + {this.state.redeemInfo.type !== 2 && ( + <> + {t("vas.days", { + num: + Math.ceil( + this.state.redeemInfo + .time / 86400 + ) * + this.state.redeemInfo.num, + })} + + )} + + + )} + + + + + + + + + + {t("vas.subscribe")} + + + + {t("vas.selected")} + {this.state.selectedGroup !== -1 && + this.state.groups[this.state.selectedGroup] + .name} + + {methodSelect} +
+ {t("vas.subtotal")} + {this.state.packPayMethod !== "score" && ( + + ¥ + {this.state.selectedGroup === -1 && ( + -- + )} + {this.state.selectedGroup !== -1 && + this.state.times <= 99 && + this.state.times >= 1 && ( + + {( + (this.state.groups[ + this.state.selectedGroup + ].price / + 100) * + this.state.times + ).toFixed(2)} + + )} + + )} + {this.state.packPayMethod === "score" && ( + + {this.state.selectedGroup === -1 && ( + -- + )} + {this.state.selectedGroup !== -1 && + this.state.times <= 99 && + this.state.times >= 1 && ( + + {t("vas.creditsTotalNum", { + num: + this.state.groups[ + this.state + .selectedGroup + ].score * + this.state.times, + })} + + )} + + )} +
+
+ + + + +
+
+ ); + } +} + +const BuyQuota = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(BuyQuotaCompoment)))); + +export default BuyQuota; diff --git a/src/component/VAS/PackSelect.js b/src/component/VAS/PackSelect.js new file mode 100644 index 0000000..e573f82 --- /dev/null +++ b/src/component/VAS/PackSelect.js @@ -0,0 +1,90 @@ +import React, { Component } from "react"; +import classNames from "classnames"; +import { ButtonBase, Typography, withStyles } from "@material-ui/core"; +import { withTranslation } from "react-i18next"; + +const styles = (theme) => ({ + container: { + boxShadow: "0 0 0 1px #e6e9eb", + borderRadius: theme.shape.borderRadius, + transition: "box-shadow .5s", + width: "100%", + display: "block", + }, + active: { + boxShadow: "0 0 0 3px " + theme.palette.primary.main, + }, + boxHead: { + textAlign: "center", + padding: "10px 10px 10px", + borderBottom: "1px solid #e6e9eb", + color: theme.palette.text.main, + width: "100%", + }, + price: { + fontSize: "33px", + fontWeight: "500", + lineHeight: "40px", + color: theme.palette.primary.main, + }, + priceWithScore: { + fontSize: "23px", + fontWeight: "500", + lineHeight: "40px", + color: theme.palette.primary.main, + }, + packName: { + marginTop: "5px", + marginBottom: "5px", + }, + boxBottom: { + color: theme.palette.text.main, + textAlign: "center", + padding: "5px", + }, +}); + +class PackSelect extends Component { + render() { + const { classes, pack, t } = this.props; + return ( + +
+ + {pack.name} + + {pack.score === 0 && ( + + ¥{(pack.price / 100).toFixed(2)} + + )} + {pack.score !== 0 && ( + + ¥{(pack.price / 100).toFixed(2)} / + {t("vas.creditsTotalNum", { + num: pack.score, + })} + + )} +
+
+ + {t("vas.validDurationDays", { + num: Math.ceil(pack.time / 86400), + })} + +
+
+ ); + } +} + +export default withStyles(styles)(withTranslation()(PackSelect)); diff --git a/src/component/VAS/PaymentDialog.js b/src/component/VAS/PaymentDialog.js new file mode 100644 index 0000000..769bd9e --- /dev/null +++ b/src/component/VAS/PaymentDialog.js @@ -0,0 +1,55 @@ +import React from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + makeStyles, +} from "@material-ui/core"; +import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme) => ({ + codeContainer: { + textAlign: "center", + marginTop: "20px", + }, +})); + +export default function PaymentDialog({ open, handleClose, payment }) { + const classes = useStyles(); + const { t } = useTranslation(); + + return ( + + + {t("vas.paymentQrcode")} + + + + {payment.type === "alipay" && t("vas.qrcodeAlipay")} + {payment.type === "payjs" && t("vas.qrcodeWechat")} + {payment.type === "custom" && t("vas.qrcodeCustom")} + +
+ , +
+
+ + + + +
+ ); +} diff --git a/src/component/VAS/Quota.js b/src/component/VAS/Quota.js new file mode 100644 index 0000000..35a122f --- /dev/null +++ b/src/component/VAS/Quota.js @@ -0,0 +1,349 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import classNames from "classnames"; +import API from "../../middleware/Api"; + +import { + Button, + Grid, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, + withStyles, +} from "@material-ui/core"; +import { sizeToString } from "../../utils"; +import { withRouter } from "react-router"; +import { formatLocalTime } from "../../utils/datetime"; +import { toggleSnackbar } from "../../redux/explorer"; +import Nothing from "../Placeholder/Nothing"; +import { withTranslation } from "react-i18next"; + +const styles = (theme) => ({ + layout: { + width: "auto", + marginTop: "50px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: "50px", + }, + + gird: { + marginTop: "30px", + }, + paper: { + padding: theme.spacing(2), + color: theme.palette.text.secondary, + }, + data: { + fontSize: "25px", + color: theme.palette.primary.main, + }, + proBar: { + height: "10px", + }, + r1: { + backgroundColor: "#f0ad4e", + transition: "width .6s ease", + height: "100%", + fontSize: "12px", + lineHeight: "20px", + float: "left", + }, + r2: { + backgroundColor: "#4caf50", + transition: "width .6s ease", + height: "100%", + fontSize: "12px", + lineHeight: "20px", + float: "left", + }, + r3: { + backgroundColor: "#5bc0de", + transition: "width .6s ease", + height: "100%", + fontSize: "12px", + lineHeight: "20px", + float: "left", + }, + note_block: { + width: "10px", + height: "10px", + display: "inline-block", + position: "relative", + marginLeft: "10px", + marginRight: "3px", + }, + r1_block: { + backgroundColor: "#f0ad4e", + }, + r2_block: { + backgroundColor: "#4caf50", + }, + r3_block: { + backgroundColor: "#5bc0de", + }, + title: { + marginTop: "30px", + marginBottom: "30px", + }, + button: { + margin: theme.spacing(1), + }, + table: { + overflowX: "auto", + }, +}); +const mapStateToProps = () => { + return {}; +}; + +const mapDispatchToProps = (dispatch) => { + return { + toggleSnackbar: (vertical, horizontal, msg, color) => { + dispatch(toggleSnackbar(vertical, horizontal, msg, color)); + }, + }; +}; + +class QuotaCompoment extends Component { + state = { + data: { + basic: 0, + used: 0, + total: 0, + pack: 0, + r1: 0, + r2: 0, + r3: 0, + }, + packs: [], + }; + + firstLoad = true; + + componentDidMount() { + if (this.firstLoad) { + this.firstLoad = !this.firstLoad; + API.get("/vas/pack") + .then((response) => { + let usedR, + baseR, + packR = 0; + if (response.data.used > response.data.base) { + usedR = response.data.used / response.data.total; + baseR = 0; + packR = 1 - usedR; + } else { + usedR = response.data.used / response.data.total; + baseR = + (response.data.base - response.data.used) / + response.data.total; + packR = 1 - usedR - baseR; + } + + this.setState({ + data: { + used: response.data.used, + pack: response.data.pack, + total: response.data.total, + basic: response.data.base, + r1: usedR > 1 ? 100 : usedR * 100, + r2: usedR > 1 ? 0 : baseR * 100, + r3: usedR > 1 ? 0 : packR * 100, + }, + packs: response.data.packs, + }); + }) + .catch((error) => { + this.setState({ + loading: false, + }); + this.props.toggleSnackbar( + "top", + "right", + error.message, + "error" + ); + }); + } + } + render() { + const { classes, t } = this.props; + + return ( +
+ + {t("vas.quota")} + + + + + + {sizeToString(this.state.data.basic)} + + {t("vas.groupBaseQuota")} + + + + + + {sizeToString(this.state.data.pack)} + + {t("vas.validPackQuota")} + + + + + + {sizeToString(this.state.data.used)} + + {t("vas.used")} + + + + + + {sizeToString(this.state.data.total)} + + {t("vas.total")} + + + + +
+
+
+
+
+
+ + {t("vas.used")} + + {t("vas.groupBaseQuota")} + + {t("vas.validPackQuota")} +
+ + + + + {t("vas.validStoragePack")} + + + + +
+ + + + + {t("vas.packName")} + + + {t("fileManager.size")} + + + {t("vas.activationDate")} + + + {t("vas.validDuration")} + + + {t("vas.expiredAt")} + + + + + {this.state.packs.map((row, id) => ( + + + {row.name} + + + {sizeToString(row.size)} + + + {formatLocalTime(row.activate_date)} + + + {t("vas.days", { + num: Math.round( + row.expiration / 86400 + ), + })} + + + {formatLocalTime( + row.expiration_date + )} + + + ))} + +
+ {this.state.packs.length === 0 && ( + + )} +
+
+
+ ); + } +} + +const Quota = connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(withRouter(withTranslation()(QuotaCompoment)))); + +export default Quota; diff --git a/src/component/Viewer/Code.js b/src/component/Viewer/Code.js new file mode 100644 index 0000000..d5321bb --- /dev/null +++ b/src/component/Viewer/Code.js @@ -0,0 +1,198 @@ +import React, { Suspense, useCallback, useEffect, useState } from "react"; +import { Paper, useTheme } from "@material-ui/core"; +import { makeStyles } from "@material-ui/core/styles"; +import { useLocation, useParams, useRouteMatch } from "react-router"; +import API from "../../middleware/Api"; +import { useDispatch } from "react-redux"; +import pathHelper from "../../utils/page"; +import SaveButton from "../Dial/Save"; +import { codePreviewSuffix } from "../../config"; +import TextLoading from "../Placeholder/TextLoading"; +import FormControl from "@material-ui/core/FormControl"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Select from "@material-ui/core/Select"; +import Switch from "@material-ui/core/Switch"; +import MenuItem from "@material-ui/core/MenuItem"; +import Divider from "@material-ui/core/Divider"; +import { toggleSnackbar } from "../../redux/explorer"; +import UseFileSubTitle from "../../hooks/fileSubtitle"; +import { useTranslation } from "react-i18next"; + +const MonacoEditor = React.lazy(() => + import(/* webpackChunkName: "codeEditor" */ "react-monaco-editor") +); + +const useStyles = makeStyles((theme) => ({ + layout: { + width: "auto", + marginTop: "30px", + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(1100 + theme.spacing(3) * 2)]: { + width: 1100, + marginLeft: "auto", + marginRight: "auto", + }, + marginBottom: 40, + }, + editor: { + borderRadius: theme.shape.borderRadius, + }, + "@global": { + ".overflow-guard": { + borderRadius: "0 0 12px 12px!important", + }, + }, + formControl: { + margin: "8px 16px 8px 16px", + }, + toobar: { + textAlign: "right", + }, +})); + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +export default function CodeViewer() { + const { t } = useTranslation(); + const [content, setContent] = useState(""); + const [status, setStatus] = useState(""); + const [loading, setLoading] = useState(true); + const [suffix, setSuffix] = useState("javascript"); + const [wordWrap, setWordWrap] = useState("off"); + + const math = useRouteMatch(); + const location = useLocation(); + const query = useQuery(); + const { id } = useParams(); + const theme = useTheme(); + const { title } = UseFileSubTitle(query, math, location); + + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + + useEffect(() => { + const extension = title.split("."); + setSuffix(codePreviewSuffix[extension.pop()]); + // eslint-disable-next-line + }, [title]); + + useEffect(() => { + let requestURL = "/file/content/" + query.get("id"); + if (pathHelper.isSharePage(location.pathname)) { + requestURL = "/share/content/" + id; + if (query.get("share_path") !== "") { + requestURL += + "?path=" + encodeURIComponent(query.get("share_path")); + } + } + + setLoading(true); + API.get(requestURL, { responseType: "arraybuffer" }) + .then((response) => { + const buffer = new Buffer(response.rawData, "binary"); + const textdata = buffer.toString(); // for string + setContent(textdata); + }) + .catch((error) => { + ToggleSnackbar( + "top", + "right", + t("fileManager.errorReadFileContent", { + msg: error.message, + }), + "error" + ); + }) + .then(() => { + setLoading(false); + }); + // eslint-disable-next-line + }, [math.params[0]]); + + const save = () => { + setStatus("loading"); + API.put("/file/update/" + query.get("id"), content) + .then(() => { + setStatus("success"); + setTimeout(() => setStatus(""), 2000); + }) + .catch((error) => { + setStatus(""); + ToggleSnackbar("top", "right", error.message, "error"); + }); + }; + + const classes = useStyles(); + const isSharePage = pathHelper.isSharePage(location.pathname); + return ( +
+ +
+ + + setWordWrap( + e.target.checked ? "on" : "off" + ) + } + /> + } + label={t("fileManager.wordWrap")} + /> + + + + +
+ + {loading && } + {!loading && ( + }> + setContent(value)} + /> + + )} +
+ {!isSharePage && } +
+ ); +} diff --git a/src/component/Viewer/Doc.js b/src/component/Viewer/Doc.js new file mode 100644 index 0000000..1b9fc19 --- /dev/null +++ b/src/component/Viewer/Doc.js @@ -0,0 +1,186 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { makeStyles, useTheme } from "@material-ui/core/styles"; +import { useLocation, useParams, useRouteMatch } from "react-router"; +import API from "../../middleware/Api"; +import { useDispatch, useSelector } from "react-redux"; +import pathHelper from "../../utils/page"; +import { + closeAllModals, + openShareDialog, + setModalsLoading, + setSelectedTarget, + toggleSnackbar, +} from "../../redux/explorer"; +import UseFileSubTitle from "../../hooks/fileSubtitle"; +import i18n from "i18next"; +import CreatShare from "../Modals/CreateShare"; + +const useStyles = makeStyles(() => ({ + layout: { + width: "auto", + }, + "@global": { + iframe: { + border: "none", + width: "100%", + height: "calc(100vh - 64px)", + marginBottom: -10, + }, + }, +})); + +function useQuery() { + return new URLSearchParams(useLocation().search); +} + +export default function DocViewer() { + const [session, setSession] = useState(null); + const [file, setFile] = useState(null); + const math = useRouteMatch(); + const location = useLocation(); + const query = useQuery(); + const { id } = useParams(); + const theme = useTheme(); + const { title } = UseFileSubTitle(query, math, location); + + const shareOpened = useSelector((state) => state.viewUpdate.modals.share); + const modalLoading = useSelector((state) => state.viewUpdate.modalsLoading); + const dispatch = useDispatch(); + const ToggleSnackbar = useCallback( + (vertical, horizontal, msg, color) => + dispatch(toggleSnackbar(vertical, horizontal, msg, color)), + [dispatch] + ); + const CloseAllModals = useCallback( + () => dispatch(closeAllModals()), + [dispatch] + ); + const OpenShareDialog = useCallback( + () => dispatch(openShareDialog()), + [dispatch] + ); + const SetModalsLoading = useCallback( + (status) => dispatch(setModalsLoading(status)), + [dispatch] + ); + + useEffect(() => { + let requestURL = "/file/doc/" + query.get("id"); + if (pathHelper.isSharePage(location.pathname)) { + requestURL = "/share/doc/" + id; + if (query.get("share_path") !== "") { + requestURL += + "?path=" + encodeURIComponent(query.get("share_path")); + } + } + API.get(requestURL) + .then((response) => { + if (response.data.access_token) { + response.data.url = response.data.url.replaceAll( + "lng", + i18n.resolvedLanguage.toLowerCase() + ); + response.data.url = response.data.url.replaceAll( + "darkmode", + theme.palette.type === "dark" ? "2" : "1" + ); + } + + setSession(response.data); + }) + .catch((error) => { + ToggleSnackbar("top", "right", error.message, "error"); + }); + // eslint-disable-next-line + }, [math.params[0], location]); + + const classes = useStyles(); + + const handlePostMessage = (e) => { + console.log("Received PostMessage from " + e.origin, e.data); + let msg; + try { + msg = JSON.parse(e.data); + } catch (e) { + return; + } + + if (msg.MessageId === "UI_Sharing") { + setFile([ + { + name: title, + id: query.get("id"), + type: "file", + }, + ]); + OpenShareDialog(); + } + }; + + useEffect(() => { + const frameholder = document.getElementById("frameholder"); + const office_frame = document.createElement("iframe"); + if (session && session.access_token && frameholder) { + office_frame.name = "office_frame"; + office_frame.id = "office_frame"; + + // The title should be set for accessibility + office_frame.title = "Office Frame"; + + // This attribute allows true fullscreen mode in slideshow view + // when using PowerPoint's 'view' action. + office_frame.setAttribute("allowfullscreen", "true"); + + // The sandbox attribute is needed to allow automatic redirection to the O365 sign-in page in the business user flow + office_frame.setAttribute( + "sandbox", + "allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox" + ); + frameholder.appendChild(office_frame); + document.getElementById("office_form").submit(); + window.addEventListener("message", handlePostMessage, false); + + return () => { + window.removeEventListener("message", handlePostMessage, false); + }; + } + }, [session]); + + return ( +
+ CloseAllModals()} + modalsLoading={modalLoading} + setModalsLoading={SetModalsLoading} + selected={file} + /> + {session && !session.access_token && ( +