This commit is contained in:
Zichao Lin 2024-02-25 08:27:01 +08:00
commit 20c1fa08dc
Signed by: earthjasonlin
GPG Key ID: 406D9913DE2E42FB
279 changed files with 78489 additions and 0 deletions

43
.eslintrc.yaml Normal file

@ -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

25
.gitignore vendored Normal file

@ -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/

1
.huskyrc Normal file

@ -0,0 +1 @@
export PATH="/usr/local/bin:$PATH"

3
.prettierrc Normal file

@ -0,0 +1,3 @@
{
"tabWidth": 4
}

8
.travis.yml Normal file

@ -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

68
README.md Normal file

@ -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.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.<br />
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.<br />
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.<br />
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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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

93
config/env.js Normal file

@ -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 were 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, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// 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;

@ -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';
},
};

@ -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};`;
},
};

141
config/modules.js Normal file

@ -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/(.*)$': '<rootDir>/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();

89
config/paths.js Normal file

@ -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 <script> hrefs into HTML even in
// single-page apps that may serve index.html for nested URLs like /todos/42.
// We can't use a relative path in HTML because we don't want to load something
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(appPackageJson);
const servedUrl =
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : "/");
return ensureSlash(servedUrl, true);
}
const moduleFileExtensions = [
"web.mjs",
"mjs",
"web.js",
"js",
"web.ts",
"ts",
"web.tsx",
"tsx",
"json",
"web.jsx",
"jsx",
];
// Resolve file paths in the same order as webpack
const resolveModule = (resolveFn, filePath) => {
const extension = moduleFileExtensions.find((extension) =>
fs.existsSync(resolveFn(`${filePath}.${extension}`))
);
if (extension) {
return resolveFn(`${filePath}.${extension}`);
}
return resolveFn(`${filePath}.js`);
};
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp(".env"),
appPath: resolveApp("."),
appBuild: resolveApp("build"),
appPublic: resolveApp("public"),
appHtml: resolveApp("public/index.html"),
appIndexJs: resolveModule(resolveApp, "src/index"),
appPackageJson: resolveApp("package.json"),
appSrc: resolveApp("src"),
appTsConfig: resolveApp("tsconfig.json"),
appJsConfig: resolveApp("jsconfig.json"),
yarnLockFile: resolveApp("yarn.lock"),
testsSetup: resolveModule(resolveApp, "src/setupTests"),
proxySetup: resolveApp("src/setupProxy.js"),
appNodeModules: resolveApp("node_modules"),
publicUrl: getPublicUrl(resolveApp("package.json")),
servedPath: getServedPath(resolveApp("package.json")),
i18nPath: resolveApp("public/locales"),
};
module.exports.moduleFileExtensions = moduleFileExtensions;

35
config/pnpTs.js Normal file

@ -0,0 +1,35 @@
'use strict';
const { resolveModuleName } = require('ts-pnp');
exports.resolveModuleName = (
typescript,
moduleName,
containingFile,
compilerOptions,
resolutionHost
) => {
return resolveModuleName(
moduleName,
containingFile,
compilerOptions,
resolutionHost,
typescript.resolveModuleName
);
};
exports.resolveTypeReferenceDirective = (
typescript,
moduleName,
containingFile,
compilerOptions,
resolutionHost
) => {
return resolveModuleName(
moduleName,
containingFile,
compilerOptions,
resolutionHost,
typescript.resolveTypeReferenceDirective
);
};

780
config/webpack.config.js Normal file

@ -0,0 +1,780 @@
'use strict';
const fs = require("fs");
const isWsl = require("is-wsl");
const path = require("path");
const webpack = require("webpack");
const resolve = require("resolve");
const PnpWebpackPlugin = require("pnp-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin");
const TerserPlugin = require("terser-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const safePostCssParser = require("postcss-safe-parser");
const ManifestPlugin = require("webpack-manifest-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const WorkboxWebpackPlugin = require("workbox-webpack-plugin");
const WatchMissingNodeModulesPlugin = require("react-dev-utils/WatchMissingNodeModulesPlugin");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent");
const paths = require("./paths");
const modules = require("./modules");
const getClientEnvironment = require("./env");
const ModuleNotFoundPlugin = require("react-dev-utils/ModuleNotFoundPlugin");
const ForkTsCheckerWebpackPlugin = require("react-dev-utils/ForkTsCheckerWebpackPlugin");
const typescriptFormatter = require("react-dev-utils/typescriptFormatter");
const eslint = require("eslint");
const CopyPlugin = require("copy-webpack-plugin");
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
const postcssNormalize = require("postcss-normalize");
const appPackageJson = require(paths.appPackageJson);
//const FileListPlugin = require("@abslant/cd-js-injector/src");
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
// makes for a smoother build process.
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
const imageInlineSizeLimit = parseInt(
process.env.IMAGE_INLINE_SIZE_LIMIT || '10000'
);
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
//const mainJSRegex = /main\.(.+)\.chunk\.js$/;
//const mainJSRegex = /main\.chunk\.js$/;
// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
module.exports = function (webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
// Variable used for enabling profiling in Production
// passed into alias object. Uses a flag if passed into the build command
const isEnvProductionProfile =
isEnvProduction && process.argv.includes('--profile');
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// In development, we always serve from the root. This makes config easier.
const publicPath = isEnvProduction
? paths.servedPath
: isEnvDevelopment && '/';
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = isEnvProduction
? publicPath.slice(0, -1)
: isEnvDevelopment && '';
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
isEnvDevelopment && require.resolve("style-loader"),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
options: shouldUseRelativeAssetPaths
? { publicPath: "../../" }
: {},
},
{
loader: require.resolve("css-loader"),
options: cssOptions,
},
{
// Options for PostCSS as we reference these options twice
// Adds vendor prefixing based on your specified browser support in
// package.json
loader: require.resolve("postcss-loader"),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebook/create-react-app/issues/2677
ident: "postcss",
plugins: () => [
require("postcss-flexbugs-fixes"),
require("postcss-preset-env")({
autoprefixer: {
flexbox: "no-2009",
},
stage: 3,
}),
// Adds PostCSS Normalize as the reset css with default options,
// so that it honors browserslist config in package.json
// which in turn let's users customize the target behavior as per their needs.
postcssNormalize(),
],
sourceMap: isEnvProduction && shouldUseSourceMap,
},
},
].filter(Boolean);
if (preProcessor) {
loaders.push(
{
loader: require.resolve("resolve-url-loader"),
options: {
sourceMap: isEnvProduction && shouldUseSourceMap,
},
},
{
loader: require.resolve(preProcessor),
options: {
sourceMap: true,
},
}
);
}
return loaders;
};
return {
mode: isEnvProduction
? "production"
: isEnvDevelopment && "development",
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? "source-map"
: false
: isEnvDevelopment && "cheap-module-source-map",
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: [
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
isEnvDevelopment &&
require.resolve("react-dev-utils/webpackHotDevClient"),
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
].filter(Boolean),
output: {
// The build folder.
path: isEnvProduction ? paths.appBuild : undefined,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? "static/js/[name].[contenthash:8].js"
: isEnvDevelopment && "static/js/bundle.js",
// TODO: remove this when upgrading to webpack 5
futureEmitAssets: true,
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? "static/js/[name].[contenthash:8].chunk.js"
: isEnvDevelopment && "static/js/[name].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
// We use "/" in development.
publicPath: publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/")
: isEnvDevelopment &&
((info) =>
path
.resolve(info.absoluteResourcePath)
.replace(/\\/g, "/")),
// Prevents conflicts when multiple Webpack runtimes (from different apps)
// are used on the same page.
jsonpFunction: `webpackJsonp${appPackageJson.name}`,
// this defaults to 'window', but by setting it to 'this' then
// module chunks which are built will work in web workers as well.
globalObject: "this",
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// We want terser to parse ecma 8 code. However, we don't want it
// to apply any minification steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending further investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2,
},
mangle: {
safari10: true,
},
// Added for profiling in devtools
keep_classnames: isEnvProductionProfile,
keep_fnames: isEnvProductionProfile,
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
// Use multi-process parallel running to improve the build speed
// Default number of concurrent runs: os.cpus().length - 1
// Disabled on WSL (Windows Subsystem for Linux) due to an issue with Terser
// https://github.com/webpack-contrib/terser-webpack-plugin/issues/21
parallel: !isWsl,
// Enable file caching
cache: true,
sourceMap: shouldUseSourceMap,
}),
// This is only used in production mode
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
parser: safePostCssParser,
map: shouldUseSourceMap
? {
// `inline: false` forces the sourcemap to be output into a
// separate file
inline: false,
// `annotation: true` appends the sourceMappingURL to the end of
// the css file, helping the browser find the sourcemap
annotation: true,
}
: false,
},
}),
],
// Automatically split vendor and commons
// https://twitter.com/wSokra/status/969633336732905474
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
splitChunks: {
chunks: "all",
name: false,
cacheGroups: {
monacoCommon: {
test: /[\\/]node_modules[\\/]monaco\-editor/,
name: "monaco-editor-common",
chunks: "async",
},
pdfCommon: {
test: /[\\/]node_modules[\\/](react\-pdf|pdfjs\-dist)/,
name: "react-pdf",
chunks: "async",
},
},
},
// Keep the runtime chunk separated to enable long term caching
// https://twitter.com/wSokra/status/969679223278505985
// https://github.com/facebook/create-react-app/issues/5358
runtimeChunk: {
name: (entrypoint) => `runtime-${entrypoint.name}`,
},
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules].concat(
modules.additionalModulePaths || []
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes("ts")),
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
"react-native": "react-native-web",
// Allows for better profiling with ReactDevTools
...(isEnvProductionProfile && {
"react-dom$": "react-dom/profiling",
"scheduler/tracing": "scheduler/tracing-profiling",
}),
...(modules.webpackAliases || {}),
},
plugins: [
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
// guards against forgotten dependencies and such.
PnpWebpackPlugin,
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
enforce: "pre",
use: [
{
options: {
cache: true,
formatter: require.resolve(
"react-dev-utils/eslintFormatter"
),
eslintPath: require.resolve("eslint"),
resolvePluginsRelativeTo: __dirname,
},
loader: require.resolve("eslint-loader"),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: imageInlineSizeLimit,
name: "static/media/[name].[hash:8].[ext]",
},
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve("babel-loader"),
options: {
customize: require.resolve(
"babel-preset-react-app/webpack-overrides"
),
plugins: [
[
require.resolve(
"babel-plugin-named-asset-import"
),
{
loaderMap: {
svg: {
ReactComponent:
"@svgr/webpack?-svgo,+titleProp,+ref![path]",
},
},
},
],
],
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
compact: isEnvProduction,
},
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve("babel-loader"),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [
[
require.resolve(
"babel-preset-react-app/dependencies"
),
{ helpers: true },
],
],
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
// If an error happens in a package, it's possible to be
// because it was compiled. Thus, we don't want the browser
// debugger to show the original code. Instead, the code
// being evaluated would be much more helpful.
sourceMaps: false,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap:
isEnvProduction && shouldUseSourceMap,
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap:
isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
}),
},
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap:
isEnvProduction && shouldUseSourceMap,
},
"sass-loader"
),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap:
isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
},
"sass-loader"
),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve("file-loader"),
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [
/\.(js|mjs|jsx|ts|tsx)$/,
/\.html$/,
/\.json$/,
],
options: {
name: "static/media/[name].[hash:8].[ext]",
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Monaco 代码编辑器
new MonacoWebpackPlugin({
// available options are documented at https://github.com/Microsoft/monaco-editor-webpack-plugin#options
languages: [
"json",
"php",
"bat",
"cpp",
"python",
"csharp",
"css",
"dockerfile",
"go",
"html",
"ini",
"java",
"javascript",
"less",
"lua",
"shell",
"sql",
"xml",
"yaml",
],
}),
// 写入版本文件
new CopyPlugin([
{
from: "package.json",
to: "version.json",
transform(content, path) {
let contentJson = JSON.parse(content);
return JSON.stringify({
version: contentJson.version,
name: contentJson.name,
});
},
},
]),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
//new FileListPlugin(),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
// https://github.com/facebook/create-react-app/issues/5358
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [
/runtime-.+[.]js/,
]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
// In development, this will be an empty string.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: "static/css/[name].[contenthash:8].css",
chunkFilename:
"static/css/[name].[contenthash:8].chunk.css",
}),
// Generate an asset manifest file with the following content:
// - "files" key: Mapping of all asset filenames to their corresponding
// output file so that tools can pick it up without having to parse
// `index.html`
// - "entrypoints" key: Array of files which are included in `index.html`,
// can be used to reconstruct the HTML if necessary
new ManifestPlugin({
fileName: "asset-manifest.json",
publicPath: publicPath,
generate: (seed, files, entrypoints) => {
const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path;
return manifest;
}, seed);
const entrypointFiles = entrypoints.main.filter(
(fileName) => !fileName.endsWith(".map")
);
return {
files: manifestFiles,
entrypoints: entrypointFiles,
};
},
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new webpack.DefinePlugin({
ASSETS_VERSION: JSON.stringify(
require("../package.json").version
),
}),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the Webpack build.
isEnvProduction &&
new WorkboxWebpackPlugin.GenerateSW({
clientsClaim: true,
exclude: [
/\.map$/,
/asset-manifest\.json$/,
/\.ttf$/,
/version\.json$/,
/\.worker\.js$/,
],
importWorkboxFrom: "cdn",
excludeChunks: ["monaco-editor-common", "pdf", "react-pdf"],
navigateFallback: publicUrl + "/index.html",
navigateFallbackBlacklist: [
// Exclude URLs starting with /_, as they're likely an API call
new RegExp("^/_"),
// Exclude any URLs whose last part seems to be a file extension
// as they're likely a resource and not a SPA route.
// URLs containing a "?" character won't be blacklisted as they're likely
// a route with query params (e.g. auth callbacks).
new RegExp("/[^/?]+\\.[^/]+$"),
new RegExp(/^\/api/),
new RegExp(/^\/f\//),
new RegExp(/^\/custom/),
],
}),
// TypeScript type checking
useTypeScript &&
new ForkTsCheckerWebpackPlugin({
typescript: resolve.sync("typescript", {
basedir: paths.appNodeModules,
}),
async: isEnvDevelopment,
useTypescriptIncrementalApi: true,
checkSyntacticErrors: true,
resolveModuleNameModule: process.versions.pnp
? `${__dirname}/pnpTs.js`
: undefined,
resolveTypeReferenceDirectiveModule: process.versions.pnp
? `${__dirname}/pnpTs.js`
: undefined,
tsconfig: paths.appTsConfig,
reportFiles: [
"**",
"!**/__tests__/**",
"!**/?(*.)(spec|test).*",
"!**/src/setupProxy.*",
"!**/src/setupTests.*",
],
silent: true,
// The formatter is invoked directly in WebpackDevServerUtils during development
formatter: isEnvProduction
? typescriptFormatter
: undefined,
}),
].filter(Boolean),
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
module: "empty",
dgram: "empty",
dns: "mock",
fs: "empty",
http2: "empty",
net: "empty",
tls: "empty",
child_process: "empty",
},
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
};
};

@ -0,0 +1,104 @@
"use strict";
const errorOverlayMiddleware = require("react-dev-utils/errorOverlayMiddleware");
const evalSourceMapMiddleware = require("react-dev-utils/evalSourceMapMiddleware");
const noopServiceWorkerMiddleware = require("react-dev-utils/noopServiceWorkerMiddleware");
const ignoredFiles = require("react-dev-utils/ignoredFiles");
const paths = require("./paths");
const fs = require("fs");
const protocol = process.env.HTTPS === "true" ? "https" : "http";
const host = process.env.HOST || "0.0.0.0";
module.exports = function (proxy, allowedHost) {
return {
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
// websites from potentially accessing local content through DNS rebinding:
// https://github.com/webpack/webpack-dev-server/issues/887
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebook/create-react-app/issues/2271
// https://github.com/facebook/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
// use the `proxy` feature, it gets more dangerous because it can expose
// remote code execution vulnerabilities in backends like Django and Rails.
// So we will disable the host check normally, but enable it if you have
// specified the `proxy` setting. Finally, we let you override it if you
// really know what you're doing with a special environment variable.
disableHostCheck:
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === "true",
// Enable gzip compression of generated files.
compress: true,
// Silence WebpackDevServer's own logs since they're generally not useful.
// It will still show compile warnings and errors with this setting.
clientLogLevel: "none",
// By default WebpackDevServer serves physical files from current directory
// in addition to all the virtual build products that it serves from memory.
// This is confusing because those files wont automatically be available in
// production build folder unless we copy them. However, copying the whole
// project directory is dangerous because we may expose sensitive files.
// Instead, we establish a convention that only files in `public` directory
// get served. Our build script will copy `public` into the `build` folder.
// In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
// Note that we only recommend to use `public` folder as an escape hatch
// for files like `favicon.ico`, `manifest.json`, and libraries that are
// for some reason broken when imported through Webpack. If you just want to
// use an image, put it in `src` and `import` it from JavaScript instead.
contentBase: paths.appPublic,
// By default files from `contentBase` will not trigger a page reload.
watchContentBase: true,
// Enable hot reloading server. It will provide /sockjs-node/ endpoint
// for the WebpackDevServer client so it can learn when the files were
// updated. The WebpackDevServer client is included as an entry point
// in the Webpack development configuration. Note that only changes
// to CSS are currently hot reloaded. JS changes will refresh the browser.
hot: true,
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: "/",
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
quiet: true,
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebook/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebook/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
// Enable HTTPS if the HTTPS environment variable is set to 'true'
https: protocol === "https",
host,
overlay: false,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebook/create-react-app/issues/387.
disableDotRule: true,
},
public: allowedHost,
proxy,
before(app, server) {
if (fs.existsSync(paths.proxySetup)) {
// This registers user provided middleware for proxy reasons
require(paths.proxySetup)(app);
}
// This lets us fetch source contents from webpack for the error overlay
app.use(evalSourceMapMiddleware(server));
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
// This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware("/"));
},
};
};

3
crowdin.yml Normal file

@ -0,0 +1,3 @@
files:
- source: /public/locales/zh-CN/*.json
translation: /public/locales/%locale%/%original_file_name%

204
package.json Normal file

@ -0,0 +1,204 @@
{
"name": "cloudreve-frontend-plus",
"version": "3.8.3+1.1",
"private": true,
"dependencies": {
"@babel/core": "7.6.0",
"@material-ui/core": "~4.11.0",
"@material-ui/icons": "^4.5.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@svgr/webpack": "4.3.2",
"@types/invariant": "^2.2.32",
"@types/jest": "^25.2.2",
"@types/node": "^14.0.1",
"@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@types/streamsaver": "^2.0.1",
"@types/wicg-file-system-access": "^2020.9.5",
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"ahooks": "^3.5.2",
"artplayer": "^4.3.4",
"axios": "^0.21.1",
"babel-eslint": "10.0.3",
"babel-jest": "^24.9.0",
"babel-loader": "8.0.6",
"babel-plugin-named-asset-import": "^0.3.4",
"babel-preset-react-app": "^9.0.2",
"camelcase": "^5.2.0",
"case-sensitive-paths-webpack-plugin": "2.2.0",
"classnames": "^2.2.6",
"clsx": "latest",
"connected-react-router": "^6.9.1",
"css-loader": "2.1.1",
"dayjs": "^1.10.4",
"dotenv": "6.2.0",
"dotenv-expand": "5.1.0",
"eslint": "^6.8.0",
"eslint-config-react-app": "^5.0.2",
"eslint-loader": "3.0.2",
"eslint-plugin-flowtype": "3.13.0",
"eslint-plugin-import": "2.18.2",
"eslint-plugin-jsx-a11y": "6.2.3",
"eslint-plugin-react": "^7.19.0",
"file-loader": "3.0.1",
"for-editor": "^0.3.5",
"fs-extra": "7.0.1",
"html-webpack-plugin": "4.0.0-beta.5",
"http-proxy-middleware": "^0.20.0",
"husky": "^4.2.5",
"i18next": "^21.6.16",
"i18next-browser-languagedetector": "^6.1.4",
"i18next-chained-backend": "^4.2.0",
"i18next-http-backend": "^1.4.0",
"i18next-localstorage-backend": "^4.1.0",
"identity-obj-proxy": "3.0.0",
"invariant": "^2.2.4",
"is-wsl": "^1.1.0",
"jest": "24.9.0",
"jest-environment-jsdom-fourteen": "0.1.0",
"jest-resolve": "24.9.0",
"jest-watch-typeahead": "0.4.0",
"material-ui-toggle-icon": "^1.1.1",
"mdi-material-ui": "^6.9.0",
"mini-css-extract-plugin": "0.8.0",
"monaco-editor-webpack-plugin": "^3.0.0",
"optimize-css-assets-webpack-plugin": "5.0.3",
"pnp-webpack-plugin": "1.5.0",
"postcss-flexbugs-fixes": "4.1.0",
"postcss-loader": "3.0.0",
"postcss-normalize": "7.0.1",
"postcss-preset-env": "6.7.0",
"postcss-safe-parser": "4.0.1",
"qrcode.react": "^3.1.0",
"react": "^16.12.0",
"react-addons-update": "^15.6.2",
"react-app-polyfill": "^1.0.4",
"react-async-script": "^1.1.1",
"react-color": "^2.18.0",
"react-content-loader": "^5.0.2",
"react-dev-utils": "^9.1.0",
"react-dnd": "^9.5.1",
"react-dnd-html5-backend": "^9.5.1",
"react-dom": "^16.12.0",
"react-highlight-words": "^0.18.0",
"react-hotkeys": "^2.0.0",
"react-i18next": "^11.16.7",
"react-lazy-load-image-component": "^1.3.2",
"react-load-script": "^0.0.6",
"react-monaco-editor": "^0.36.0",
"react-pdf": "^4.1.0",
"react-photo-view": "^0.4.0",
"react-reader": "^0.21.1",
"react-redux": "^7.1.3",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-virtuoso": "^2.8.6",
"recharts": "^2.0.6",
"redux": "^4.0.4",
"redux-mock-store": "^1.5.4",
"redux-thunk": "^2.3.0",
"resolve": "1.12.0",
"resolve-url-loader": "3.1.0",
"sass-loader": "7.2.0",
"semver": "6.3.0",
"streamsaver": "^2.0.6",
"style-loader": "1.0.0",
"styled-components": "^5.3.6",
"terser-webpack-plugin": "1.4.1",
"timeago-react": "^3.0.0",
"ts-pnp": "1.1.4",
"typescript": "^3.9.2",
"url-loader": "2.1.0",
"webpack": "4.41.0",
"webpack-dev-server": "3.11.3",
"webpack-manifest-plugin": "2.1.1",
"workbox-webpack-plugin": "4.3.1",
"yarn": "^1.22.4"
},
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js",
"eslint": "eslint src --fix",
"postinstall": "node node_modules/husky/lib/installer/bin install"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"roots": [
"<rootDir>/src"
],
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
],
"setupFiles": [
"react-app-polyfill/jsdom"
],
"setupFilesAfterEnv": [],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
],
"testEnvironment": "jest-environment-jsdom-fourteen",
"transform": {
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
"^.+\\.module\\.(css|sass|scss)$"
],
"modulePaths": [],
"moduleNameMapper": {
"^react-native$": "react-native-web",
"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"web.js",
"js",
"web.ts",
"ts",
"web.tsx",
"tsx",
"json",
"web.jsx",
"jsx",
"node"
],
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
},
"babel": {
"presets": [
"react-app"
]
},
"devDependencies": {
"copy-webpack-plugin": "^5.1.1",
"eslint-plugin-react-hooks": "^4.0.0",
"prettier": "2.7.1"
},
"husky": {
"hooks": {
"pre-commit": "yarn run eslint"
}
},
"resolutions": {
"@types/react": "^16.9.35"
}
}

6
pakstatics.sh Normal file

@ -0,0 +1,6 @@
#!/usr/bin/bash
cd ../
# zip -r - assets/build >assets_full.zip
find assets/build/ -name "*.map" -type f -delete
zip -r - assets/build >assets.zip

24
public/index.html Normal file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="{pwa_small_icon}" sizes="64x64"/>
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="" />
<link rel="manifest" href="/manifest.json" />
<meta name="description" content="{siteDes}">
<meta name="keywords" content="{siteKeywords}">
<title>{siteName}</title>
<script>
window.subTitle = "{siteName}";
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
{siteScript}
</body>
</html>

@ -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 didnt 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>*</0> as a wildcard. For example, <1>*.png</1> 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}}</0> in my files",
"searchInFolders": "Search <0>{{name}}</0> under current folder",
"searchInShares": "Search <0>{{name}}</0> 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></1>",
"musicPlayer": "Music player",
"closeAndStop": "Close and stop",
"playInBackground": "Play in background",
"copyTo": "Copy to",
"copyToDst": "Copy to <0>{{dst}}</0>",
"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}}</0> :",
"newName": "New name",
"moveToTitle": "Move to",
"moveToDescription": "Move to <0>{{name}}</0>",
"saveToTitle": "Save to",
"saveToTitleDescription": "Save to <0>{{name}}</0>",
"deleteTitle": "Delete objects",
"deleteOneDescription": "Are you sure to delete <0>{{name}}</0> ?",
"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}}</0>",
"decompressTo": "Decompress to",
"decompressToDst": "Decompress to <0>{{name}}</0>",
"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></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}}</0>",
"sharedBy": "<0>{{nick}}</0> 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></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}}</0> 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></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"
}
}

@ -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."
}
}

@ -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></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</0>.",
"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</0>.",
"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</0>.",
"siteSecret": "Secret",
"siteSecretDes": "You can find it at <0>App Management Page</0>.",
"secretID": "SecretId",
"secretIDDes": "You can find it at <0>Access Management Page</0>.",
"secretKey": "SecretKey",
"secretKeyDes": "You can find it at <0>Access Management Page</0>.",
"tCaptchaAppID": "APPID",
"tCaptchaAppIDDes": "You can find it at <0>Captcha Management Page</0>.",
"tCaptchaSecretKey": "App Secret Key",
"tCaptchaSecretKeyDes": "You can find it at <0>Captcha Management Page</0>.",
"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</0>.",
"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</0> 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</0> 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</0> 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</1> field under the <0>System</0> section must be changed to <2>slave</2>.",
"remoteConfigDifference2": "You must specify the <1>Secret</1> field under the <0>Slave</0> 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</0> 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</0> 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</0>.",
"createQiniuBucket": "Go to <0>Qiniu dashboard</0> 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</0> to create a Bucket。Attention: You can only use bucket with SKU of <1>Standard storage</1> or <2>Low frequency storage</2>, <3>Archive storage</3> is not supported.",
"ossBucketNameDes": "Enter the your specified <0>Bucket name</0>:",
"bucketName": "Bucket name",
"publicReadBucket": "Public read",
"ossEndpointDes": "Go to Bucket summary page, enter the <2>Endpoint</2> under <1>External access</1> section, in <0>Access domain</0> 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</0> 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</0> 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</0> 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</0> to create a storage bucket.",
"cosBucketNameDes": "Go to basic setting page of created bucket, enter <0>Bucket name</0> 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</0> 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</1> given under the <0>Basic Information</0> 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</0> 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</0> 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</0> 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)</0> or <1>Azure Active Directory Dashboard (21V Chinese cloud)</1>, after logging in, go to the <2>Azure Active Directory</2> 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</0> menu on the left and click the <1>New registration</1> button.",
"createAadAppDes3": "Fill out the application registration form. Makse sure <0>Supported account types</0> is selected as <1>\tAccounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)</1>; <2>Redirect URI (optional)</2> is selected as <3>Web</3> and fill in <4>{{url}}</4>; For other fields, just leave it as default.",
"aadAppIDDes": "Once created, go to the <0>Overview</0> page in Application Management, copy the <1>Application (Client) ID</1> and fill in the following fields:",
"aadAppID": "Application (Client) ID",
"addAppSecretDes": "Go to the <0>Certificates & secrets</0> menu on the left side, click the <1>New client secret</1> button, and select <3>Never</3> for the <2>Expires</2>. 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</0> 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</0>. 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</0>.",
"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. </0> 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></0>; which can be applied to the slave node via the configuration file, please adjust them according to <0></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</0>. 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. </1> For more information and guidelines, refer the <2>Offline Downloads</2> 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/</0>, where the port number <1>6800</1> is consistent with <2>rpc-listen-port</2> 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</0> in the Aria2 configuration file; leave blank if not set.",
"aria2PathDes": "Fill in the <0>absolute path</0> 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</0>.",
"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</0> to edit the master node.",
"slaveAria2Des": "If you want to distribute remote download tasks on slave nodes, <0>click here</0> 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</0> 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</0>.",
"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</0>, 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</0> 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</0>. The VOL license allows your users to connect to your site using the <1>Cloudreve iOS</1> 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."
}
}

@ -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>*</0> 作为通配符。比如 <1>*.png</1> 表示匹配 png 格式图像。多行规则间会以 “或” 的关系进行运算。",
"icon": "图标:",
"color": "颜色:",
"folderPath": "目录路径"
},
"storage": "存储空间",
"storageDetail": "已使用 {{used}}, 共 {{total}}",
"notLoginIn": "未登录",
"visitor": "游客",
"objectsSelected": "{{num}} 个对象",
"searchPlaceholder": "搜索...",
"searchInFiles": "在我的文件中搜索 <0>{{name}}</0>",
"searchInFolders": "在当前目录中搜索 <0>{{name}}</0>",
"searchInShares": "在全站分享中搜索 <0>{{name}}</0>",
"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></1>",
"musicPlayer": "音频播放",
"closeAndStop": "退出播放",
"playInBackground": "后台播放",
"copyTo": "复制到",
"copyToDst": "复制到 <0>{{dst}}</0>",
"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}}</0> 的新名称:",
"newName": "新名称",
"moveToTitle": "移动至",
"moveToDescription": "移动至 <0>{{name}}</0>",
"saveToTitle": "保存至",
"saveToTitleDescription": "保存至 <0>{{name}}</0>",
"deleteTitle": "删除对象",
"deleteOneDescription": "确定要删除 <0>{{name}}</0> 吗?",
"deleteMultipleDescription": "确定要删除这 {{num}} 个对象吗?",
"newRemoteDownloadTitle": "新建离线下载任务",
"remoteDownloadURL": "下载链接",
"remoteDownloadURLDescription": "输入文件下载地址,一行一个,支持 HTTP(s) / FTP / 磁力链",
"remoteDownloadDst": "下载至",
"remoteDownloadNode": "下载节点",
"remoteDownloadNodeAuto": "自动分配",
"createTask": "创建任务",
"downloadTo": "下载至 <0>{{name}}</0>",
"decompressTo": "解压缩至",
"decompressToDst": "解压缩至 <0>{{name}}</0>",
"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></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}}</0> 创建",
"sharedBy": "<0>{{nick}}</0> 向您分享了 {{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></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}}</0>",
"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></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": "直接打开支付链接"
}
}

@ -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": "管理员无法升级至其他用户组"
}
}

@ -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></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</0> 查阅。",
"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>官方文档</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>应用管理页面</0> 获取到的的 网站密钥",
"siteSecret": "Secret",
"siteSecretDes": "<0>应用管理页面</0> 获取到的的 秘钥",
"secretID": "SecretId",
"secretIDDes": "<0>访问密钥页面</0> 获取到的的 SecretId",
"secretKey": "SecretKey",
"secretKeyDes": "<0>访问密钥页面</0> 获取到的的 SecretKey",
"tCaptchaAppID": "APPID",
"tCaptchaAppIDDes": "<0>图形验证页面</0> 获取到的的 APPID",
"tCaptchaSecretKey": "App Secret Key",
"tCaptchaSecretKeyDes": "<0>图形验证页面</0> 获取到的的 App Secret Key",
"staticResourceCache": "静态公共资源缓存",
"staticResourceCacheDes": "公共可访问的静态资源(如:本机策略直链、文件下载链接)的缓存有效期",
"wopiClient": "WOPI 客户端",
"wopiClientDes": "通过对接支持 WOPI 协议的在线文档处理系统,扩展 Cloudreve 的文档在线预览和编辑能力。详情请参考 <0>官方文档</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>路径魔法变量列表</0>。",
"pathOfFolderToStoreFiles": "存储目录",
"filePathMagicVarDes": "是否需要对存储的物理文件进行重命名?此处的重命名不会影响最终呈现给用户的 文件名。文件名也可使用魔法变量, 可用魔法变量可参考 <0>文件名魔法变量列表</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</0> 文件,填入从机配置,启动/重启从机端 Cloudreve。以下为一个可供参考的配置例子其中密钥部分已帮您填写为上一步所生成的。",
"remoteConfigDifference": "从机端配置文件格式大致与主站端相同,区别在于:",
"remoteConfigDifference1": "<0>System</0> 分区下的 <1>mode</1> 字段必须更改为 <2>slave</2>。",
"remoteConfigDifference2": "必须指定 <0>Slave</0> 分区下的 <1>Secret</1> 字段,其值为第二步里填写或生成的密钥。",
"remoteConfigDifference3": "必须启动跨域配置,即 <0>CORS</0> 字段的内容,具体可参考上文范例或官方文档。如果配置不正确,用户将无法通过 Web 端向从机上传文件。",
"inputRemoteAddress": "填写从机地址。",
"inputRemoteAddressDes": "如果主站启用了 HTTPS从机也需要启用并在下方填入 HTTPS 协议的地址。",
"remoteAddress": "从机地址",
"testCommunicationDes": "完成以上步骤后,你可以点击下方的测试按钮测试通信是否正常。",
"testCommunication": "测试从机通信",
"pathMagicVarDesRemote": "请在下方输入文件的存储目录路径,可以为绝对路径或相对路径(相对于 从机的 Cloudreve。路径中可以使用魔法变量文件在上传时会自动替换这些变量为相应值 可用魔法变量可参考 <0>路径魔法变量列表</0>。",
"storageBucket": "存储空间",
"editQiniuStoragePolicy": "修改七牛存储策略",
"addQiniuStoragePolicy": "添加七牛存储策略",
"wanSiteURLDes": "在使用此存储策略前,请确保您在 参数设置 - 站点信息 - 站点URL 中填写的 地址与实际相符,并且 <0>能够被外网正常访问</0>。",
"createQiniuBucket": "前往 <0>七牛控制面板</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 管理控制台</0> 创建 Bucket。注意创建空间类型只能选择 <1>标准存储</1> 或 <2>低频访问</2>,暂不支持 <3>归档存储</3>。",
"ossBucketNameDes": "在下方填写您创建 Bucket 时指定的 <0>Bucket 名称</0>",
"bucketName": "Bucket 名称",
"publicReadBucket": "公共读",
"ossEndpointDes": "转到所创建 Bucket 的概览页面,填写 <0>访问域名</0> 栏目下 <1>外网访问</1> 一行中间的 <2>EndPoint地域节点</2>。",
"endpoint": "EndPoint",
"endpointDomainOnly": "格式不合法,只需输入域名部分即可",
"ossLANEndpointDes": "如果您的 Cloudreve 部署在阿里云计算服务中,并且与 OSS 处在同一可用区下,您可以额外指定使用内网 EndPoint 以节省流量开支。是否要在服务端发送请求时使用 OSS 内网 EndPoint",
"intranetEndPoint": "内网 EndPoint",
"ossCDNDes": "是否要使用配套的 阿里云CDN 加速 OSS 访问?",
"createOSSCDNDes": "前往 <0>阿里云 CDN 管理控制台</0> 创建 CDN 加速域名,并设定源站为刚创建的 OSS Bucket。在下方填写 CDN 加速域名,并选择是否使用 HTTPS",
"ossAKDes": "在阿里云 <0>安全信息管理</0> 页面获取 用户 AccessKey并填写在下方。",
"shouldNotContainSpace": "不能含有空格",
"nameThePolicyFirst": "为此存储策略命名:",
"chunkSizeLabelOSS": "请指定分片上传时的分片大小,范围 100 KB ~ 5 GB。",
"ossCORSDes": "此存储策略需要正确配置跨域策略后才能使用 Web 端上传文件Cloudreve 可以帮您自动设置,您也可以参考文档步骤手动设置。如果您已设置过此 Bucket 的跨域策略,此步骤可以跳过。",
"letCloudreveHelpMe": "让 Cloudreve 帮我设置",
"skip": "跳过",
"editUpyunStoragePolicy": "修改又拍云存储策略",
"addUpyunStoragePolicy": "添加又拍云存储策略",
"createUpyunBucketDes": "前往 <0>又拍云面板</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 管理控制台</0> 创建存储桶。",
"cosBucketNameDes": "转到所创建存储桶的基础配置页面,将 <0>空间名称</0> 填写在下方:",
"cosBucketFormatError": "空间名格式不正确, 举例ccc-1252109809",
"cosBucketTypeDes": "在下方选择您创建的空间的访问权限类型,推荐选择 <0>私有读写</0> 以获得更高的安全性,私有空间无法开启“获取直链”功能。",
"cosPrivateRW": "私有读写",
"cosPublicRW": "公共读私有写",
"cosAccessDomainDes": "转到所创建 Bucket 的基础配置,填写 <0>基本信息</0> 栏目下 给出的 <1>访问域名</1>。",
"accessDomain": "访问域名",
"cosCDNDes": "是否要使用配套的 腾讯云CDN 加速 COS 访问?",
"cosCDNDomainDes": "前往 <0>腾讯云 CDN 管理控制台</0> 创建 CDN 加速域名,并设定源站为刚创建的 COS 存储桶。在下方填写 CDN 加速域名,并选择是否使用 HTTPS",
"cosCredentialDes": "在腾讯云 <0>访问密钥</0> 页面获取一对访问密钥,并填写在下方。请确保这对密钥拥有 COS 和 SCF 服务的访问权限。",
"secretId": "SecretId",
"secretKey": "SecretKey",
"cosCallbackDes": "COS 存储桶 客户端直传需要借助腾讯云的 <0>云函数</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 控制台 (国际版账号)</0> 或者 <1>Azure Active Directory 控制台 (世纪互联账号)</1> 并登录,登录后进入<2>Azure Active Directory</2> 管理面板,这里登录使用的账号和最终存储使用的 OneDrive 所属账号可以不同。",
"createAadAppDes2": "进入左侧 <0>应用注册</0> 菜单,并点击 <1>新注册</1> 按钮。",
"createAadAppDes3": "填写应用注册表单。其中,名称可任取;<0>受支持的帐户类型</0> 选择为 <1>任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如Skype、Xbox)</1><2>重定向 URI (可选)</2> 请选择 <3>Web</3>,并填写 <4>{{url}}</4> 其他保持默认即可",
"aadAppIDDes": "创建完成后进入应用管理的 <0>概览</0> 页面,复制 <1>应用程序(客户端) ID</1> 并填写在下方:",
"aadAppID": "应用程序(客户端) ID",
"addAppSecretDes": "进入应用管理页面左侧的 <0>证书和密码</0> 菜单,点击 <1>新建客户端密码</1> 按钮,<2>截止期限</2> 选择为 <3>从不</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 名称</0>",
"publicAccessDisabled": "阻止全部公共访问权限",
"publicAccessEnabled": "允许公共读取",
"s3EndpointDes": "(可选) 指定存储桶的 EndPoint地域节点填写为完整的 URL 格式,比如 <0>https://bucket.region.example.com</0>。留空则将使用系统生成的默认接入点。",
"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</0>。",
"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>如果你已经在目标服务器上部署了从机存储策略,您可以跳过本页面的某些步骤,只将从机密钥、服务器地址在这里填写并保持与从机存储策略中一致即可。</0> 在后续版本中,从机存储策略的相关配置会合并到这里。",
"overwriteDes": "; 以下为可选的设置,对应主机节点的相关参数,可以通过配置文件应用到从机节点,请根据<0></0>; 实际情况调整。更改下面设置需要重启从机节点后生效。",
"workerNumDes": "任务队列最多并行执行的任务数",
"parallelTransferDes": "任务队列中转任务传输时,最大并行协程数",
"chunkRetriesDes": "中转分片上传失败后重试的最大次数",
"multipleMasterDes": "一个从机 Cloudreve 实例可以对接多个 Cloudreve 主节点,只需在所有主节点中添加此从机节点并保持密钥一致即可。",
"ariaSuccess": "连接成功Aria2 版本为:{{version}}",
"slave": "从机",
"master": "主机",
"aria2Des": "Cloudreve 的离线下载功能由 <0>Aria2</0> 驱动。如需使用,请在目标节点服务器上以和运行 Cloudreve 相同的用户身份启动 Aria2 并在 Aria2 的配置文件中开启 RPC 服务,<1>Aria2 需要和{{mode}} Cloudreve 进程共用相同的文件系统。</1> 更多信息及指引请参考文档的 <2>离线下载</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/</0>,其中端口号 <1>6800</1> 与上文配置文件中 <2>rpc-listen-port</2>保持一致。",
"rpcServer": "RPC 服务器地址",
"rpcServerHelpDes": "包含端口的完整 RPC 服务器地址例如http://127.0.0.1:6800/,留空表示不启用 Aria2 服务",
"rpcTokenDes": "RPC 授权令牌,与 Aria2 配置文件中 <0>rpc-secret</0> 保持一致,未设置请留空。",
"aria2PathDes": "在下方填写 Aria2 用作临时下载目录的 节点上的 <0>绝对路径</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>比较存储策略</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>点击这里</0> 编辑主节点;",
"slaveAria2Des": "如果您想要在从机节点上分散处理离线下载任务,请 <0>点击这里</0> 添加并配置新节点。",
"editGroupDes": "当你添加多个可用于离线下载的节点后,主节点会将离线下载请求轮流发送到这些节点处理。节点离线下载配置完成后,您可能还需要 <0>到这里</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 密钥</0>。",
"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</0> 提供, 产生的任何纠纷与 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>官方文档</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>授权管理面板</0> 购买。VOL 授权允许您的用户免费使用 <1>Cloudreve iOS 客户端</1> 连接到您的站点,无需用户再付费订阅 iOS 客户端。购买授权后请点击下方同步授权。",
"mobileApp": "移动客户端",
"volSynced": "VOL 授权已同步"
}
}

@ -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>*</0> 作為通用。比如 <1>*.png</1> 表示對應 png 格式圖像。多行規則間會以「或」的關係運算。",
"icon": "圖示:",
"color": "顏色:",
"folderPath": "資料夾路徑"
},
"storage": "儲存空間",
"storageDetail": "已使用 {{used}}, 共 {{total}}",
"notLoginIn": "未登入",
"visitor": "遊客",
"objectsSelected": "{{num}} 個對象",
"searchPlaceholder": "搜尋...",
"searchInFiles": "在我的文件中搜尋 <0>{{name}}</0>",
"searchInFolders": "在當前目錄中搜尋 <0>{{name}}</0>",
"searchInShares": "在全站分享中搜尋 <0>{{name}}</0>",
"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></1>",
"musicPlayer": "音訊播放",
"closeAndStop": "退出播放",
"playInBackground": "後台播放",
"copyTo": "複製到",
"copyToDst": "複製到 <0>{{dst}}</0>",
"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}}</0> 的新名稱:",
"newName": "新名稱",
"moveToTitle": "移動至",
"moveToDescription": "移動至 <0>{{name}}</0>",
"saveToTitle": "儲存至",
"saveToTitleDescription": "儲存至 <0>{{name}}</0>",
"deleteTitle": "刪除對象",
"deleteOneDescription": "確定要刪除 <0>{{name}}</0> 嗎?",
"deleteMultipleDescription": "確定要刪除這 {{num}} 個對象嗎?",
"newRemoteDownloadTitle": "新增離線下載任務",
"remoteDownloadURL": "下載連結",
"remoteDownloadURLDescription": "輸入文件下載網址,一行一個,支援 HTTP(s) / FTP / 磁力鏈",
"remoteDownloadDst": "下載至",
"createTask": "建立任務",
"downloadTo": "下載至 <0>{{name}}</0>",
"decompressTo": "解壓縮至",
"decompressToDst": "解壓縮至 <0>{{name}}</0>",
"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></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}}</0> 建立",
"sharedBy": "<0>{{nick}}</0> 向您分享了 {{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></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}}</0>",
"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></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": "直接打開支付鏈接"
}
}

@ -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": "管理員無法升級至其他用戶組"
}
}

@ -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></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</0> 查閱。",
"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>官方文件</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>應用管理頁面</0> 獲取到的的 網站金鑰",
"siteSecret": "Secret",
"siteSecretDes": "<0>應用管理頁面</0> 獲取到的的 秘鑰",
"secretID": "SecretId",
"secretIDDes": "<0>訪問金鑰頁面</0> 獲取到的的 SecretId",
"secretKey": "SecretKey",
"secretKeyDes": "<0>訪問金鑰頁面</0> 獲取到的的 SecretKey",
"tCaptchaAppID": "APPID",
"tCaptchaAppIDDes": "<0>圖形驗證頁面</0> 獲取到的的 APPID",
"tCaptchaSecretKey": "App Secret Key",
"tCaptchaSecretKeyDes": "<0>圖形驗證頁面</0> 獲取到的的 App Secret Key",
"staticResourceCache": "靜態公共資源快取",
"staticResourceCacheDes": "公共可訪問的靜態資源(如:本機策略直鏈、文件下載連接)的快取有效期",
"wopiClient": "WOPI 客戶端",
"wopiClientDes": "透過對接支援 WOPI 協議的線上文件處理系統,擴展 Cloudreve 的文件線上預覽和編輯能力。詳情請參考 <0>官方文件</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>路徑魔法變數列表</0>。",
"pathOfFolderToStoreFiles": "儲存目錄",
"filePathMagicVarDes": "是否需要對儲存的物理文件進行重新命名?此處的重新命名不會影響最終呈現給用戶的 檔案名。檔案名也可使用魔法變數, 可用魔法變數可參考 <0>檔案名魔法變數列表</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</0> 文件,填入從機配置,啟動/重啟從機端 Cloudreve。以下為一個可供參考的配置例子其中金鑰部分已幫您填寫為上一步所生成的。",
"remoteConfigDifference": "從機端配置檔案格式大致與主站端相同,區別在於:",
"remoteConfigDifference1": "<0>System</0> 分區下的 <1>mode</1> 欄位必須更改為 <2>slave</2>。",
"remoteConfigDifference2": "必須指定 <0>Slave</0> 分區下的 <1>Secret</1> 欄位,其值為第二步裡填寫或生成的金鑰。",
"remoteConfigDifference3": "必須啟動跨域配置,即 <0>CORS</0> 欄位的內容,具體可參考上文範例或官方文件。如果配置不正確,用戶將無法透過 Web 端向從機上傳文件。",
"inputRemoteAddress": "填寫從機地址。",
"inputRemoteAddressDes": "如果主站啟用了 HTTPS從機也需要啟用並在下方填入 HTTPS 協議的地址。",
"remoteAddress": "從機地址",
"testCommunicationDes": "完成以上步驟後,你可以點擊下方的測試按鈕測試通信是否正常。",
"testCommunication": "測試從機通信",
"pathMagicVarDesRemote": "請在下方輸入文件的儲存目錄路徑,可以為絕對路徑或相對路徑(相對於 從機的 Cloudreve。路徑中可以使用魔法變數文件在上傳時會自動替換這些變數為相應值 可用魔法變數可參考 <0>路徑魔法變數列表</0>。",
"storageBucket": "儲存空間",
"editQiniuStoragePolicy": "修改七牛儲存策略",
"addQiniuStoragePolicy": "新增七牛儲存策略",
"wanSiteURLDes": "在使用此儲存策略前,請確保您在 參數設定 - 站點訊息 - 站點URL 中填寫的 地址與實際相符,並且 <0>能夠被外網正常訪問</0>。",
"createQiniuBucket": "前往 <0>七牛控制面板</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 管理控制台</0> 建立 Bucket。注意建立空間類型只能選擇 <1>標準儲存</1> 或 <2>低頻訪問</2>,暫不支援 <3>歸檔儲存</3>。",
"ossBucketNameDes": "在下方填寫您建立 Bucket 時指定的 <0>Bucket 名稱</0>",
"bucketName": "Bucket 名稱",
"publicReadBucket": "公共讀",
"ossEndpointDes": "轉到所建立 Bucket 的概覽頁面,填寫 <0>訪問域名</0> 欄目下 <1>外網訪問</1> 一行中間的 <2>EndPoint地域節點</2>。",
"endpoint": "EndPoint",
"endpointDomainOnly": "格式不合法,只需輸入域名部分即可",
"ossLANEndpointDes": "如果您的 Cloudreve 部署在阿里雲端計算服務中,並且與 OSS 處在同一可用區下,您可以額外指定使用內網 EndPoint 以節省流量開支。是否要在服務端發送請求時使用 OSS 內網 EndPoint",
"intranetEndPoint": "內網 EndPoint",
"ossCDNDes": "是否要使用配套的 阿里雲CDN 加速 OSS 訪問?",
"createOSSCDNDes": "前往 <0>阿里雲 CDN 管理控制台</0> 建立 CDN 加速域名,並設定源站為剛建立的 OSS Bucket。在下方填寫 CDN 加速域名,並選擇是否使用 HTTPS",
"ossAKDes": "在阿里雲 <0>安全訊息管理</0> 頁面獲取 用戶 AccessKey並填寫在下方。",
"shouldNotContainSpace": "不能含有空格",
"nameThePolicyFirst": "為此儲存策略命名:",
"chunkSizeLabelOSS": "請指定分片上傳時的分片大小,範圍 100 KB ~ 5 GB。",
"ossCORSDes": "此儲存策略需要正確配置跨域策略後才能使用 Web 端上傳文件Cloudreve 可以幫您自動設定,您也可以參考文件步驟手動設定。如果您已設定過此 Bucket 的跨域策略,此步驟可以跳過。",
"letCloudreveHelpMe": "讓 Cloudreve 幫我設定",
"skip": "跳過",
"editUpyunStoragePolicy": "修改又拍雲端儲存策略",
"addUpyunStoragePolicy": "新增又拍雲端儲存策略",
"createUpyunBucketDes": "前往 <0>又拍雲面板</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 管理控制台</0> 建立儲存桶。",
"cosBucketNameDes": "轉到所建立儲存桶的基礎配置頁面,將 <0>空間名稱</0> 填寫在下方:",
"cosBucketFormatError": "空間名格式不正確, 舉例ccc-1252109809",
"cosBucketTypeDes": "在下方選擇您建立的空間的訪問權限類型,推薦選擇 <0>私有讀寫</0> 以獲得更高的安全性,私有空間無法開啟「獲取直鏈」功能。",
"cosPrivateRW": "私有讀寫",
"cosPublicRW": "公共讀私有寫",
"cosAccessDomainDes": "轉到所建立 Bucket 的基礎配置,填寫 <0>基本訊息</0> 欄目下 給出的 <1>訪問域名</1>。",
"accessDomain": "訪問域名",
"cosCDNDes": "是否要使用配套的 騰訊雲CDN 加速 COS 訪問?",
"cosCDNDomainDes": "前往 <0>騰訊雲 CDN 管理控制台</0> 建立 CDN 加速域名,並設定源站為剛建立的 COS 儲存桶。在下方填寫 CDN 加速域名,並選擇是否使用 HTTPS",
"cosCredentialDes": "在騰訊雲 <0>訪問金鑰</0> 頁面獲取一對訪問金鑰,並填寫在下方。請確保這對金鑰擁有 COS 和 SCF 服務的訪問權限。",
"secretId": "SecretId",
"secretKey": "SecretKey",
"cosCallbackDes": "COS 儲存桶 用戶端直傳需要借助騰訊雲的 <0>雲函數</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 控制台 (國際版帳號)</0> 或者 <1>Azure Active Directory 控制台 (世紀互聯帳號)</1> 並登入,登入後進入<2>Azure Active Directory</2> 管理面板,這裡登入使用的帳號和最終儲存使用的 OneDrive 所屬帳號可以不同。",
"createAadAppDes2": "進入左側 <0>應用註冊</0> 選單,並點擊 <1>新註冊</1> 按鈕。",
"createAadAppDes3": "填寫應用註冊表單。其中,名稱可任取;<0>受支援的帳戶類型</0> 選擇為 <1>任何組織目錄(任何 Azure AD 目錄 - 多租戶)中的帳戶和個人 Microsoft 帳戶(例如Skype、Xbox)</1><2>重定向 URI (可選)</2> 請選擇 <3>Web</3>,並填寫 <4>{{url}}</4> 其他保持預設即可",
"aadAppIDDes": "建立完成後進入應用管理的 <0>概覽</0> 頁面,複製 <1>應用程式(用戶端) ID</1> 並填寫在下方:",
"aadAppID": "應用程式(用戶端) ID",
"addAppSecretDes": "進入應用管理頁面左側的 <0>證書和密碼</0> 選單,點擊 <1>新增用戶端密碼</1> 按鈕,<2>截止期限</2> 選擇為 <3>從不</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 名稱</0>",
"publicAccessDisabled": "阻止全部公共訪問權限",
"publicAccessEnabled": "允許公共讀取",
"s3EndpointDes": "(可選) 指定儲存桶的 EndPoint地域節點填寫為完整的 URL 格式,比如 <0>https://bucket.region.example.com</0>。留空則將使用系統生成的預設接入點。",
"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</0>。",
"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>如果你已經在目標伺服器上部署了從機儲存策略,您可以跳過本頁面的某些步驟,只將從機密鑰、伺服器地址在這裡填寫並保持與從機儲存策略中一致即可。</0> 在後續版本中,從機儲存策略的相關配置會合併到這裡。",
"overwriteDes": "; 以下為可選的設定,對應主機節點的相關參數,可以透過配置文件應用到從機節點,請根據<0></0>; 實際情況調整。更改下面設定需要重啟從機節點後生效。",
"workerNumDes": "任務隊列最多並行執行的任務數",
"parallelTransferDes": "任務隊列中轉任務傳輸時,最大並行協程數",
"chunkRetriesDes": "中轉分片上傳失敗後重試的最大次數",
"multipleMasterDes": "一個從機 Cloudreve 實例可以對接多個 Cloudreve 主節點,只需在所有主節點中新增此從機節點並保持金鑰一致即可。",
"ariaSuccess": "連接成功Aria2 版本為:{{version}}",
"slave": "從機",
"master": "主機",
"aria2Des": "Cloudreve 的離線下載功能由 <0>Aria2</0> 驅動。如需使用,請在目標節點伺服器上以和執行 Cloudreve 相同的用戶身份啟動 Aria2 並在 Aria2 的配置文件中開啟 RPC 服務,<1>Aria2 需要和{{mode}} Cloudreve 進程共用相同的文件系統。</1> 更多訊息及指引請參考文件的 <2>離線下載</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/</0>,其中埠號 <1>6800</1> 與上文配置文件中 <2>rpc-listen-port</2>保持一致。",
"rpcServer": "RPC 伺服器地址",
"rpcServerHelpDes": "包含埠的完整 RPC 伺服器地址例如http://127.0.0.1:6800/,留空表示不啟用 Aria2 服務",
"rpcTokenDes": "RPC 授權令牌,與 Aria2 配置文件中 <0>rpc-secret</0> 保持一致,未設定請留空。",
"aria2PathDes": "在下方填寫 Aria2 用作臨時下載目錄的 節點上的 <0>絕對路徑</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>比較儲存策略</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>點擊這裡</0> 編輯主節點;",
"slaveAria2Des": "如果您想要在從機節點上分散處理離線下載任務,請 <0>點擊這裡</0> 新增並配置新節點。",
"editGroupDes": "當你新增多個可用於離線下載的節點後,主節點會將離線下載請求輪流發送到這些節點處理。節點離線下載配置完成後,您可能還需要 <0>到這裡</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 密鑰</0>。",
"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</0> 提供, 產生的任何糾紛與 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>授權管理面板</0> 購買。VOL 授權允許您的用戶免費使用 <1>Cloudreve iOS 客戶端</1> 連接到您的站點,無需用戶再付費訂閱 iOS 客戶端。購買授權後請點擊下方同步授權。",
"iosVol": "iOS 客戶端批量授權 (VOL)",
"mobileApp": "移動客戶端",
"syncLicense": "同步授權",
"volSynced": "VOL 授權已同步",
"showAppPromotion": "展示客戶端引導頁面",
"showAppPromotionDes": "開啓後,用戶可以在 “連接與掛載” 頁面中看到移動客戶端的使用引導",
"customPayment": "自定義付款渠道",
"customPaymentDes":"通過實現 Cloudreve 兼容付款接口來對接其他第三方支付平臺,詳情請參考 <0>官方文檔</0>。",
"customPaymentDocumentLink":"https://docs.cloudreve.org/use/pro/pay",
"customPaymentName": "付款方式名稱",
"customPaymentNameDes": "用於展示給用戶的付款方式名稱",
"customPaymentSecretDes": "Cloudreve 用於簽名付款請求的密鑰",
"customPaymentEndpoint":"支付接口地址",
"customPaymentEndpointDes":"創建支付訂單時請求的接口 URL",
"appFeedback": "反饋頁面 URL",
"appForum": "用戶論壇 URL",
"appLinkDes": "用於在 App 設置頁面展示,留空即不展示鏈接按鈕,僅當 VOL 授權有效時此項設置纔會生效。"
}
}

@ -0,0 +1,46 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

BIN
public/static/img/cos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
public/static/img/local.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
public/static/img/oss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
public/static/img/qiniu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/static/img/s3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/static/img/upyun.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

199
scripts/build.js Normal file

@ -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,
});
}

147
scripts/start.js Normal file

@ -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);
});

53
scripts/test.js Normal file

@ -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);

233
src/Admin.js Normal file

@ -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 (
<React.Fragment>
<ThemeProvider theme={theme}>
<div className={classes.root}>
<CssBaseline />
<AlertBar />
{show && (
<Dashboard
content={(path) => (
<Switch>
<Route path={`${path}/home`} exact>
<Index />
</Route>
<Route path={`${path}/basic`}>
<SiteInformation />
</Route>
<Route path={`${path}/access`}>
<Access />
</Route>
<Route path={`${path}/mail`}>
<Mail />
</Route>
<Route path={`${path}/upload`}>
<UploadDownload />
</Route>
<Route path={`${path}/vas`}>
<VAS />
</Route>
<Route path={`${path}/theme`}>
<Theme />
</Route>
<Route path={`${path}/image`}>
<ImageSetting />
</Route>
<Route path={`${path}/captcha`}>
<Captcha />
</Route>
<Route path={`${path}/policy`} exact>
<Policy />
</Route>
<Route
path={`${path}/policy/add/:type`}
exact
>
<AddPolicy />
</Route>
<Route
path={`${path}/policy/edit/:mode/:id`}
exact
>
<EditPolicyPreload />
</Route>
<Route path={`${path}/group`} exact>
<Group />
</Route>
<Route path={`${path}/group/add`} exact>
<GroupForm />
</Route>
<Route
path={`${path}/group/edit/:id`}
exact
>
<EditGroupPreload />
</Route>
<Route path={`${path}/user`} exact>
<User />
</Route>
<Route path={`${path}/user/add`} exact>
<UserForm />
</Route>
<Route path={`${path}/user/edit/:id`} exact>
<EditUserPreload />
</Route>
<Route path={`${path}/file`} exact>
<File />
</Route>
<Route path={`${path}/file/import`} exact>
<Import />
</Route>
<Route path={`${path}/share`} exact>
<Share />
</Route>
<Route path={`${path}/order`} exact>
<Order />
</Route>
<Route path={`${path}/download`} exact>
<Download />
</Route>
<Route path={`${path}/task`} exact>
<Task />
</Route>
<Route path={`${path}/report`} exact>
<ReportList />
</Route>
<Route path={`${path}/node`} exact>
<Node />
</Route>
<Route path={`${path}/node/add`} exact>
<AddNode />
</Route>
<Route path={`${path}/node/edit/:id`} exact>
<EditNode />
</Route>
</Switch>
)}
/>
)}
</div>
</ThemeProvider>
</React.Fragment>
);
}

280
src/App.js Normal file

@ -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 (
<React.Fragment>
<ThemeProvider theme={theme}>
<div className={classes.root} id="container">
<CssBaseline />
<AlertBar />
<Navbar />
<main className={classes.content}>
<div className={classes.toolbar} />
<Switch>
<AuthRoute exact path={path} isLogin={isLogin}>
<Redirect
to={{
pathname: "/home",
}}
/>
</AuthRoute>
<AuthRoute path={`${path}home`} isLogin={isLogin}>
<>
<SiteNotice />
<FileManager />
</>
</AuthRoute>
<AuthRoute path={`${path}video`} isLogin={isLogin}>
<VideoPreview />
</AuthRoute>
<AuthRoute path={`${path}text`} isLogin={isLogin}>
<TextViewer />
</AuthRoute>
<AuthRoute path={`${path}doc`} isLogin={isLogin}>
<DocViewer />
</AuthRoute>
<AuthRoute path={`${path}pdf`} isLogin={isLogin}>
<Suspense fallback={<PageLoading />}>
<PDFViewer />
</Suspense>
</AuthRoute>
<AuthRoute path={`${path}code`} isLogin={isLogin}>
<CodeViewer />
</AuthRoute>
<AuthRoute path={`${path}epub`} isLogin={isLogin}>
<EpubViewer />
</AuthRoute>
<AuthRoute path={`${path}aria2`} isLogin={isLogin}>
<Download />
</AuthRoute>
<AuthRoute path={`${path}shares`} isLogin={isLogin}>
<MyShare />
</AuthRoute>
<Route path={`${path}search`} isLogin={isLogin}>
<SearchResult />
</Route>
<AuthRoute path={`${path}quota`} isLogin={isLogin}>
<Quota />
</AuthRoute>
<AuthRoute path={`${path}buy`} isLogin={isLogin}>
<BuyQuota />
</AuthRoute>
<AuthRoute
path={`${path}setting`}
isLogin={isLogin}
>
<UserSetting />
</AuthRoute>
<AuthRoute
path={`${path}profile/:id`}
isLogin={isLogin}
>
<Profile />
</AuthRoute>
<AuthRoute
path={`${path}connect`}
isLogin={isLogin}
>
<WebDAV />
</AuthRoute>
<AuthRoute path={`${path}tasks`} isLogin={isLogin}>
<Tasks />
</AuthRoute>
<NoAuthRoute
exact
path={`${path}login`}
isLogin={isLogin}
>
<LoginForm />
</NoAuthRoute>
<NoAuthRoute
exact
path={`${path}signup`}
isLogin={isLogin}
>
<Register />
</NoAuthRoute>
<Route path={`${path}activate`} exact>
<Activation />
</Route>
<Route path={`${path}reset`} exact>
<ResetForm />
</Route>
<Route path={`${path}forget`} exact>
<Reset />
</Route>
<Route path={`${path}login/qq`}>
<QQCallback />
</Route>
<Route exact path={`${path}s/:id`}>
<SharePreload />
</Route>
<Route path={`${path}s/:id/video(/)*`}>
<VideoPreview />
</Route>
<Route path={`${path}s/:id/doc(/)*`}>
<DocViewer />
</Route>
<Route path={`${path}s/:id/text(/)*`}>
<TextViewer />
</Route>
<Route path={`${path}s/:id/pdf(/)*`}>
<Suspense fallback={<PageLoading />}>
<PDFViewer />
</Suspense>
</Route>
<Route path={`${path}s/:id/code(/)*`}>
<CodeViewer />
</Route>
<Route path={`${path}s/:id/epub(/)*`}>
<EpubViewer />
</Route>
<Route path="*">
<NotFound
msg={t("pageNotFound", { ns: "common" })}
/>
</Route>
</Switch>
</main>
<MusicPlayer />
</div>
</ThemeProvider>
</React.Fragment>
);
}

9
src/App.test.js Normal file

@ -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(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

@ -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 (
<FormControl>
<InputLabel htmlFor="component-helper">{label}</InputLabel>
<Input
error={error !== ""}
value={domain}
onChange={(e) => {
setDomain(e.target.value);
onChange({
target: {
value: protocol + e.target.value,
},
});
}}
required={required}
startAdornment={
<InputAdornment position="start">
<Select
value={protocol}
onChange={(e) => {
setProtocol(e.target.value);
onChange({
target: {
value: e.target.value + domain,
},
});
}}
>
<MenuItem value={"http://"}>http://</MenuItem>
<MenuItem value={"https://"}>https://</MenuItem>
</Select>
</InputAdornment>
}
/>
{error !== "" && (
<FormHelperText error={error !== ""}>{error}</FormHelperText>
)}
</FormControl>
);
}

@ -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 (
<Autocomplete
multiple
style={{ width: 300 }}
options={options}
getOptionLabel={(option) =>
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) => (
<TextField {...params} label={label} type={"number"} />
)}
renderOption={(option) => (
<Typography noWrap>{option.Name}</Typography>
)}
/>
);
}

@ -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 (
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">{label}</InputLabel>
<Select
labelId="demo-mutiple-chip-label"
id="demo-mutiple-chip"
multiple
value={value}
onChange={onChange}
input={<Input id="select-multiple-chip" />}
renderValue={(selected) => (
<div>
{selected.map((value) => (
<Chip
style={{
margin: 2,
}}
key={value}
size={"small"}
label={policies[value]}
/>
))}
</div>
)}
>
{Object.keys(policies).map((pid) => (
<MenuItem
key={pid}
value={pid}
style={getSelectItemStyles(pid, value, theme)}
>
{policies[pid]}
</MenuItem>
))}
</Select>
<FormHelperText id="component-helper-text">
{helperText}
</FormHelperText>
</FormControl>
);
}

@ -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 (
<FormControl error={err !== ""}>
<InputLabel htmlFor="component-helper">{label}</InputLabel>
<Input
style={{ width: 200 }}
value={val}
type={"number"}
inputProps={{ step: 1 }}
onChange={(e) => setVal(e.target.value)}
required={required}
endAdornment={
<InputAdornment position="end">
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={unit}
onChange={(e) => setUnit(e.target.value)}
>
<MenuItem value={1}>B{suffix && suffix}</MenuItem>
<MenuItem value={1024}>
KB{suffix && suffix}
</MenuItem>
<MenuItem value={1024 * 1024}>
MB{suffix && suffix}
</MenuItem>
<MenuItem value={1024 * 1024 * 1024}>
GB{suffix && suffix}
</MenuItem>
<MenuItem value={1024 * 1024 * 1024 * 1024}>
TB{suffix && suffix}
</MenuItem>
</Select>
</InputAdornment>
}
/>
{err !== "" && <FormHelperText>{err}</FormHelperText>}
</FormControl>
);
}

@ -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: <Home />,
path: "home",
},
{
title: "nav.settings",
icon: <Settings />,
sub: [
{
title: "nav.basicSetting",
path: "basic",
icon: <Language />,
},
{
title: "nav.publicAccess",
path: "access",
icon: <Contacts />,
},
{
title: "nav.email",
path: "mail",
icon: <Mail />,
},
{
title: "nav.transportation",
path: "upload",
icon: <SettingsEthernet />,
},
{
title: "vas.vas",
path: "vas",
icon: <AttachMoney />,
},
{
title: "nav.appearance",
path: "theme",
icon: <Palette />,
},
{
title: "nav.image",
path: "image",
icon: <Image />,
},
{
title: "nav.captcha",
path: "captcha",
icon: <Category />,
},
],
},
{
title: "nav.storagePolicy",
icon: <Storage />,
path: "policy",
},
{
title: "nav.nodes",
icon: <Contactless />,
path: "node",
},
{
title: "nav.groups",
icon: <Group />,
path: "group",
},
{
title: "nav.users",
icon: <Person />,
path: "user",
},
{
title: "nav.files",
icon: <InsertDriveFile />,
path: "file",
},
{
title: "nav.shares",
icon: <Share />,
path: "share",
},
{
title: "vas.reports",
icon: <Report />,
path: "report",
},
{
title: "vas.orders",
icon: <ShoppingCart />,
path: "order",
},
{
title: "nav.tasks",
icon: <Assignment />,
sub: [
{
title: "nav.remoteDownload",
path: "download",
icon: <CloudDownload />,
},
{
title: "nav.generalTasks",
path: "task",
icon: <ListAlt />,
},
],
},
];
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 (
<div className={classes.root}>
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
className={clsx(classes.menuButton, {
[classes.hide]: open,
})}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title} noWrap>
{t("nav.dashboard")}
</Typography>
<UserAvatar />
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
className={clsx(classes.drawer, {
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
})}
classes={{
paper: clsx({
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
}),
}}
>
<div className={classes.toolbar}>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? (
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton>
</div>
<Divider />
<List className={classes.noPadding}>
{items.map((item) => {
if (item.path !== undefined) {
return (
<ListItem
onClick={() =>
history.push("/admin/" + item.path)
}
button
className={clsx({
[classes.active]: location.pathname.startsWith(
"/admin/" + item.path
),
})}
key={item.title}
>
<ListItemIcon
className={clsx({
[classes.activeIcon]: location.pathname.startsWith(
"/admin/" + item.path
),
})}
>
{item.icon}
</ListItemIcon>
<ListItemText
className={clsx({
[classes.activeText]: location.pathname.startsWith(
"/admin/" + item.path
),
})}
primary={t(item.title)}
/>
</ListItem>
);
}
return (
// eslint-disable-next-line react/jsx-key
<ExpansionPanel
key={item.title}
square
expanded={menuOpen === item.title}
onChange={(event, isExpanded) => {
setMenuOpen(isExpanded ? item.title : null);
}}
>
<ExpansionPanelSummary
aria-controls="panel1d-content"
id="panel1d-header"
>
<ListItem button key={item.title}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={t(item.title)} />
</ListItem>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<List className={classes.subMenu}>
{item.sub.map((sub) => (
<ListItem
onClick={() =>
history.push(
"/admin/" + sub.path
)
}
className={clsx({
[classes.sub]: open,
[classes.active]: location.pathname.startsWith(
"/admin/" + sub.path
),
})}
button
key={sub.title}
>
<ListItemIcon
className={clsx({
[classes.activeIcon]: location.pathname.startsWith(
"/admin/" + sub.path
),
})}
>
{sub.icon}
</ListItemIcon>
<ListItemText
primary={t(sub.title)}
/>
</ListItem>
))}
</List>
</ExpansionPanelDetails>
</ExpansionPanel>
);
})}
</List>
</Drawer>
<main className={classes.content}>
<div className={classes.toolbar} />
{content(path)}
</main>
</div>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth={"xs"}
scroll={"paper"}
>
<form onSubmit={submit}>
<DialogTitle id="alert-dialog-title">
{groupEdit ? t("editMembership") : t("addMembership")}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("name")}
</InputLabel>
<Input
value={group.name}
onChange={handleChange("name")}
required
/>
<FormHelperText id="component-helper-text">
{t("productNameDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("group")}
</InputLabel>
<Select
value={group.group_id}
onChange={handleChange("group_id")}
required
>
{groups.map((v) => {
if (v.ID !== 3) {
return (
<MenuItem value={v.ID}>
{v.Name}
</MenuItem>
);
}
return null;
})}
</Select>
<FormHelperText id="component-helper-text">
{t("groupDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("durationDay")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={group.time}
onChange={handleChange("time")}
required
/>
<FormHelperText id="component-helper-text">
{t("durationGroupDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("priceYuan")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 0.01,
step: 0.01,
}}
value={group.price}
onChange={handleChange("price")}
required
/>
<FormHelperText id="component-helper-text">
{t("groupPriceDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("priceCredits")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 0,
step: 1,
}}
value={group.score}
onChange={handleChange("score")}
required
/>
<FormHelperText id="component-helper-text">
{t("priceCreditsDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("productDescription")}
</InputLabel>
<Input
value={group.des}
onChange={handleChange("des")}
multiline
rowsMax={10}
required
/>
<FormHelperText id="component-helper-text">
{t("productDescriptionDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={group.highlight}
onChange={handleCheckChange(
"highlight"
)}
/>
}
label={t("highlight")}
/>
<FormHelperText id="component-helper-text">
{t("highlightDes")}
</FormHelperText>
</FormControl>
</div>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="default">
{tCommon("cancel")}
</Button>
<Button type={"submit"} color="primary">
{tCommon("ok")}
</Button>
</DialogActions>
</form>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth={"xs"}
>
<form onSubmit={submit}>
<DialogTitle id="alert-dialog-title">
{packEdit ? t("editStoragePack") : t("addStoragePack")}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("name")}
</InputLabel>
<Input
value={pack.name}
onChange={handleChange("name")}
required
/>
<FormHelperText id="component-helper-text">
{t("productNameDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<SizeInput
value={pack.size}
onChange={handleChange("size")}
min={1}
label={t("size")}
max={9223372036854775807}
required
/>
<FormHelperText id="component-helper-text">
{t("packSizeDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("durationDay")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={pack.time}
onChange={handleChange("time")}
required
/>
<FormHelperText id="component-helper-text">
{t("durationDayDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("priceYuan")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 0.01,
step: 0.01,
}}
value={pack.price}
onChange={handleChange("price")}
required
/>
<FormHelperText id="component-helper-text">
{t("packPriceDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("priceCredits")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 0,
step: 1,
}}
value={pack.score}
onChange={handleChange("score")}
required
/>
<FormHelperText id="component-helper-text">
{t("priceCreditsDes")}
</FormHelperText>
</FormControl>
</div>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="default">
{tCommon("cancel")}
</Button>
<Button type={"submit"} color="primary">
{tCommon("ok")}
</Button>
</DialogActions>
</form>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth={"sm"}
fullWidth
>
<DialogTitle id="alert-dialog-title">
{t("selectAStorageProvider")}
</DialogTitle>
<DialogContent dividers className={classes.bg}>
<Grid container spacing={2}>
{policies.map((v, index) => (
<Grid key={index} item sm={12} md={6}>
<Card className={classes.card}>
<CardActionArea
onClick={() => {
location.push(v.path);
onClose();
}}
className={classes.cardContainer}
>
<CardMedia
className={classes.cover}
image={"/static/img/" + v.img}
/>
<CardContent className={classes.content}>
<Typography
variant="subtitle1"
color="textSecondary"
>
{t(v.name)}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
</DialogContent>
<DialogActions className={classes.dialogFooter}>
<Button
onClick={() =>
window.open(t("comparesStoragePoliciesLink"))
}
color="primary"
>
{t("comparesStoragePolicies")}
</Button>
<Button onClick={onClose} color="primary">
{tCommon("cancel")}
</Button>
</DialogActions>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth={"xs"}
>
<form onSubmit={submit}>
<DialogTitle id="alert-dialog-title">
{t("generateGiftCode")}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("numberOfCodes")}
</InputLabel>
<Input
type={"number"}
inputProps={{
step: 1,
min: 1,
max: 100,
}}
value={input.num}
onChange={handleChange("num")}
required
/>
<FormHelperText id="component-helper-text">
{t("numberOfCodesDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("linkedProduct")}
</InputLabel>
<Select
value={input.id}
onChange={(e) => {
handleChange("id")(e);
}}
>
{products.map((v) => (
<MenuItem
key={v.id}
value={v.id}
data-type={"1"}
>
{v.name}
</MenuItem>
))}
<MenuItem value={0}>
{tApp("vas.credits")}
</MenuItem>
</Select>
</FormControl>
</div>
<div className={classes.formContainer}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("productQyt")}
</InputLabel>
<Input
type={"number"}
inputProps={{
step: 1,
min: 1,
}}
value={input.time}
onChange={handleChange("time")}
required
/>
<FormHelperText id="component-helper-text">
{t("productQytDes")}
</FormHelperText>
</FormControl>
</div>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
disabled={loading}
onClick={onClose}
color="default"
>
{tCommon("cancel")}
</Button>
<Button disabled={loading} type={"submit"} color="primary">
{tCommon("ok")}
</Button>
</DialogActions>
</form>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{title}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
<Typography>{msg}</Typography>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
{t("ok")}
</Button>
</DialogActions>
</Dialog>
);
}

@ -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 (
<Dialog open={open} onClose={onClose} fullWidth maxWidth={"md"}>
<DialogContent>
<Grid container>
<Grid spacing={2} md={8} xs={12} container>
<Grid md={6} xs={12} item>
<Typography variant="h6" gutterBottom>
{t("primaryColor")}
</Typography>
<TextField
value={theme.palette.primary.main}
onChange={(e) => {
setTheme({
...theme,
palette: {
...theme.palette,
primary: {
...theme.palette.primary,
main: e.target.value,
},
},
});
}}
fullWidth
/>
<div className={classes.picker}>
<CompactPicker
colors={[
"#4D4D4D",
"#999999",
"#FFFFFF",
"#f44336",
"#ff9800",
"#ffeb3b",
"#cddc39",
"#A4DD00",
"#00bcd4",
"#03a9f4",
"#AEA1FF",
"#FDA1FF",
"#333333",
"#808080",
"#cccccc",
"#ff5722",
"#ffc107",
"#FCC400",
"#8bc34a",
"#4caf50",
"#009688",
"#2196f3",
"#3f51b5",
"#e91e63",
"#000000",
"#666666",
"#B3B3B3",
"#9F0500",
"#C45100",
"#FB9E00",
"#808900",
"#194D33",
"#0C797D",
"#0062B1",
"#673ab7",
"#9c27b0",
]}
color={theme.palette.primary.main}
onChangeComplete={(c) => {
setTheme({
...theme,
palette: {
...theme.palette,
primary: {
...theme.palette.primary,
main: c.hex,
},
},
});
}}
/>
</div>
</Grid>
<Grid md={6} xs={12} item>
<Typography variant="h6" gutterBottom>
{t("secondaryColor")}
</Typography>
<TextField
value={theme.palette.secondary.main}
onChange={(e) => {
setTheme({
...theme,
palette: {
...theme.palette,
secondary: {
...theme.palette.secondary,
main: e.target.value,
},
},
});
}}
fullWidth
/>
<div className={classes.picker}>
<CompactPicker
colors={[
"#4D4D4D",
"#999999",
"#FFFFFF",
"#ff1744",
"#ff3d00",
"#ffeb3b",
"#cddc39",
"#A4DD00",
"#00bcd4",
"#00e5ff",
"#AEA1FF",
"#FDA1FF",
"#333333",
"#808080",
"#cccccc",
"#ff5722",
"#ffea00",
"#ffc400",
"#c6ff00",
"#00e676",
"#76ff03",
"#00b0ff",
"#2979ff",
"#f50057",
"#000000",
"#666666",
"#B3B3B3",
"#9F0500",
"#C45100",
"#FB9E00",
"#808900",
"#1de9b6",
"#0C797D",
"#3d5afe",
"#651fff",
"#d500f9",
]}
color={theme.palette.secondary.main}
onChangeComplete={(c) => {
setTheme({
...theme,
palette: {
...theme.palette,
secondary: {
...theme.palette.secondary,
main: c.hex,
},
},
});
}}
/>
</div>
</Grid>
<Grid md={6} xs={12} item>
<Typography variant="h6" gutterBottom>
{t("primaryColorText")}
</Typography>
<TextField
value={theme.palette.primary.contrastText}
onChange={(e) => {
setTheme({
...theme,
palette: {
...theme.palette,
primary: {
...theme.palette.primary,
contrastText: e.target.value,
},
},
});
}}
fullWidth
/>
<div className={classes.picker}>
<CompactPicker
color={theme.palette.primary.contrastText}
onChangeComplete={(c) => {
setTheme({
...theme,
palette: {
...theme.palette,
primary: {
...theme.palette.primary,
contrastText: c.hex,
},
},
});
}}
/>
</div>
</Grid>
<Grid md={6} xs={12} item>
<Typography variant="h6" gutterBottom>
{t("secondaryColorText")}
</Typography>
<TextField
value={theme.palette.secondary.contrastText}
onChange={(e) => {
setTheme({
...theme,
palette: {
...theme.palette,
secondary: {
...theme.palette.secondary,
contrastText: e.target.value,
},
},
});
}}
fullWidth
/>
<div className={classes.picker}>
<CompactPicker
color={theme.palette.secondary.contrastText}
onChangeComplete={(c) => {
setTheme({
...theme,
palette: {
...theme.palette,
secondary: {
...theme.palette.secondary,
contrastText: c.hex,
},
},
});
}}
/>
</div>
</Grid>
</Grid>
<Grid spacing={2} md={4} xs={12}>
<ThemeProvider theme={subTheme()}>
<div
className={classes.statusBar}
style={{
backgroundColor: subTheme().palette.primary
.dark,
}}
/>
<AppBar position="static">
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
color="inherit"
aria-label="menu"
>
<Menu />
</IconButton>
<Typography
variant="h6"
className={classes.title}
>
Color
</Typography>
</Toolbar>
</AppBar>
<div style={{ padding: 16 }}>
<TextField
fullWidth
color={"secondary"}
label={"Text input"}
/>
<div
className={classes.fab}
style={{ paddingTop: 64 }}
>
<Fab color="secondary" aria-label="add">
<Add />
</Fab>
</div>
</div>
</ThemeProvider>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="default">
{tGlobal("cancel")}
</Button>
<Button onClick={() => onSubmit(theme)} color="primary">
{tGlobal("ok")}
</Button>
</DialogActions>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
fullWidth
maxWidth={"xs"}
>
<DialogTitle id="alert-dialog-title">
{tDashboard("user.filterCondition")}
</DialogTitle>
<DialogContent>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">
{t("storagePolicy")}
</InputLabel>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={input.policy_id}
onChange={handleChange("policy_id")}
>
<MenuItem value={"all"}>
{tDashboard("user.all")}
</MenuItem>
{policies.map((v) => {
return (
<MenuItem key={v.ID} value={v.ID.toString()}>
{v.Name}
</MenuItem>
);
})}
</Select>
</FormControl>
<FormControl fullWidth style={{ marginTop: 16 }}>
<TextField
value={input.user_id}
onChange={handleChange("user_id")}
id="standard-basic"
label={t("uploaderID")}
/>
</FormControl>
<FormControl fullWidth style={{ marginTop: 16 }}>
<TextField
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
id="standard-basic"
label={t("searchFileName")}
/>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="default">
{tCommon("cancel")}
</Button>
<Button onClick={submit} color="primary">
{tDashboard("user.apply")}
</Button>
</DialogActions>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{isFile ? t("fileNameMagicVar") : t("pathMagicVar")}
</DialogTitle>
<DialogContent>
<TableContainer>
<Table size="small" aria-label="a dense table">
<TableHead>
<TableRow>
<TableCell>{t("variable")}</TableCell>
<TableCell>{t("description")}</TableCell>
<TableCell>{t("example")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{magicVars.map((m) => {
if (!m.fileOnly || isFile) {
return (
<TableRow>
<TableCell
component="th"
scope="row"
>
{m.value}
</TableCell>
<TableCell>{t(m.des)}</TableCell>
<TableCell>{m.example}</TableCell>
</TableRow>
);
}
})}
{!isFile && (
<TableRow>
<TableCell component="th" scope="row">
{"{path}"}
</TableCell>
<TableCell>{t("userUploadPath")}</TableCell>
<TableCell>/MyFile/Documents/</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
{tCommon("close")}
</Button>
</DialogActions>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
fullWidth
maxWidth={"xs"}
>
<DialogTitle id="alert-dialog-title">
{tDashboard("user.filterCondition")}
</DialogTitle>
<DialogContent>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">
{t("srcType")}
</InputLabel>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={input.is_dir}
onChange={handleChange("is_dir")}
>
<MenuItem value={"all"}>
{tDashboard("user.all")}
</MenuItem>
<MenuItem value={"1"}>{t("folder")}</MenuItem>
<MenuItem value={"0"}>{t("file")}</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth style={{ marginTop: 16 }}>
<TextField
value={input.user_id}
onChange={handleChange("user_id")}
id="standard-basic"
label={tDashboard("file.uploaderID")}
/>
</FormControl>
<FormControl fullWidth style={{ marginTop: 16 }}>
<TextField
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
id="standard-basic"
label={tDashboard("file.searchFileName")}
/>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="default">
{tCommon("cancel")}
</Button>
<Button onClick={submit} color="primary">
{tCommon("ok")}
</Button>
</DialogActions>
</Dialog>
);
}

@ -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 (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
fullWidth
maxWidth={"xs"}
>
<DialogTitle id="alert-dialog-title">
{t("filterCondition")}
</DialogTitle>
<DialogContent>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">
{t("group")}
</InputLabel>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={input.group_id}
onChange={handleChange("group_id")}
>
<MenuItem value={"all"}>{t("all")}</MenuItem>
{groups.map((v) => {
if (v.ID === 3) {
return null;
}
return (
<MenuItem key={v.ID} value={v.ID.toString()}>
{v.Name}
</MenuItem>
);
})}
</Select>
</FormControl>
<FormControl fullWidth style={{ marginTop: 16 }}>
<InputLabel id="demo-simple-select-label">
{t("userStatus")}
</InputLabel>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={input.status}
onChange={handleChange("status")}
>
<MenuItem value={"all"}>{t("all")}</MenuItem>
<MenuItem value={"0"}>{t("active")}</MenuItem>
<MenuItem value={"1"}>{t("notActivated")}</MenuItem>
<MenuItem value={"2"}>{t("banned")}</MenuItem>
<MenuItem value={"3"}>{t("bannedBySys")}</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth style={{ marginTop: 16 }}>
<TextField
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
id="standard-basic"
label={t("searchNickUserName")}
/>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="default">
{tCommon("cancel")}
</Button>
<Button onClick={submit} color="primary">
{t("apply")}
</Button>
</DialogActions>
</Dialog>
);
}

@ -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 (
<div>
<FileFilter
filter={filter}
open={filterDialog}
onClose={() => setFilterDialog(false)}
setSearch={setSearch}
setFilter={setFilter}
/>
<div className={classes.header}>
<Button
color={"primary"}
onClick={() => history.push("/admin/file/import")}
variant={"contained"}
style={{
alignSelf: "center",
}}
>
{t("import")}
</Button>
<div className={classes.headerRight}>
<Tooltip title={tDashboard("user.filter")}>
<IconButton
style={{ marginRight: 8 }}
onClick={() => setFilterDialog(true)}
>
<Badge
color="secondary"
variant="dot"
invisible={
Object.keys(search).length === 0 &&
Object.keys(filter).length === 0
}
>
<FilterList />
</Badge>
</IconButton>
</Tooltip>
<Button
color={"primary"}
onClick={() => loadList()}
variant={"outlined"}
>
{tDashboard("policy.refresh")}
</Button>
</div>
</div>
<Paper square className={classes.tableContainer}>
{selected.length > 0 && (
<Toolbar className={classes.highlight}>
<Typography
style={{ flex: "1 1 100%" }}
color="inherit"
variant="subtitle1"
>
{tDashboard("user.selectedObjects", {
num: selected.length,
})}
</Typography>
<Tooltip title={tDashboard("policy.delete")}>
<IconButton
onClick={deleteBatch(false)}
disabled={loading}
aria-label="delete"
>
<Delete />
</IconButton>
</Tooltip>
<Tooltip title={t("forceDelete")}>
<IconButton
onClick={deleteBatch(true)}
disabled={loading}
aria-label="delete"
>
<DeleteForever />
</IconButton>
</Tooltip>
<Tooltip title={tDashboard("file.unlink")}>
<IconButton
disabled={loading}
onClick={deleteBatch(true, true)}
size={"small"}
>
<LinkOff />
</IconButton>
</Tooltip>
</Toolbar>
)}
<TableContainer className={classes.container}>
<Table aria-label="sticky table" size={"small"}>
<TableHead>
<TableRow style={{ height: 52 }}>
<TableCell padding="checkbox">
<Checkbox
indeterminate={
selected.length > 0 &&
selected.length < files.length
}
checked={
files.length > 0 &&
selected.length === files.length
}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all desserts",
}}
/>
</TableCell>
<TableCell style={{ minWidth: 59 }}>
<TableSortLabel
active={orderBy[0] === "id"}
direction={orderBy[1]}
onClick={() =>
setOrderBy([
"id",
orderBy[1] === "asc"
? "desc"
: "asc",
])
}
>
#
{orderBy[0] === "id" ? (
<span
className={
classes.visuallyHidden
}
>
{orderBy[1] === "desc"
? "sorted descending"
: "sorted ascending"}
</span>
) : null}
</TableSortLabel>
</TableCell>
<TableCell style={{ minWidth: 250 }}>
<TableSortLabel
active={orderBy[0] === "name"}
direction={orderBy[1]}
onClick={() =>
setOrderBy([
"name",
orderBy[1] === "asc"
? "desc"
: "asc",
])
}
>
{t("name")}
{orderBy[0] === "name" ? (
<span
className={
classes.visuallyHidden
}
>
{orderBy[1] === "desc"
? "sorted descending"
: "sorted ascending"}
</span>
) : null}
</TableSortLabel>
</TableCell>
<TableCell
align={"right"}
style={{ minWidth: 70 }}
>
<TableSortLabel
active={orderBy[0] === "size"}
direction={orderBy[1]}
onClick={() =>
setOrderBy([
"size",
orderBy[1] === "asc"
? "desc"
: "asc",
])
}
>
{t("size")}
{orderBy[0] === "size" ? (
<span
className={
classes.visuallyHidden
}
>
{orderBy[1] === "desc"
? "sorted descending"
: "sorted ascending"}
</span>
) : null}
</TableSortLabel>
</TableCell>
<TableCell style={{ minWidth: 120 }}>
{t("uploader")}
</TableCell>
<TableCell style={{ minWidth: 150 }}>
{t("createdAt")}
</TableCell>
<TableCell style={{ minWidth: 100 }}>
{tDashboard("policy.actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.map((row) => (
<TableRow
hover
key={row.ID}
role="checkbox"
selected={isSelected(row.ID)}
>
<TableCell padding="checkbox">
<Checkbox
onClick={(event) =>
handleClick(event, row.ID)
}
checked={isSelected(row.ID)}
/>
</TableCell>
<TableCell>{row.ID}</TableCell>
<TableCell>
<Link
target={"_blank"}
color="inherit"
href={
"/api/v3/admin/file/preview/" +
row.ID
}
>
{row.Name}
{row.UploadSessionID && (
<Chip
className={
classes.disabledBadge
}
size="small"
label={t("uploading")}
/>
)}
</Link>
</TableCell>
<TableCell align={"right"}>
{sizeToString(row.Size)}
</TableCell>
<TableCell>
<Link
href={
"/admin/user/edit/" + row.UserID
}
>
{users[row.UserID]
? users[row.UserID].Nick
: t("unknownUploader")}
</Link>
</TableCell>
<TableCell>
{formatLocalTime(
row.CreatedAt,
"YYYY-MM-DD H:mm:ss"
)}
</TableCell>
<TableCell>
<Tooltip
title={tDashboard("policy.delete")}
>
<IconButton
disabled={loading}
onClick={() =>
deleteFile(row.ID)
}
size={"small"}
>
<Delete />
</IconButton>
</Tooltip>
<Tooltip
title={tDashboard("file.unlink")}
>
<IconButton
disabled={loading}
onClick={() =>
deleteFile(row.ID, true)
}
size={"small"}
>
<LinkOff />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 50, 100]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page - 1}
onChangePage={(e, p) => setPage(p + 1)}
onChangeRowsPerPage={(e) => {
setPageSize(e.target.value);
setPage(1);
}}
/>
</Paper>
</div>
);
}

@ -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 (
<div>
<Dialog
open={selectRemote}
onClose={() => setSelectRemote(false)}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title">
{t("selectFolder")}
</DialogTitle>
<PathSelector
presentPath="/"
api={"/admin/file/folders/policy/" + options.policy}
selected={[]}
onSelect={setMoveTarget((p) =>
setOptions({
...options,
src: p,
})
)}
/>
<DialogActions>
<Button
onClick={() => setSelectRemote(false)}
color="primary"
>
{tCommon("ok")}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={selectLocal}
onClose={() => setSelectLocal(false)}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title">
{t("selectFolder")}
</DialogTitle>
<PathSelector
presentPath="/"
api={
"/admin/file/folders/user/" +
(user === null ? 0 : user.ID)
}
selected={[]}
onSelect={setMoveTarget((p) =>
setOptions({
...options,
dst: p,
})
)}
/>
<DialogActions>
<Button
onClick={() => setSelectLocal(false)}
color="primary"
>
{tCommon("ok")}
</Button>
</DialogActions>
</Dialog>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("importExternalFolder")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<Alert severity="info">
{t("importExternalFolderDes")}
</Alert>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("storagePolicy")}
</InputLabel>
<Select
labelId="demo-mutiple-chip-label"
id="demo-mutiple-chip"
value={options.policy}
onChange={handleChange("policy")}
input={<Input id="select-multiple-chip" />}
>
{Object.keys(policies).map((pid) => (
<MenuItem key={pid} value={pid}>
{policies[pid].Name}
</MenuItem>
))}
</Select>
<FormHelperText id="component-helper-text">
{t("storagePolicyDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("targetUser")}
</InputLabel>
<Input
value={options.userInput}
onChange={(e) => {
handleChange("userInput")(e);
setAnchorEl(e.currentTarget);
}}
startAdornment={
user !== null && (
<InputAdornment position="start">
<Chip
size="small"
onDelete={() => {
setUser(null);
}}
label={user.Nick}
/>
</InputAdornment>
)
}
disabled={user !== null}
/>
<Popper
open={
options.userInput !== "" &&
users.length > 0
}
anchorEl={anchorEl}
placement={"bottom"}
transition
>
{({ TransitionProps }) => (
<Fade
{...TransitionProps}
timeout={350}
>
<Paper
className={classes.userSelect}
>
{users.map((u) => (
<MenuItem
key={u.ID}
onClick={() =>
selectUser(u)
}
>
{u.Nick}{" "}
{"<" + u.Email + ">"}
</MenuItem>
))}
</Paper>
</Fade>
)}
</Popper>
<FormHelperText id="component-helper-text">
{t("targetUserDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("srcFolderPath")}
</InputLabel>
<Input
value={options.src}
onChange={(e) => {
handleChange("src")(e);
setAnchorEl(e.currentTarget);
}}
required
endAdornment={
<Button
onClick={() =>
openPathSelector(true)
}
>
{t("select")}
</Button>
}
/>
<FormHelperText id="component-helper-text">
{t("selectSrcDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("dstFolderPath")}
</InputLabel>
<Input
value={options.dst}
onChange={(e) => {
handleChange("dst")(e);
setAnchorEl(e.currentTarget);
}}
required
endAdornment={
<Button
onClick={() =>
openPathSelector(false)
}
>
{t("select")}
</Button>
}
/>
<FormHelperText id="component-helper-text">
{t("dstFolderPathDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={options.recursive}
onChange={handleCheckChange(
"recursive"
)}
/>
}
label={t("recursivelyImport")}
/>
<FormHelperText id="component-helper-text">
{t("recursivelyImportDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("createImportTask")}
</Button>
</div>
</form>
</div>
);
}

@ -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 <div>{group.ID !== undefined && <GroupForm group={group} />}</div>;
}

@ -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 (
<div>
<div className={classes.header}>
<Button
color={"primary"}
onClick={() => history.push("/admin/group/add")}
variant={"contained"}
>
{t("new")}
</Button>
<div className={classes.headerRight}>
<Button
color={"primary"}
onClick={() => loadList()}
variant={"outlined"}
>
{tDashboard("policy.refresh")}
</Button>
</div>
</div>
<Paper square className={classes.tableContainer}>
<TableContainer className={classes.container}>
<Table aria-label="sticky table" size={"small"}>
<TableHead>
<TableRow style={{ height: 52 }}>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
style={{ minWidth: column.minWidth }}
>
{t(column.id)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{groups.map((row) => (
<TableRow hover key={row.ID}>
<TableCell>{row.ID}</TableCell>
<TableCell>{row.Name}</TableCell>
<TableCell>
{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;
})}
</TableCell>
<TableCell align={"right"}>
{statics[row.ID] !== undefined &&
statics[row.ID].toLocaleString()}
</TableCell>
<TableCell align={"right"}>
{statics[row.ID] !== undefined &&
sizeToString(row.MaxStorage)}
</TableCell>
<TableCell align={"right"}>
<Tooltip
title={tDashboard("policy.edit")}
>
<IconButton
onClick={() =>
history.push(
"/admin/group/edit/" +
row.ID
)
}
size={"small"}
>
<Edit />
</IconButton>
</Tooltip>
<Tooltip
title={tDashboard("policy.delete")}
>
<IconButton
onClick={() =>
deletePolicy(row.ID)
}
size={"small"}
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page - 1}
onChangePage={(e, p) => setPage(p + 1)}
onChangeRowsPerPage={(e) => {
setPageSize(e.target.value);
setPage(1);
}}
/>
</Paper>
</div>
);
}

@ -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 (
<div>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{group.ID === 0 && t("new")}
{group.ID !== 0 &&
t("editGroup", { group: group.Name })}
</Typography>
<div className={classes.formContainer}>
{group.ID !== 3 && (
<>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("nameOfGroup")}
</InputLabel>
<Input
value={group.Name}
onChange={handleChange("Name")}
required
/>
<FormHelperText id="component-helper-text">
{t("nameOfGroupDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("availablePolicies")}
</InputLabel>
<Select
labelId="demo-mutiple-chip-label"
id="demo-mutiple-chip"
multiple
value={group.PolicyList}
onChange={handleChange(
"PolicyList"
)}
input={
<Input id="select-multiple-chip" />
}
renderValue={(selected) => (
<div>
{selected.map((value) => (
<Chip
style={{
margin: 2,
}}
key={value}
size={"small"}
label={
policies[value]
}
className={
classes.chip
}
/>
))}
</div>
)}
>
{Object.keys(policies).map(
(pid) => (
<MenuItem
key={pid}
value={pid}
style={getSelectItemStyles(
pid,
group.PolicyList,
theme
)}
>
{policies[pid]}
</MenuItem>
)
)}
</Select>
<FormHelperText id="component-helper-text">
{t("availablePoliciesDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<SizeInput
value={group.MaxStorage}
onChange={handleChange(
"MaxStorage"
)}
min={0}
max={9223372036854775807}
label={t("initialStorageQuota")}
required
/>
</FormControl>
<FormHelperText id="component-helper-text">
{t("initialStorageQuotaDes")}
</FormHelperText>
</div>
</>
)}
<div className={classes.form}>
<FormControl fullWidth>
<SizeInput
value={group.SpeedLimit}
onChange={handleChange("SpeedLimit")}
min={0}
max={9223372036854775807}
label={t("downloadSpeedLimit")}
suffix={"/s"}
required
/>
</FormControl>
<FormHelperText id="component-helper-text">
{t("downloadSpeedLimitDes")}
</FormHelperText>
</div>
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("bathSourceLinkLimit")}
</InputLabel>
<Input
multiline
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={
group.OptionsSerialized.source_batch
}
onChange={handleOptionChange(
"source_batch"
)}
/>
<FormHelperText id="component-helper-text">
{t("bathSourceLinkLimitDes")}
</FormHelperText>
</FormControl>
</div>
)}
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.ShareEnabled ===
"true"
}
onChange={handleCheckChange(
"ShareEnabled"
)}
/>
}
label={t("allowCreateShareLink")}
/>
<FormHelperText id="component-helper-text">
{t("allowCreateShareLinkDes")}
</FormHelperText>
</FormControl>
</div>
)}
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.share_download === "true"
}
onChange={handleOptionCheckChange(
"share_download"
)}
/>
}
label={t("allowDownloadShare")}
/>
<FormHelperText id="component-helper-text">
{t("allowDownloadShareDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.share_free === "true"
}
onChange={handleOptionCheckChange(
"share_free"
)}
/>
}
label={tVas("freeDownload")}
/>
<FormHelperText id="component-helper-text">
{tVas("freeDownloadDes")}
</FormHelperText>
</FormControl>
</div>
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.WebDAVEnabled ===
"true"
}
onChange={handleCheckChange(
"WebDAVEnabled"
)}
/>
}
label={t("allowWabDAV")}
/>
<FormHelperText id="component-helper-text">
{t("allowWabDAVDes")}
</FormHelperText>
</FormControl>
</div>
)}
{group.ID !== 3 && group.WebDAVEnabled === "true" && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized.webdav_proxy ===
"true"
}
onChange={handleOptionCheckChange(
"webdav_proxy"
)}
/>
}
label={t("allowWabDAVProxy")}
/>
<FormHelperText id="component-helper-text">
{t("allowWabDAVProxyDes")}
</FormHelperText>
</FormControl>
</div>
)}
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.one_time_download ===
"true"
}
onChange={handleOptionCheckChange(
"one_time_download"
)}
/>
}
label={t("disableMultipleDownload")}
/>
<FormHelperText id="component-helper-text">
{t("disableMultipleDownloadDes")}
</FormHelperText>
</FormControl>
</div>
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.aria2 === "true"
}
onChange={handleOptionCheckChange(
"aria2"
)}
/>
}
label={t("allowRemoteDownload")}
/>
<FormHelperText id="component-helper-text">
{t("allowRemoteDownloadDes")}
</FormHelperText>
</FormControl>
</div>
)}
<Collapse in={group.OptionsSerialized.aria2 === "true"}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("aria2Options")}
</InputLabel>
<Input
multiline
value={
group.OptionsSerialized
.aria2_options
}
onChange={handleOptionChange(
"aria2_options"
)}
/>
<FormHelperText id="component-helper-text">
{t("aria2OptionsDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("aria2BatchSize")}
</InputLabel>
<Input
multiline
type={"number"}
inputProps={{
min: 0,
step: 1,
}}
value={
group.OptionsSerialized.aria2_batch
}
onChange={handleOptionChange(
"aria2_batch"
)}
/>
<FormHelperText id="component-helper-text">
{t("aria2BatchSizeDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("availableNodes")}
</InputLabel>
<NodeSelector
selected={
group.OptionsSerialized
.available_nodes
}
handleChange={handleOptionChange(
"available_nodes"
)}
/>
<FormHelperText id="component-helper-text">
{t("availableNodesDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.select_node === "true"
}
onChange={handleOptionCheckChange(
"select_node"
)}
/>
}
label={t("allowSelectNode")}
/>
<FormHelperText id="component-helper-text">
{t("allowSelectNodeDes")}
</FormHelperText>
</FormControl>
</div>
</Collapse>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.archive_download === "true"
}
onChange={handleOptionCheckChange(
"archive_download"
)}
/>
}
label={t("serverSideBatchDownload")}
/>
<FormHelperText id="component-helper-text">
{t("serverSideBatchDownloadDes")}
</FormHelperText>
</FormControl>
</div>
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.archive_task === "true"
}
onChange={handleOptionCheckChange(
"archive_task"
)}
/>
}
label={t("compressTask")}
/>
<FormHelperText id="component-helper-text">
{t("compressTaskDes")}
</FormHelperText>
</FormControl>
</div>
)}
<Collapse
in={group.OptionsSerialized.archive_task === "true"}
>
<div className={classes.form}>
<FormControl fullWidth>
<SizeInput
value={
group.OptionsSerialized
.compress_size
}
onChange={handleOptionChange(
"compress_size"
)}
min={0}
max={9223372036854775807}
label={t("compressSize")}
/>
</FormControl>
<FormHelperText id="component-helper-text">
{t("compressSizeDes")}
</FormHelperText>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<SizeInput
value={
group.OptionsSerialized
.decompress_size
}
onChange={handleOptionChange(
"decompress_size"
)}
min={0}
max={9223372036854775807}
label={t("decompressSize")}
/>
</FormControl>
<FormHelperText id="component-helper-text">
{t("decompressSizeDes")}
</FormHelperText>
</div>
</Collapse>
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.relocate === "true"
}
onChange={handleOptionCheckChange(
"relocate"
)}
/>
}
label={t("migratePolicy")}
/>
<FormHelperText id="component-helper-text">
{t("migratePolicyDes")}
</FormHelperText>
</FormControl>
</div>
)}
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.redirected_source ===
"true"
}
onChange={handleOptionCheckChange(
"redirected_source"
)}
/>
}
label={t("redirectedSource")}
/>
<FormHelperText id="component-helper-text">
<Trans
ns={"dashboard"}
i18nKey={
"group.redirectedSourceDes"
}
components={[
<Link
href={tDashboard(
"policy.comparesStoragePoliciesLink"
)}
key={0}
target={"_blank"}
/>,
]}
/>
</FormHelperText>
</FormControl>
</div>
)}
{group.ID !== 3 && (
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
group.OptionsSerialized
.advance_delete ===
"true"
}
onChange={handleOptionCheckChange(
"advance_delete"
)}
/>
}
label={t("advanceDelete")}
/>
<FormHelperText id="component-helper-text">
{t("advanceDeleteDes")}
</FormHelperText>
</FormControl>
</div>
)}
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{tDashboard("settings.save")}
</Button>
</div>
</form>
</div>
);
}

@ -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 (
<Select
labelId="demo-mutiple-chip-label"
id="demo-mutiple-chip"
multiple
value={selected}
onChange={handleChange}
input={<Input id="select-multiple-chip" />}
renderValue={(selected) => (
<div>
{selected.map((value) => (
<Chip
style={{
margin: 2,
}}
key={value}
size={"small"}
label={nodes[value]}
/>
))}
</div>
)}
>
{Object.keys(nodes).map((pid) => (
<MenuItem
key={pid}
value={pid}
style={getSelectItemStyles(pid, selected, theme)}
>
{nodes[pid]}
</MenuItem>
))}
</Select>
);
}

@ -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 (
<Grid container spacing={3}>
<Dialog
open={open}
onClose={() => setOpen(false)}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{t("summary.confirmSiteURLTitle")}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
<Typography>
{siteURL === "" &&
t("summary.siteURLNotSet", {
current: window.location.origin,
})}
{siteURL !== "" &&
t("summary.siteURLNotMatch", {
current: window.location.origin,
})}
</Typography>
<Typography>
{t("summary.siteURLDescription")}
</Typography>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color="default">
{t("summary.ignore")}
</Button>
<Button onClick={() => ResetSiteURL()} color="primary">
{t("summary.changeIt")}
</Button>
</DialogActions>
</Dialog>
<Grid alignContent={"stretch"} item xs={12} md={8} lg={9}>
<Paper className={classes.paper}>
<Typography variant="button" display="block" gutterBottom>
{t("summary.trend")}
</Typography>
<ResponsiveContainer
width="100%"
aspect={pathHelper.isMobile() ? 4.0 / 3.0 : 3.0 / 1.0}
>
<LineChart width={1200} height={300} data={lineData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Line
name={t("nav.files")}
type="monotone"
dataKey="file"
stroke="#3f51b5"
/>
<Line
name={t("nav.users")}
type="monotone"
dataKey="user"
stroke="#82ca9d"
/>
<Line
name={t("nav.shares")}
type="monotone"
dataKey="share"
stroke="#e91e63"
/>
</LineChart>
</ResponsiveContainer>
</Paper>
</Grid>
<Grid item xs={12} md={4} lg={3}>
<Paper className={classes.paper}>
<Typography variant="button" display="block" gutterBottom>
{t("summary.summary")}
</Typography>
<Divider />
<List className={classes.root}>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.userIcon}>
<People />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={statistics.userTotal}
secondary={t("summary.totalUsers")}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.fileIcon}>
<FileCopy />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={statistics.fileTotal}
secondary={t("summary.totalFiles")}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.publicIcon}>
<Public />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={statistics.publicShareTotal}
secondary={t("summary.publicShares")}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.secretIcon}>
<Lock />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={statistics.secretShareTotal}
secondary={t("summary.privateShares")}
/>
</ListItem>
</List>
</Paper>
</Grid>
<Grid item xs={12} md={4} lg={3}>
<Paper>
<div className={classes.logoContainer}>
<img
alt="cloudreve"
className={classes.logo}
src={"/static/img/cloudreve.svg"}
/>
<div className={classes.title}>
<Typography className={classes.cloudreve}>
Cloudreve
</Typography>
<Typography className={classes.version}>
{version.backend}{" "}
{version.is_plus === "true" && (
<Chip size="small" label="Plus" />
)}
</Typography>
</div>
</div>
<Divider />
<div>
<List component="nav" aria-label="main mailbox folders">
<ListItem
button
onClick={() =>
window.open("https://cloudreve.org")
}
>
<ListItemIcon>
<Home />
</ListItemIcon>
<ListItemText primary={t("summary.homepage")} />
<ListItemIcon className={classes.iconRight}>
<Launch />
</ListItemIcon>
</ListItem>
<ListItem
button
onClick={() =>
window.open(
"https://github.com/cloudreve/cloudreve"
)
}
>
<ListItemIcon>
<GitHub />
</ListItemIcon>
<ListItemText primary={t("summary.github")} />
<ListItemIcon className={classes.iconRight}>
<Launch />
</ListItemIcon>
</ListItem>
<ListItem
button
onClick={() =>
window.open("https://docs.cloudreve.org/")
}
>
<ListItemIcon>
<Description />
</ListItemIcon>
<ListItemText
primary={t("summary.documents")}
/>
<ListItemIcon className={classes.iconRight}>
<Launch />
</ListItemIcon>
</ListItem>
<ListItem
button
onClick={() =>
window.open(t("summary.forumLink"))
}
>
<ListItemIcon>
<Forum />
</ListItemIcon>
<ListItemText primary={t("summary.forum")} />
<ListItemIcon className={classes.iconRight}>
<Launch />
</ListItemIcon>
</ListItem>
<ListItem
button
onClick={() =>
window.open(t("summary.telegramGroupLink"))
}
>
<ListItemIcon>
<Telegram />
</ListItemIcon>
<ListItemText
primary={t("summary.telegramGroup")}
/>
<ListItemIcon className={classes.iconRight}>
<Launch />
</ListItemIcon>
</ListItem>
<ListItem
button
onClick={() =>
window.open("https://docs.cloudreve.org/use/pro/jie-shao")
}
>
<ListItemIcon style={{ color: "#ff789d" }}>
<Favorite />
</ListItemIcon>
<ListItemText primary={t("summary.buyPro")} />
<ListItemIcon className={classes.iconRight}>
<Launch />
</ListItemIcon>
</ListItem>
</List>
</div>
</Paper>
</Grid>
{/* <Grid item xs={12} md={8} lg={9}>
<Paper className={classes.paper}>
<List>
{news &&
news.map((v) => (
<>
<ListItem
button
alignItems="flex-start"
onClick={() =>
window.open(
"https://forum.cloudreve.org/d/" +
v.id
)
}
>
<ListItemAvatar>
<Avatar
alt="Travis Howard"
src={
newsUsers[
v.relationships
.startUser.data.id
] &&
newsUsers[
v.relationships
.startUser.data.id
].avatarUrl
}
/>
</ListItemAvatar>
<ListItemText
primary={v.attributes.title}
secondary={
<React.Fragment>
<Typography
component="span"
variant="body2"
className={
classes.inline
}
color="textPrimary"
>
{newsUsers[
v.relationships
.startUser.data
.id
] &&
newsUsers[
v.relationships
.startUser
.data.id
].username}{" "}
</Typography>
<Trans
ns={"dashboard"}
i18nKey="summary.publishedAt"
components={[
<TimeAgo
key={0}
datetime={
v.attributes
.startTime
}
locale={t(
"timeAgoLocaleCode",
{
ns:
"common",
}
)}
/>,
]}
/>
</React.Fragment>
}
/>
</ListItem>
<Divider />
</>
))}
</List>
</Paper>
</Grid> */}
</Grid>
);
}

@ -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 (
<div>
<Paper square className={classes.content}>
<NodeGuide />
</Paper>
</div>
);
}

@ -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 (
<div>
<Paper square className={classes.content}>
{node && <NodeGuide node={node} />}
</Paper>
</div>
);
}

@ -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 (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
props.onSubmit(e);
}}
>
<Alert severity="info" style={{ marginBottom: 10 }}>
<Typography variant="body2">
<Trans
ns={"dashboard"}
i18nKey={"node.aria2Des"}
values={{
mode: mode,
}}
components={[
<Link
href={"https://aria2.github.io/"}
target={"_blank"}
key={0}
/>,
<Box
component="span"
fontWeight="fontWeightBold"
key={1}
/>,
<Link
href={t("aria2DocURL")}
target={"_blank"}
key={2}
/>,
]}
/>
</Typography>
</Alert>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{props.node.Type === 0
? t("slaveTakeOverRemoteDownload")
: t("masterTakeOverRemoteDownload")}
<br />
{props.node.Type === 0
? t("routeTaskSlave")
: t("routeTaskMaster")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={props.node.Aria2Enabled}
onChange={props.handleTextChange(
"Aria2Enabled"
)}
row
>
<FormControlLabel
value={"true"}
control={<Radio color={"primary"} />}
label={t("enable")}
/>
<FormControlLabel
value={"false"}
control={<Radio color={"primary"} />}
label={t("disable")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={props.node.Aria2Enabled === "true"}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("aria2ConfigDes", {
target:
props.node.Type === 0
? t("slaveNodeTarget")
: t("masterNodeTarget"),
})}
</Typography>
<pre>
# {t("enableRPCComment")}
<br />
enable-rpc=true
<br /># {t("rpcPortComment")}
<br />
rpc-listen-port=6800
<br /># {t("rpcSecretComment")}
<br />
rpc-secret=
{props.node.Aria2OptionsSerialized.token}
<br />
</pre>
<Alert severity="info" style={{ marginBottom: 10 }}>
<Typography variant="body2">
{t("rpcConfigDes")}
</Typography>
</Alert>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>3</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"node.rpcServerDes"}
values={{
mode: mode,
}}
components={[
<code key={0} />,
<code key={1} />,
<code key={2} />,
]}
/>
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("rpcServer")}
</InputLabel>
<Input
required
type={"url"}
value={
props.node.Aria2OptionsSerialized.server
}
onChange={props.handleOptionChange(
"server"
)}
/>
<FormHelperText id="component-helper-text">
{t("rpcServerHelpDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>4</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"node.rpcTokenDes"}
components={[<code key={0} />]}
/>
</Typography>
<div className={classes.form}>
<Input
value={props.node.Aria2OptionsSerialized.token}
onChange={props.handleOptionChange("token")}
/>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>5</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"node.aria2PathDes"}
components={[<strong key={0} />]}
/>
</Typography>
<div className={classes.form}>
<Input
value={
props.node.Aria2OptionsSerialized.temp_path
}
onChange={props.handleOptionChange("temp_path")}
/>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>5</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("aria2SettingDes")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("refreshInterval")}
</InputLabel>
<Input
type={"number"}
inputProps={{
step: 1,
min: 1,
}}
required
value={
props.node.Aria2OptionsSerialized
.interval
}
onChange={props.handleOptionChange(
"interval"
)}
/>
<FormHelperText id="component-helper-text">
{t("refreshIntervalDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("rpcTimeout")}
</InputLabel>
<Input
type={"number"}
inputProps={{
step: 1,
min: 1,
}}
required
value={
props.node.Aria2OptionsSerialized
.timeout
}
onChange={props.handleOptionChange(
"timeout"
)}
/>
<FormHelperText id="component-helper-text">
{t("rpcTimeoutDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("globalOptions")}
</InputLabel>
<Input
multiline
required
value={
props.node.Aria2OptionsSerialized
.options
}
onChange={props.handleOptionChange(
"options"
)}
/>
<FormHelperText id="component-helper-text">
{t("globalOptionsDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>6</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("testAria2Des", { mode })}
{props.node.Type === 0 &&
t("testAria2DesSlaveAddition")}
</Typography>
<div className={classes.form}>
<Button
disabled={loading}
variant={"outlined"}
onClick={() => testAria2()}
color={"primary"}
>
{t("testAria2")}
</Button>
</div>
</div>
</div>
</Collapse>
<div className={classes.stepFooter}>
{props.activeStep !== 0 && (
<Button
color={"default"}
className={classes.button}
onClick={props.onBack}
>
{tDashboard("policy.back")}
</Button>
)}
<Button
disabled={loading || props.loading}
type={"submit"}
variant={"contained"}
color={"primary"}
onClick={props.onSubmit}
>
{tDashboard("policy.next")}
</Button>
</div>
</form>
);
}

@ -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 (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
props.onSubmit(e);
}}
>
<Alert severity="info" style={{ marginBottom: 10 }}>
<Trans
ns={"dashboard"}
i18nKey={"node.slaveNodeDes"}
components={[<Box key={0} fontWeight="fontWeightBold" />]}
/>
</Alert>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{tDashboard("policy.remoteCopyBinaryDescription")}
</Typography>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{tDashboard("policy.remoteSecretDescription")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tDashboard("policy.remoteSecret")}
</InputLabel>
<Input
required
inputProps={{
minlength: 64,
}}
value={props.node.SlaveKey}
onChange={props.handleTextChange("SlaveKey")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>3</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{tDashboard("policy.modifyRemoteConfig")}
<br />
<Trans
ns={"dashboard"}
i18nKey={"policy.addRemoteConfigDes"}
components={[<code key={0} />]}
/>
</Typography>
<pre>
[System]
<br />
Mode = slave
<br />
Listen = :5212
<br />
<br />
[Slave]
<br />
Secret = {props.node.SlaveKey}
<br />
<br />
<Trans
ns={"dashboard"}
i18nKey={"node.overwriteDes"}
components={[<br key={0} />, <br key={1} />]}
/>
<br />
[OptionOverwrite]
<br />; {t("workerNumDes")}
<br />
max_worker_num = 50
<br />; {t("parallelTransferDes")}
<br />
max_parallel_transfer = 10
<br />; {t("chunkRetriesDes")}
<br />
chunk_retries = 10
</pre>
<Typography variant={"body2"}>
{tDashboard("policy.remoteConfigDifference")}
<ul>
<li>
<Trans
ns={"dashboard"}
i18nKey={"policy.remoteConfigDifference1"}
components={[
<code key={0} />,
<code key={1} />,
<code key={2} />,
]}
/>
</li>
<li>
<Trans
ns={"dashboard"}
i18nKey={"policy.remoteConfigDifference2"}
components={[
<code key={0} />,
<code key={1} />,
]}
/>
</li>
</ul>
{t("multipleMasterDes")}
</Typography>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>4</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{tDashboard("policy.inputRemoteAddress")}
<br />
{tDashboard("policy.inputRemoteAddressDes")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tDashboard("policy.remoteAddress")}
</InputLabel>
<Input
fullWidth
required
type={"url"}
value={props.node.Server}
onChange={props.handleTextChange("Server")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>5</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{tDashboard("policy.testCommunicationDes")}
</Typography>
<div className={classes.form}>
<Button
disabled={loading}
variant={"outlined"}
onClick={() => testSlave()}
color={"primary"}
>
{tDashboard("policy.testCommunication")}
</Button>
</div>
</div>
</div>
<div className={classes.stepFooter}>
<Button
disabled={loading || props.loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{tDashboard("policy.next")}
</Button>
</div>
</form>
);
}

@ -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 (
<form className={classes.stepContent}>
<Typography>{t("nodeSaved")}</Typography>
<Typography variant={"body2"} color={"textSecondary"}>
{t("nodeSavedFutureAction")}
</Typography>
<div className={classes.stepFooter}>
<Button
color={"primary"}
className={classes.button}
onClick={() => history.push("/admin/node")}
>
{t("backToNodeList")}
</Button>
</div>
</form>
);
}

@ -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 (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
props.onSubmit(e);
}}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>{t("nameNode")}</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<Input
required
value={props.node.Name}
onChange={props.handleTextChange("Name")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("loadBalancerRankDes")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("loadBalancerRank")}
</InputLabel>
<Input
type={"number"}
required
inputProps={{
step: 1,
min: 0,
}}
value={props.node.Rank}
onChange={props.handleTextChange("Rank")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.stepFooter}>
<Button
disabled={loading || props.loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{tDashboard("policy.next")}
</Button>
</div>
</form>
);
}

@ -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 <Communication {...p} />;
},
},
{
slaveOnly: false,
title: "remoteDownload",
optional: false,
component: function show(p) {
return <Aria2RPC {...p} />;
},
},
{
slaveOnly: false,
title: "otherSettings",
optional: false,
component: function show(p) {
return <Metainfo {...p} />;
},
},
{
slaveOnly: false,
title: "finish",
optional: false,
component: function show(p) {
return <Completed {...p} />;
},
},
];
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 (
<div>
<Typography variant={"h6"}>
{props.node ? t("editNode") : t("addNode")}
</Typography>
<Stepper activeStep={activeStep}>
{usedSteps.map((label, index) => {
const stepProps = {};
const labelProps = {};
if (label.optional) {
labelProps.optional = (
<Typography variant="caption">
{tDashboard("policy.optional")}
</Typography>
);
}
if (isStepSkipped(index)) {
stepProps.completed = false;
}
if (!(label.slaveOnly && node.Type === 1)) {
return (
<Step key={label.title} {...stepProps}>
<StepLabel {...labelProps}>
{t(label.title)}
</StepLabel>
</Step>
);
}
})}
</Stepper>
{usedSteps[activeStep].component({
onSubmit: (e) => nextStep(),
node: node,
loading: loading,
onBack: (e) => setActiveStep(activeStep - 1),
handleTextChange: handleTextChange,
activeStep: activeStep,
handleOptionChange: handleOptionChange,
})}
</div>
);
}

@ -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 (
<Chip
className={classes.disabledBadge}
size="small"
label={t("disabled")}
/>
);
}
};
const getFeatureBadge = (node) =>
features.map((feature) => {
if (node[feature.field]) {
return (
<Chip
className={classes.disabledBadge}
size="small"
color="primary"
label={t(feature.name)}
/>
);
}
});
const getRealStatusBadge = (status) =>
status ? (
<Box color="success.main" fontSize="small">
<CheckCircle
className={classes.verticalAlign}
fontSize="small"
/>{" "}
<span className={classes.verticalAlign}>{t("online")}</span>
</Box>
) : (
<Box color="error.main" fontSize="small">
<Cancel className={classes.verticalAlign} fontSize="small" />{" "}
<span className={classes.verticalAlign}>{t("offline")}</span>
</Box>
);
return (
<div>
<div className={classes.header}>
<Button
color={"primary"}
onClick={() => history.push("/admin/node/add")}
variant={"contained"}
>
{t("addNewNode")}
</Button>
<div className={classes.headerRight}>
<Button
color={"primary"}
onClick={() => loadList()}
variant={"outlined"}
>
{t("refresh")}
</Button>
</div>
</div>
<Paper square className={classes.tableContainer}>
<TableContainer className={classes.container}>
<Table aria-label="sticky table" size={"small"}>
<TableHead>
<TableRow style={{ height: 52 }}>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
style={{
minWidth: column.minWidthclassNames,
}}
>
{t(column.id)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{nodes.map((row) => (
<TableRow hover key={row.ID}>
<TableCell>{row.ID}</TableCell>
<TableCell
className={classNames({
[classes.disabledCell]:
row.Status === 1,
})}
>
{row.Name}
{getStatusBadge(row.Status)}
</TableCell>
<TableCell>
{getRealStatusBadge(isActive[row.ID])}
</TableCell>
<TableCell>
{getFeatureBadge(row)}
</TableCell>
<TableCell align={"right"}>
<Tooltip
title={
row.Status === 1
? t("enableNode")
: t("disableNode")
}
>
<IconButton
disabled={loading}
onClick={() =>
toggleNode(
row.ID,
1 - row.Status
)
}
size={"small"}
>
{row.Status === 1 && (
<PlayArrow />
)}
{row.Status !== 1 && <Pause />}
</IconButton>
</Tooltip>
<Tooltip title={t("edit")}>
<IconButton
disabled={loading}
onClick={() =>
history.push(
"/admin/node/edit/" +
row.ID
)
}
size={"small"}
>
<Edit />
</IconButton>
</Tooltip>
<Tooltip title={t("delete")}>
<IconButton
onClick={() =>
deleteNode(row.ID)
}
disabled={loading}
size={"small"}
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page - 1}
onChangePage={(e, p) => setPage(p + 1)}
onChangeRowsPerPage={(e) => {
setPageSize(e.target.value);
setPage(1);
}}
/>
</Paper>
</div>
);
}

@ -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 (
<div>
<ShareFilter
filter={filter}
open={filterDialog}
onClose={() => setFilterDialog(false)}
setSearch={setSearch}
setFilter={setFilter}
/>
<div className={classes.header}>
<div className={classes.headerRight}>
<Button
color={"primary"}
onClick={() => loadList()}
variant={"outlined"}
>
{tDashboard("policy.refresh")}
</Button>
</div>
</div>
<Paper square className={classes.tableContainer}>
{selected.length > 0 && (
<Toolbar className={classes.highlight}>
<Typography
style={{ flex: "1 1 100%" }}
color="inherit"
variant="subtitle1"
>
{tDashboard("user.selectedObjects", {
num: selected.length,
})}
</Typography>
<Tooltip title={tDashboard("policy.delete")}>
<IconButton
onClick={deleteBatch}
disabled={loading}
aria-label="delete"
>
<Delete />
</IconButton>
</Tooltip>
</Toolbar>
)}
<TableContainer className={classes.container}>
<Table aria-label="sticky table" size={"small"}>
<TableHead>
<TableRow style={{ height: 52 }}>
<TableCell padding="checkbox">
<Checkbox
indeterminate={
selected.length > 0 &&
selected.length < orders.length
}
checked={
orders.length > 0 &&
selected.length === orders.length
}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all desserts",
}}
/>
</TableCell>
<TableCell style={{ minWidth: 10 }}>
<TableSortLabel
active={orderBy[0] === "id"}
direction={orderBy[1]}
onClick={() =>
setOrderBy([
"id",
orderBy[1] === "asc"
? "desc"
: "asc",
])
}
>
#
{orderBy[0] === "id" ? (
<span
className={
classes.visuallyHidden
}
>
{orderBy[1] === "desc"
? "sorted descending"
: "sorted ascending"}
</span>
) : null}
</TableSortLabel>
</TableCell>
<TableCell style={{ minWidth: 130 }}>
{t("orderName")}
</TableCell>
<TableCell style={{ minWidth: 90 }}>
{t("product")}
</TableCell>
<TableCell style={{ minWidth: 80 }}>
<TableSortLabel
active={orderBy[0] === "order_no"}
direction={orderBy[1]}
onClick={() =>
setOrderBy([
"order_no",
orderBy[1] === "asc"
? "desc"
: "asc",
])
}
>
{t("orderNumber")}
{orderBy[0] === "order_no" ? (
<span
className={
classes.visuallyHidden
}
>
{orderBy[1] === "desc"
? "sorted descending"
: "sorted ascending"}
</span>
) : null}
</TableSortLabel>
</TableCell>
<TableCell
style={{ minWidth: 100 }}
align={"right"}
>
{t("price")}
</TableCell>
<TableCell
style={{ minWidth: 80 }}
align={"right"}
>
{t("qyt")}
</TableCell>
<TableCell style={{ minWidth: 100 }}>
{t("status")}
</TableCell>
<TableCell style={{ minWidth: 80 }}>
{t("paidBy")}
</TableCell>
<TableCell style={{ minWidth: 100 }}>
{t("orderOwner")}
</TableCell>
<TableCell style={{ minWidth: 150 }}>
{tDashboard("file.createdAt")}
</TableCell>
<TableCell style={{ minWidth: 80 }}>
{tDashboard("policy.actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orders.map((row) => (
<TableRow
hover
key={row.ID}
role="checkbox"
selected={isSelected(row.ID)}
>
<TableCell padding="checkbox">
<Checkbox
onClick={(event) =>
handleClick(event, row.ID)
}
checked={isSelected(row.ID)}
/>
</TableCell>
<TableCell>{row.ID}</TableCell>
<TableCell>{row.Name}</TableCell>
<TableCell>
{row.Type === 0 && t("storagePack")}
{row.Type === 1 &&
t("purchasableGroups")}
{row.Type === 2 && t("credits")}
</TableCell>
<TableCell>{row.OrderNo}</TableCell>
<TableCell align={"right"}>
{row.Method === "score" && row.Price}
{row.Method !== "score" && (
<>
{(row.Price / 100).toFixed(2)}
</>
)}
</TableCell>
<TableCell align={"right"}>
{row.Num}
</TableCell>
<TableCell>
{row.Status === 0 && t("unpaid")}
{row.Status === 1 && t("paid")}
</TableCell>
<TableCell>
{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")}
</TableCell>
<TableCell>
<Link
href={
"/admin/user/edit/" + row.UserID
}
>
{users[row.UserID]
? users[row.UserID].Nick
: tDashboard(
"file.unknownUploader"
)}
</Link>
</TableCell>
<TableCell>
{formatLocalTime(row.CreatedAt)}
</TableCell>
<TableCell>
<Tooltip
title={tDashboard("policy.delete")}
>
<IconButton
disabled={loading}
onClick={() =>
deletePolicy(row.ID)
}
size={"small"}
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 50, 100]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page - 1}
onChangePage={(e, p) => setPage(p + 1)}
onChangeRowsPerPage={(e) => {
setPageSize(e.target.value);
setPage(1);
}}
/>
</Paper>
</div>
);
}

@ -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 (
<div>
<Paper square className={classes.content}>
{type === "local" && <LocalGuide />}
{type === "remote" && <RemoteGuide />}
{type === "qiniu" && <QiniuGuide />}
{type === "oss" && <OSSGuide />}
{type === "upyun" && <UpyunGuide />}
{type === "cos" && <COSGuide />}
{type === "onedrive" && <OneDriveGuide />}
{type === "s3" && <S3Guide />}
</Paper>
</div>
);
}

@ -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 (
<div>
<Paper square className={classes.content}>
{mode === "guide" && (
<>
{type === "local" && <LocalGuide policy={policy} />}
{type === "remote" && <RemoteGuide policy={policy} />}
{type === "qiniu" && <QiniuGuide policy={policy} />}
{type === "oss" && <OSSGuide policy={policy} />}
{type === "upyun" && <UpyunGuide policy={policy} />}
{type === "cos" && <COSGuide policy={policy} />}
{type === "onedrive" && (
<OneDriveGuide policy={policy} />
)}
{type === "s3" && <S3Guide policy={policy} />}
</>
)}
{mode === "pro" && type !== "" && <EditPro policy={policy} />}
</Paper>
</div>
);
}

File diff suppressed because it is too large Load Diff

@ -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 (
<div>
<Typography variant={"h6"}>{t("editPolicy")}</Typography>
<TableContainer>
<form onSubmit={submitPolicy}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>{t("setting")}</TableCell>
<TableCell>{t("value")}</TableCell>
<TableCell>{t("description")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell component="th" scope="row">
{t("id")}
</TableCell>
<TableCell>{policy.ID}</TableCell>
<TableCell>{t("policyID")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("type")}
</TableCell>
<TableCell>{policy.Type}</TableCell>
<TableCell>{t("policyType")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("name")}
</TableCell>
<TableCell>
<FormControl>
<Input
required
value={policy.Name}
onChange={handleChange("Name")}
/>
</FormControl>
</TableCell>
<TableCell>{t("policyName")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("server")}
</TableCell>
<TableCell>
<FormControl>
<Input
value={policy.Server}
onChange={handleChange("Server")}
/>
</FormControl>
</TableCell>
<TableCell>{t("policyEndpoint")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("bucketName")}
</TableCell>
<TableCell>
<FormControl>
<Input
value={policy.BucketName}
onChange={handleChange(
"BucketName"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("bucketID")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("privateBucket")}
</TableCell>
<TableCell>
<FormControl>
<RadioGroup
required
value={policy.IsPrivate}
onChange={handleChange("IsPrivate")}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("yes")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("no")}
/>
</RadioGroup>
</FormControl>
</TableCell>
<TableCell>{t("privateBucketDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("resourceRootURL")}
</TableCell>
<TableCell>
<FormControl>
<Input
value={policy.BaseURL}
onChange={handleChange("BaseURL")}
/>
</FormControl>
</TableCell>
<TableCell>{t("resourceRootURLDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("accessKey")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
rowsMax={10}
value={policy.AccessKey}
onChange={handleChange("AccessKey")}
/>
</FormControl>
</TableCell>
<TableCell>{t("akDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("secretKey")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
rowsMax={10}
value={policy.SecretKey}
onChange={handleChange("SecretKey")}
/>
</FormControl>
</TableCell>
<TableCell>{t("secretKey")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("maxSizeBytes")}
</TableCell>
<TableCell>
<FormControl>
<Input
type={"number"}
inputProps={{
min: 0,
step: 1,
}}
value={policy.MaxSize}
onChange={handleChange("MaxSize")}
/>
</FormControl>
</TableCell>
<TableCell>{t("maxSizeBytesDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("autoRename")}
</TableCell>
<TableCell>
<FormControl>
<RadioGroup
required
value={policy.AutoRename}
onChange={handleChange(
"AutoRename"
)}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("yes")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("no")}
/>
</RadioGroup>
</FormControl>
</TableCell>
<TableCell>{t("autoRenameDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("storagePath")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={policy.DirNameRule}
onChange={handleChange(
"DirNameRule"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("storagePathDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("fileName")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={policy.FileNameRule}
onChange={handleChange(
"FileNameRule"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("fileNameDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("allowGetSourceLink")}
</TableCell>
<TableCell>
<FormControl>
<RadioGroup
required
value={policy.IsOriginLinkEnable}
onChange={handleChange(
"IsOriginLinkEnable"
)}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("yes")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("no")}
/>
</RadioGroup>
</FormControl>
</TableCell>
<TableCell>
{t("allowGetSourceLinkDes")}
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("upyunToken")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized.token
}
onChange={handleOptionChange(
"token"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("upyunOnly")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("allowedFileExtension")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized
.file_type
}
onChange={handleOptionChange(
"file_type"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("emptyIsNoLimit")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("allowedMimetype")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized
.mimetype
}
onChange={handleOptionChange(
"mimetype"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("qiniuOnly")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("odRedirectURL")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized
.od_redirect
}
onChange={handleOptionChange(
"od_redirect"
)}
/>
</FormControl>
</TableCell>
<TableCell>
{t("noModificationNeeded")}
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("odReverseProxy")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized
.od_proxy
}
onChange={handleOptionChange(
"od_proxy"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("odOnly")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("odDriverID")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized
.od_driver
}
onChange={handleOptionChange(
"od_driver"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("odDriverIDDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("s3Region")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized.region
}
onChange={handleOptionChange(
"region"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("s3Only")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("lanEndpoint")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized
.server_side_endpoint
}
onChange={handleOptionChange(
"server_side_endpoint"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("ossOnly")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("chunkSizeBytes")}
</TableCell>
<TableCell>
<FormControl>
<Input
type={"number"}
inputProps={{
min: 0,
step: 1,
}}
value={
policy.OptionsSerialized
.chunk_size
}
onChange={handleOptionChange(
"chunk_size"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("chunkSizeBytesDes")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("placeHolderWithSize")}
</TableCell>
<TableCell>
<FormControl>
<RadioGroup
required
value={
policy.OptionsSerialized
.placeholder_with_size
}
onChange={handleOptionChange(
"placeholder_with_size"
)}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("yes")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("no")}
/>
</RadioGroup>
</FormControl>
</TableCell>
<TableCell>
{t("placeHolderWithSizeDes")}
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("tps")}
</TableCell>
<TableCell>
<FormControl>
<Input
type={"number"}
inputProps={{
step: 0.1,
}}
value={
policy.OptionsSerialized
.tps_limit
}
onChange={handleOptionChange(
"tps_limit"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("odOnly")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("tpsBurst")}
</TableCell>
<TableCell>
<FormControl>
<Input
type={"number"}
inputProps={{
step: 1,
}}
value={
policy.OptionsSerialized
.tps_limit_burst
}
onChange={handleOptionChange(
"tps_limit_burst"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("odOnly")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("usePathEndpoint")}
</TableCell>
<TableCell>
<FormControl>
<RadioGroup
required
value={
policy.OptionsSerialized
.s3_path_style
}
onChange={handleOptionChange(
"s3_path_style"
)}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("yes")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("no")}
/>
</RadioGroup>
</FormControl>
</TableCell>
<TableCell>{t("s3Only")}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
{t("thumbExt")}
</TableCell>
<TableCell>
<FormControl>
<Input
multiline
value={
policy.OptionsSerialized
.thumb_exts
}
onChange={handleOptionChange(
"thumb_exts"
)}
/>
</FormControl>
</TableCell>
<TableCell>{t("thumbExtDes")}</TableCell>
</TableRow>
</TableBody>
</Table>
<Button
type={"submit"}
color={"primary"}
variant={"contained"}
style={{ margin: 8 }}
>
{t("saveChanges")}
</Button>
</form>
</TableContainer>
</div>
);
}

@ -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 (
<div>
<Typography variant={"h6"}>
{props.policy
? t("editLocalStoragePolicy")
: t("addLocalStoragePolicy")}
</Typography>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps = {};
const labelProps = {};
if (label.optional) {
labelProps.optional = (
<Typography variant="caption">
{t("optional")}
</Typography>
);
}
if (isStepSkipped(index)) {
stepProps.completed = false;
}
return (
<Step key={t(label.title)} {...stepProps}>
<StepLabel {...labelProps}>
{t(label.title)}
</StepLabel>
</Step>
);
})}
</Stepper>
{activeStep === 0 && (
<form
className={classes.stepContent}
onSubmit={checkPathSetting}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"policy.pathMagicVarDes"}
components={[
<Link
key={0}
color={"secondary"}
href={"javascript:void()"}
onClick={() => setMagicVar("path")}
/>,
]}
/>
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("pathOfFolderToStoreFiles")}
</InputLabel>
<Input
required
value={policy.DirNameRule}
onChange={handleChange("DirNameRule")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"policy.filePathMagicVarDes"}
components={[
<Link
key={0}
color={"secondary"}
href={"javascript:void()"}
onClick={() => setMagicVar("file")}
/>,
]}
/>
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
aria-label="gender"
name="gender1"
value={policy.AutoRename}
onChange={handleChange("AutoRename")}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("autoRenameStoredFile")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("keepOriginalFileName")}
/>
</RadioGroup>
</FormControl>
</div>
<Collapse in={policy.AutoRename === "true"}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("renameRule")}
</InputLabel>
<Input
required={
policy.AutoRename === "true"
}
value={policy.FileNameRule}
onChange={handleChange(
"FileNameRule"
)}
/>
</FormControl>
</div>
</Collapse>
</div>
</div>
<div className={classes.stepFooter}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("next")}
</Button>
</div>
</form>
)}
{activeStep === 1 && (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
setActiveStep(2);
}}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("enableGettingPermanentSourceLink")}
<br />
{t("enableGettingPermanentSourceLinkDes")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={policy.IsOriginLinkEnable}
onChange={handleChange(
"IsOriginLinkEnable"
)}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("allowed")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("forbidden")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={policy.IsOriginLinkEnable === "true"}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("useCDN")}
<br />
{t("useCDNDes")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={useCDN}
onChange={(e) => {
if (
e.target.value === "false"
) {
setPolicy({
...policy,
BaseURL: "",
});
}
setUseCDN(e.target.value);
}}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("use")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("notUse")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={useCDN === "true"}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>3</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("cdnDomain")}
</Typography>
<div className={classes.form}>
<DomainInput
value={policy.BaseURL}
onChange={handleChange("BaseURL")}
required={
policy.IsOriginLinkEnable ===
"true" && useCDN === "true"
}
label={t("cdnPrefix")}
/>
</div>
</div>
</div>
</Collapse>
</Collapse>
<div className={classes.stepFooter}>
<Button
color={"default"}
className={classes.button}
onClick={() => setActiveStep(0)}
>
{t("back")}
</Button>{" "}
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("next")}
</Button>
</div>
</form>
)}
{activeStep === 2 && (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
setActiveStep(3);
}}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("limitFileSize")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={
policy.MaxSize === "0"
? "false"
: "true"
}
onChange={(e) => {
if (e.target.value === "true") {
setPolicy({
...policy,
MaxSize: "10485760",
});
} else {
setPolicy({
...policy,
MaxSize: "0",
});
}
}}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("limit")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("notLimit")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={policy.MaxSize !== "0"}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("enterSizeLimit")}
</Typography>
<div className={classes.form}>
<SizeInput
value={policy.MaxSize}
onChange={handleChange("MaxSize")}
min={0}
max={9223372036854775807}
label={t("maxSizeOfSingleFile")}
/>
</div>
</div>
</div>
</Collapse>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>
{policy.MaxSize !== "0" ? "3" : "2"}
</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("limitFileExt")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={
policy.OptionsSerialized
.file_type === ""
? "false"
: "true"
}
onChange={(e) => {
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
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("limit")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("notLimit")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={policy.OptionsSerialized.file_type !== ""}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>
{policy.MaxSize !== "0" ? "4" : "3"}
</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("enterFileExt")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("extList")}
</InputLabel>
<Input
value={
policy.OptionsSerialized
.file_type
}
onChange={handleOptionChange(
"file_type"
)}
/>
</FormControl>
</div>
</div>
</div>
</Collapse>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>
{getNumber(3, [
policy.MaxSize !== "0",
policy.OptionsSerialized.file_type !== "",
])}
</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("chunkSizeLabel")}
<br />
{t("chunkSizeDes")}
</Typography>
<div className={classes.form}>
<SizeInput
value={policy.OptionsSerialized.chunk_size}
onChange={handleOptionChange("chunk_size")}
min={0}
max={9223372036854775807}
label={t("chunkSize")}
/>
</div>
</div>
</div>
<div className={classes.stepFooter}>
<Button
color={"default"}
className={classes.button}
onClick={() => setActiveStep(1)}
>
{t("back")}
</Button>{" "}
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("next")}
</Button>
</div>
</form>
)}
{activeStep === 3 && (
<form className={classes.stepContent} onSubmit={submitPolicy}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer} />
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("nameThePolicy")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("policyName")}
</InputLabel>
<Input
required
value={policy.Name}
onChange={handleChange("Name")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.stepFooter}>
<Button
color={"default"}
className={classes.button}
onClick={() => setActiveStep(2)}
>
{t("back")}
</Button>{" "}
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("finish")}
</Button>
</div>
</form>
)}
{activeStep === 4 && (
<>
<form className={classes.stepContent}>
<Typography>
{props.policy ? t("policySaved") : t("policyAdded")}
</Typography>
<Typography variant={"body2"} color={"textSecondary"}>
{t("furtherActions")}
</Typography>
</form>
<div className={classes.stepFooter}>
<Button
color={"primary"}
className={classes.button}
onClick={() => history.push("/admin/policy")}
>
{t("backToList")}
</Button>
</div>
</>
)}
<MagicVar
open={magicVar === "file"}
isFile
onClose={() => setMagicVar("")}
/>
<MagicVar
open={magicVar === "path"}
onClose={() => setMagicVar("")}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -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 (
<div>
<Typography variant={"h6"}>
{props.policy
? t("editUpyunStoragePolicy")
: t("addUpyunStoragePolicy")}
</Typography>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps = {};
const labelProps = {};
if (label.optional) {
labelProps.optional = (
<Typography variant="caption">可选</Typography>
);
}
if (isStepSkipped(index)) {
stepProps.completed = false;
}
return (
<Step key={label.title} {...stepProps}>
<StepLabel {...labelProps}>
{t(label.title)}
</StepLabel>
</Step>
);
})}
</Stepper>
{activeStep === 0 && (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
setActiveStep(1);
}}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>0</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"policy.wanSiteURLDes"}
components={[<strong key={0} />]}
/>
</Typography>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"policy.createUpyunBucketDes"}
components={[
<Link
key={0}
href={
"https://console.upyun.com/services/create/file/"
}
target={"_blank"}
/>,
]}
/>
</Typography>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("storageServiceNameDes")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("storageServiceName")}
</InputLabel>
<Input
required
value={policy.BucketName}
onChange={handleChange("BucketName")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>3</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("operatorNameDes")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("operatorName")}
</InputLabel>
<Input
required
value={policy.AccessKey}
onChange={handleChange("AccessKey")}
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("operatorPassword")}
</InputLabel>
<Input
required
value={policy.SecretKey}
onChange={handleChange("SecretKey")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>4</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("upyunCDNDes")}
</Typography>
<div className={classes.form}>
<DomainInput
value={policy.BaseURL}
onChange={handleChange("BaseURL")}
required
label={t("bucketCDNDomain")}
/>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>5</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("upyunOptionalDes")}
<br />
{t("upyunTokenDes")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={policy.IsPrivate}
onChange={handleChange("IsPrivate")}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("tokenEnabled")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("tokenDisabled")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={policy.IsPrivate === "true"}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>6</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("upyunTokenSecretDes")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("upyunTokenSecret")}
</InputLabel>
<Input
value={
policy.OptionsSerialized.token
}
onChange={handleOptionChange(
"token"
)}
required={
policy.IsPrivate === "true"
}
/>
</FormControl>
</div>
</div>
</div>
</Collapse>
<div className={classes.stepFooter}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("next")}
</Button>
</div>
</form>
)}
{activeStep === 1 && (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
setActiveStep(2);
}}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"policy.pathMagicVarDes"}
components={[
<Link
key={0}
color={"secondary"}
href={"javascript:void()"}
onClick={() => setMagicVar("path")}
/>,
]}
/>
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("pathOfFolderToStoreFiles")}
</InputLabel>
<Input
required
value={policy.DirNameRule}
onChange={handleChange("DirNameRule")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
<Trans
ns={"dashboard"}
i18nKey={"policy.filePathMagicVarDes"}
components={[
<Link
key={0}
color={"secondary"}
href={"javascript:void()"}
onClick={() => setMagicVar("file")}
/>,
]}
/>
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
aria-label="gender"
name="gender1"
value={policy.AutoRename}
onChange={handleChange("AutoRename")}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("autoRenameStoredFile")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("keepOriginalFileName")}
/>
</RadioGroup>
</FormControl>
</div>
<Collapse in={policy.AutoRename === "true"}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("renameRule")}
</InputLabel>
<Input
required={
policy.AutoRename === "true"
}
value={policy.FileNameRule}
onChange={handleChange(
"FileNameRule"
)}
/>
</FormControl>
</div>
</Collapse>
</div>
</div>
<div className={classes.stepFooter}>
<Button
color={"default"}
className={classes.button}
onClick={() => setActiveStep(0)}
>
{t("back")}
</Button>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("next")}
</Button>
</div>
</form>
)}
{activeStep === 2 && (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
setActiveStep(3);
}}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("enableGettingPermanentSourceLink")}
<br />
{t("enableGettingPermanentSourceLinkDes")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={policy.IsOriginLinkEnable}
onChange={(e) => {
if (
policy.IsPrivate === "true" &&
e.target.value === "true"
) {
ToggleSnackbar(
"top",
"right",
t(
"cannotEnableForTokenProtectedBucket"
),
"warning"
);
}
handleChange("IsOriginLinkEnable")(
e
);
}}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("allowed")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("forbidden")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<div className={classes.stepFooter}>
<Button
color={"default"}
className={classes.button}
onClick={() => setActiveStep(1)}
>
{t("back")}
</Button>{" "}
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("next")}
</Button>
</div>
</form>
)}
{activeStep === 3 && (
<form
className={classes.stepContent}
onSubmit={(e) => {
e.preventDefault();
setActiveStep(4);
}}
>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>1</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("limitFileSize")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={
policy.MaxSize === "0"
? "false"
: "true"
}
onChange={(e) => {
if (e.target.value === "true") {
setPolicy({
...policy,
MaxSize: "10485760",
});
} else {
setPolicy({
...policy,
MaxSize: "0",
});
}
}}
row
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("limit")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("notLimit")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={policy.MaxSize !== "0"}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>2</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("enterSizeLimit")}
</Typography>
<div className={classes.form}>
<SizeInput
value={policy.MaxSize}
onChange={handleChange("MaxSize")}
min={0}
max={9223372036854775807}
label={t("maxSizeOfSingleFile")}
/>
</div>
</div>
</div>
</Collapse>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>
{policy.MaxSize !== "0" ? "3" : "2"}
</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("limitFileExt")}
</Typography>
<div className={classes.form}>
<FormControl required component="fieldset">
<RadioGroup
required
value={
policy.OptionsSerialized
.file_type === ""
? "false"
: "true"
}
onChange={(e) => {
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
>
<FormControlLabel
value={"true"}
control={
<Radio color={"primary"} />
}
label={t("limit")}
/>
<FormControlLabel
value={"false"}
control={
<Radio color={"primary"} />
}
label={t("notLimit")}
/>
</RadioGroup>
</FormControl>
</div>
</div>
</div>
<Collapse in={policy.OptionsSerialized.file_type !== ""}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}>
<div className={classes.stepNumber}>
{policy.MaxSize !== "0" ? "4" : "3"}
</div>
</div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("enterFileExt")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("extList")}
</InputLabel>
<Input
value={
policy.OptionsSerialized
.file_type
}
onChange={handleOptionChange(
"file_type"
)}
/>
</FormControl>
</div>
</div>
</div>
</Collapse>
<div className={classes.stepFooter}>
<Button
color={"default"}
className={classes.button}
onClick={() => setActiveStep(2)}
>
{t("back")}
</Button>{" "}
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("next")}
</Button>
</div>
</form>
)}
{activeStep === 4 && (
<form className={classes.stepContent} onSubmit={submitPolicy}>
<div className={classes.subStepContainer}>
<div className={classes.stepNumberContainer}></div>
<div className={classes.subStepContent}>
<Typography variant={"body2"}>
{t("nameThePolicy")}
</Typography>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("policyName")}
</InputLabel>
<Input
required
value={policy.Name}
onChange={handleChange("Name")}
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.stepFooter}>
<Button
color={"default"}
className={classes.button}
onClick={() => setActiveStep(3)}
>
{t("back")}
</Button>{" "}
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("finish")}
</Button>
</div>
</form>
)}
{activeStep === 5 && (
<>
<form className={classes.stepContent}>
<Typography>
{props.policy ? t("policySaved") : t("policyAdded")}
</Typography>
<Typography variant={"body2"} color={"textSecondary"}>
{t("furtherActions")}
</Typography>
</form>
<div className={classes.stepFooter}>
<Button
color={"primary"}
className={classes.button}
onClick={() => history.push("/admin/policy")}
>
{t("backToList")}
</Button>
</div>
</>
)}
<MagicVar
open={magicVar === "file"}
isFile
onClose={() => setMagicVar("")}
/>
<MagicVar
open={magicVar === "path"}
onClose={() => setMagicVar("")}
/>
</div>
);
}

@ -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 (
<div>
<AddPolicy open={addDialog} onClose={() => setAddDialog(false)} />
<div className={classes.header}>
<Button
color={"primary"}
onClick={() => setAddDialog(true)}
variant={"contained"}
>
{t("newStoragePolicy")}
</Button>
<div className={classes.headerRight}>
<Select
style={{
marginRight: 8,
}}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
{[
"all",
"local",
"remote",
"qiniu",
"upyun",
"oss",
"cos",
"onedrive",
"s3",
].map((v) => (
<MenuItem key={v} value={v}>
{t(v)}
</MenuItem>
))}
</Select>
<Button
color={"primary"}
onClick={() => loadList()}
variant={"outlined"}
>
{t("refresh")}
</Button>
</div>
</div>
<Paper square className={classes.tableContainer}>
<TableContainer className={classes.container}>
<Table aria-label="sticky table" size={"small"}>
<TableHead>
<TableRow style={{ height: 52 }}>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
style={{ minWidth: column.minWidth }}
>
{t(column.label)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{policies.map((row) => (
<TableRow hover key={row.ID}>
<TableCell>{row.ID}</TableCell>
<TableCell>{row.Name}</TableCell>
<TableCell>{t(row.Type)}</TableCell>
<TableCell align={"right"}>
{statics[row.ID] !== undefined &&
statics[row.ID][0].toLocaleString()}
</TableCell>
<TableCell align={"right"}>
{statics[row.ID] !== undefined &&
sizeToString(statics[row.ID][1])}
</TableCell>
<TableCell align={"right"}>
<Tooltip title={t("delete")}>
<IconButton
onClick={() =>
deletePolicy(row.ID)
}
size={"small"}
>
<Delete />
</IconButton>
</Tooltip>
<Tooltip title={t("edit")}>
<IconButton
onClick={(e) => {
setEditID(row.ID);
handleClick(e);
}}
size={"small"}
>
<Edit />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page - 1}
onChangePage={(e, p) => setPage(p + 1)}
onChangeRowsPerPage={(e) => {
setPageSize(e.target.value);
setPage(1);
}}
/>
</Paper>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
keepMounted
>
<MenuItem
onClick={(e) => {
handleClose(e);
history.push("/admin/policy/edit/pro/" + editID);
}}
>
{t("editInProMode")}
</MenuItem>
<MenuItem
onClick={(e) => {
handleClose(e);
history.push("/admin/policy/edit/guide/" + editID);
}}
>
{t("editInWizardMode")}
</MenuItem>
</Menu>
</div>
);
}

@ -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;
};

@ -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 (
<div>
<div className={classes.header}>
<div className={classes.headerRight}>
<Button
color={"primary"}
onClick={() => loadList()}
variant={"outlined"}
>
{tDashboard("policy.refresh")}
</Button>
</div>
</div>
<Paper square className={classes.tableContainer}>
{selected.length > 0 && (
<Toolbar className={classes.highlight}>
<Typography
style={{ flex: "1 1 100%" }}
color="inherit"
variant="subtitle1"
>
{tDashboard("user.selectedObjects", {
num: selected.length,
})}
</Typography>
<Tooltip title={t("markAsResolved")}>
<IconButton
onClick={deleteBatch}
disabled={loading}
aria-label="delete"
>
<CheckCircleOutlineIcon />
</IconButton>
</Tooltip>
</Toolbar>
)}
<TableContainer className={classes.container}>
<Table aria-label="sticky table" size={"small"}>
<TableHead>
<TableRow style={{ height: 52 }}>
<TableCell padding="checkbox">
<Checkbox
indeterminate={
selected.length > 0 &&
selected.length < reports.length
}
checked={
reports.length > 0 &&
selected.length === reports.length
}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all desserts",
}}
/>
</TableCell>
<TableCell style={{ minWidth: 10 }}>
<TableSortLabel
active={orderBy[0] === "id"}
direction={orderBy[1]}
onClick={() =>
setOrderBy([
"id",
orderBy[1] === "asc"
? "desc"
: "asc",
])
}
>
#
{orderBy[0] === "id" ? (
<span
className={
classes.visuallyHidden
}
>
{orderBy[1] === "desc"
? "sorted descending"
: "sorted ascending"}
</span>
) : null}
</TableSortLabel>
</TableCell>
<TableCell style={{ minWidth: 130 }}>
{t("reportedContent")}
</TableCell>
<TableCell style={{ minWidth: 90 }}>
{tDashboard("policy.type")}
</TableCell>
<TableCell style={{ minWidth: 90 }}>
{t("reason")}
</TableCell>
<TableCell style={{ minWidth: 150 }}>
{t("description")}
</TableCell>
<TableCell style={{ minWidth: 100 }}>
{tDashboard("vas.shareLink")}
</TableCell>
<TableCell style={{ minWidth: 150 }}>
{t("reportTime")}
</TableCell>
<TableCell style={{ minWidth: 80 }}>
{tDashboard("policy.actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{reports.map((row) => (
<TableRow
hover
key={row.ID}
role="checkbox"
selected={isSelected(row.ID)}
>
<TableCell padding="checkbox">
<Checkbox
onClick={(event) =>
handleClick(event, row.ID)
}
checked={isSelected(row.ID)}
/>
</TableCell>
<TableCell>{row.ID}</TableCell>
<TableCell
style={{ wordBreak: "break-all" }}
>
{row.Share.ID === 0 && t("invalid")}
{row.Share.ID > 0 && (
<Link
target={"_blank"}
color="inherit"
href={
"/s/" +
ids[row.Share.ID] +
(row.Share.Password === ""
? ""
: "?password=" +
row.Share.Password)
}
>
{row.Share.SourceName}
</Link>
)}
</TableCell>
<TableCell>
{row.Share.ID > 0 &&
(row.Share.IsDir
? tDashboard("share.folder")
: tDashboard("share.file"))}
</TableCell>
<TableCell>
{tApp(reportReasons[row.Reason])}
</TableCell>
<TableCell
style={{ wordBreak: "break-all" }}
>
{row.Description}
</TableCell>
<TableCell>
<Link
href={
"/admin/user/edit/" +
row.Share.UserID
}
>
{users[row.Share.UserID]
? users[row.Share.UserID].Nick
: tDashboard(
"file.unknownUploader"
)}
</Link>
</TableCell>
<TableCell>
{formatLocalTime(row.CreatedAt)}
</TableCell>
<TableCell>
<Tooltip title={t("markAsResolved")}>
<IconButton
disabled={loading}
onClick={() =>
deleteReport(row.ID)
}
size={"small"}
>
<CheckCircleOutlineIcon />
</IconButton>
</Tooltip>
{row.Share.ID > 0 && (
<Tooltip title={t("deleteShare")}>
<IconButton
disabled={loading}
onClick={() =>
deleteShare(
row.Share.ID
)
}
size={"small"}
>
<Delete />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 50, 100]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page - 1}
onChangePage={(e, p) => setPage(p + 1)}
onChangeRowsPerPage={(e) => {
setPageSize(e.target.value);
setPage(1);
}}
/>
</Paper>
</div>
);
}

@ -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 (
<div>
<AlertDialog
title={t("hint")}
msg={t("webauthnNoHttps")}
onClose={() => setHttpAlert(false)}
open={httpAlert}
/>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("accountManagement")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.register_enabled === "1"
}
onChange={handleChange(
"register_enabled"
)}
/>
}
label={t("allowNewRegistrations")}
/>
<FormHelperText id="component-helper-text">
{t("allowNewRegistrationsDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.email_active === "1"
}
onChange={handleChange(
"email_active"
)}
/>
}
label={t("emailActivation")}
/>
<FormHelperText id="component-helper-text">
{t("emailActivationDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.reg_captcha === "1"
}
onChange={handleChange(
"reg_captcha"
)}
/>
}
label={t("captchaForSignup")}
/>
<FormHelperText id="component-helper-text">
{t("captchaForSignupDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.login_captcha === "1"
}
onChange={handleChange(
"login_captcha"
)}
/>
}
label={t("captchaForLogin")}
/>
<FormHelperText id="component-helper-text">
{t("captchaForLoginDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.forget_captcha === "1"
}
onChange={handleChange(
"forget_captcha"
)}
/>
}
label={t("captchaForReset")}
/>
<FormHelperText id="component-helper-text">
{t("captchaForResetDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.authn_enabled === "1"
}
onChange={(e) => {
if (
!siteURL.startsWith(
"https://"
)
) {
setHttpAlert(true);
return;
}
handleChange("authn_enabled")(
e
);
}}
/>
}
label={t("webauthn")}
/>
<FormHelperText id="component-helper-text">
{t("webauthnDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("defaultGroup")}
</InputLabel>
<Select
value={options.default_group}
onChange={handleInputChange(
"default_group"
)}
required
>
{groups.map((v) => {
if (v.ID === 3) {
return null;
}
return (
<MenuItem
key={v.ID}
value={v.ID.toString()}
>
{v.Name}
</MenuItem>
);
})}
</Select>
<FormHelperText id="component-helper-text">
{t("defaultGroupDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
{initCompleted && (
<FileSelector
label={tVas("initialFiles")}
value={JSON.parse(
options.initial_files
)}
onChange={(v) =>
handleInputChange("initial_files")({
target: { value: v },
})
}
/>
)}
<FormHelperText id="component-helper-text">
{tVas("initialFilesDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{tVas("filterEmailProvider")}
</InputLabel>
<Select
value={options.mail_domain_filter}
onChange={handleInputChange(
"mail_domain_filter"
)}
required
>
{[
tVas("filterEmailProviderDisabled"),
tVas("filterEmailProviderWhitelist"),
tVas("filterEmailProviderBlacklist"),
].map((v, i) => (
<MenuItem key={i} value={i.toString()}>
{v}
</MenuItem>
))}
</Select>
<FormHelperText id="component-helper-text">
{tVas("filterEmailProviderDes")}
</FormHelperText>
</FormControl>
</div>
{options.mail_domain_filter !== "0" && (
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tVas("filterEmailProviderRule")}
</InputLabel>
<Input
value={options.mail_domain_filter_list}
onChange={handleChange(
"mail_domain_filter_list"
)}
multiline
rowsMax="10"
/>
<FormHelperText id="component-helper-text">
{tVas("filterEmailProviderRuleDes")}
</FormHelperText>
</FormControl>
</div>
)}
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{tVas("qqConnect")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<Alert severity="info">
{tVas("qqConnectHint", {
url: siteURL.endsWith("/")
? siteURL + "login/qq"
: siteURL + "/login/qq",
})}
</Alert>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={options.qq_login === "1"}
onChange={handleChange("qq_login")}
/>
}
label={tVas("enableQQConnect")}
/>
<FormHelperText id="component-helper-text">
{tVas("enableQQConnectDes")}
</FormHelperText>
</FormControl>
</div>
{options.qq_login === "1" && (
<>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.qq_direct_login ===
"1"
}
onChange={handleChange(
"qq_direct_login"
)}
/>
}
label={tVas("loginWithoutBinding")}
/>
<FormHelperText id="component-helper-text">
{tVas("loginWithoutBindingDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tVas("appid")}
</InputLabel>
<Input
required
value={options.qq_login_id}
onChange={handleInputChange(
"qq_login_id"
)}
/>
<FormHelperText id="component-helper-text">
{tVas("appidDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tVas("appKey")}
</InputLabel>
<Input
required
value={options.qq_login_key}
onChange={handleInputChange(
"qq_login_key"
)}
/>
<FormHelperText id="component-helper-text">
{tVas("appKeyDes")}
</FormHelperText>
</FormControl>
</div>
</>
)}
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("save")}
</Button>
</div>
</form>
</div>
);
}

@ -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 (
<div>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("captcha")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("captchaType")}
</InputLabel>
<Select
value={options.captcha_type}
onChange={handleChange("captcha_type")}
required
>
<MenuItem value={"normal"}>
{t("plainCaptcha")}
</MenuItem>
<MenuItem value={"recaptcha"}>
{t("reCaptchaV2")}
</MenuItem>
<MenuItem value={"tcaptcha"}>
{t("tencentCloudCaptcha")}
</MenuItem>
</Select>
<FormHelperText id="component-helper-text">
{t("captchaProvider")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
{options.captcha_type === "normal" && (
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("plainCaptchaTitle")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("captchaWidth")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.captcha_width}
onChange={handleChange("captcha_width")}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("captchaHeight")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.captcha_height}
onChange={handleChange(
"captcha_height"
)}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("captchaLength")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.captcha_CaptchaLen}
onChange={handleChange(
"captcha_CaptchaLen"
)}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("captchaMode")}
</InputLabel>
<Select
value={options.captcha_mode}
onChange={handleChange("captcha_mode")}
required
>
<MenuItem value={"0"}>
{t("captchaModeNumber")}
</MenuItem>
<MenuItem value={"1"}>
{t("captchaModeLetter")}
</MenuItem>
<MenuItem value={"2"}>
{t("captchaModeMath")}
</MenuItem>
<MenuItem value={"3"}>
{t("captchaModeNumberLetter")}
</MenuItem>
</Select>
<FormHelperText id="component-helper-text">
{t("captchaElement")}
</FormHelperText>
</FormControl>
</div>
{[
{
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) => (
<div key={input.name} className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options[input.field] ===
"1"
}
onChange={handleCheckChange(
input.field
)}
/>
}
label={t(input.name)}
/>
</FormControl>
</div>
))}
</div>
</div>
)}
{options.captcha_type === "recaptcha" && (
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("reCaptchaV2")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("siteKey")}
</InputLabel>
<Input
required
value={options.captcha_ReCaptchaKey}
onChange={handleChange(
"captcha_ReCaptchaKey"
)}
/>
<FormHelperText id="component-helper-text">
<Trans
ns={"dashboard"}
i18nKey={"settings.siteKeyDes"}
components={[
<Link
key={0}
href={
"https://www.google.com/recaptcha/admin/create"
}
target={"_blank"}
/>,
]}
/>
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("siteSecret")}
</InputLabel>
<Input
required
value={
options.captcha_ReCaptchaSecret
}
onChange={handleChange(
"captcha_ReCaptchaSecret"
)}
/>
<FormHelperText id="component-helper-text">
<Trans
ns={"dashboard"}
i18nKey={
"settings.siteSecretDes"
}
components={[
<Link
key={0}
href={
"https://www.google.com/recaptcha/admin/create"
}
target={"_blank"}
/>,
]}
/>
</FormHelperText>
</FormControl>
</div>
</div>
</div>
</div>
)}
{options.captcha_type === "tcaptcha" && (
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("tencentCloudCaptcha")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("secretID")}
</InputLabel>
<Input
required
value={
options.captcha_TCaptcha_SecretId
}
onChange={handleChange(
"captcha_TCaptcha_SecretId"
)}
/>
<FormHelperText id="component-helper-text">
<Trans
ns={"dashboard"}
i18nKey={
"settings.siteSecretDes"
}
components={[
<Link
key={0}
href={
"https://console.cloud.tencent.com/capi"
}
target={"_blank"}
/>,
]}
/>
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("secretKey")}
</InputLabel>
<Input
required
value={
options.captcha_TCaptcha_SecretKey
}
onChange={handleChange(
"captcha_TCaptcha_SecretKey"
)}
/>
<FormHelperText id="component-helper-text">
<Trans
ns={"dashboard"}
i18nKey={
"settings.secretKeyDes"
}
components={[
<Link
key={0}
href={
"https://console.cloud.tencent.com/capi"
}
target={"_blank"}
/>,
]}
/>
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("tCaptchaAppID")}
</InputLabel>
<Input
required
value={
options.captcha_TCaptcha_CaptchaAppId
}
onChange={handleChange(
"captcha_TCaptcha_CaptchaAppId"
)}
/>
<FormHelperText id="component-helper-text">
<Trans
ns={"dashboard"}
i18nKey={
"settings.tCaptchaAppIDDes"
}
components={[
<Link
key={0}
href={
"https://console.cloud.tencent.com/captcha/graphical"
}
target={"_blank"}
/>,
]}
/>
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("tCaptchaSecretKey")}
</InputLabel>
<Input
required
value={
options.captcha_TCaptcha_AppSecretKey
}
onChange={handleChange(
"captcha_TCaptcha_AppSecretKey"
)}
/>
<FormHelperText id="component-helper-text">
<Trans
ns={"dashboard"}
i18nKey={
"settings.tCaptchaSecretKeyDes"
}
components={[
<Link
key={0}
href={
"https://console.cloud.tencent.com/captcha/graphical"
}
target={"_blank"}
/>,
]}
/>
</FormHelperText>
</FormControl>
</div>
</div>
</div>
</div>
)}
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("save")}
</Button>
</div>
</form>
</div>
);
}

@ -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 (
<div>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("avatar")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("gravatarServer")}
</InputLabel>
<Input
type={"url"}
value={options.gravatar_server}
onChange={handleChange("gravatar_server")}
required
/>
<FormHelperText id="component-helper-text">
{t("gravatarServerDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("avatarFilePath")}
</InputLabel>
<Input
value={options.avatar_path}
onChange={handleChange("avatar_path")}
required
/>
<FormHelperText id="component-helper-text">
{t("avatarFilePathDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
{options.avatar_size !== "" && (
<SizeInput
value={options.avatar_size}
onChange={handleChange("avatar_size")}
required
min={0}
max={2147483647}
label={t("avatarSize")}
/>
)}
<FormHelperText id="component-helper-text">
{t("avatarSizeDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("smallAvatarSize")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.avatar_size_s}
onChange={handleChange("avatar_size_s")}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("mediumAvatarSize")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.avatar_size_m}
onChange={handleChange("avatar_size_m")}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("largeAvatarSize")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.avatar_size_l}
onChange={handleChange("avatar_size_l")}
required
/>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("filePreview")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("officePreviewService")}
</InputLabel>
<Input
value={options.office_preview_service}
onChange={handleChange(
"office_preview_service"
)}
required
/>
<FormHelperText id="component-helper-text">
{t("officePreviewServiceDes")}
<br />
<code>{"{$src}"}</code> -{" "}
{t("officePreviewServiceSrcDes")}
<br />
<code>{"{$srcB64}"}</code> -{" "}
{t("officePreviewServiceSrcB64Des")}
<br />
<code>{"{$name}"}</code> -{" "}
{t("officePreviewServiceName")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
{options.maxEditSize !== "" && (
<SizeInput
value={options.maxEditSize}
onChange={handleChange("maxEditSize")}
required
min={0}
max={2147483647}
label={t("textEditMaxSize")}
/>
)}
<FormHelperText id="component-helper-text">
{t("textEditMaxSizeDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("wopiClient")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<Alert severity="info">
<Trans
ns={"dashboard"}
i18nKey={"settings.wopiClientDes"}
components={[
<Link
key={0}
target={"_blank"}
href={t("wopiDocLink")}
/>,
]}
/>
</Alert>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.wopi_enabled === "1"
}
onChange={handleCheckChange(
"wopi_enabled"
)}
/>
}
label={t("enableWopi")}
/>
</FormControl>
</div>
{options.wopi_enabled === "1" && (
<>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("wopiEndpoint")}
</InputLabel>
<Input
value={options.wopi_endpoint}
onChange={handleChange(
"wopi_endpoint"
)}
required
/>
<FormHelperText id="component-helper-text">
{t("wopiEndpointDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("wopiSessionTtl")}
</InputLabel>
<Input
inputProps={{ min: 1, step: 1 }}
type={"number"}
value={options.wopi_session_timeout}
onChange={handleChange(
"wopi_session_timeout"
)}
required
/>
<FormHelperText id="component-helper-text">
{t("wopiSessionTtlDes")}
</FormHelperText>
</FormControl>
</div>
</>
)}
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("thumbnails")}
</Typography>
<div className={classes.form}>
<Alert severity="info">
<Trans
ns={"dashboard"}
i18nKey={"settings.thumbnailDoc"}
components={[
<Link
key={0}
target={"_blank"}
href={t("thumbnailDocLink")}
/>,
]}
/>
</Alert>
</div>
<Typography variant="subtitle1" gutterBottom>
{t("thumbnailBasic")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("thumbWidth")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.thumb_width}
onChange={handleChange("thumb_width")}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("thumbHeight")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.thumb_height}
onChange={handleChange("thumb_height")}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("thumbSuffix")}
</InputLabel>
<Input
type={"text"}
value={options.thumb_file_suffix}
onChange={handleChange("thumb_file_suffix")}
required
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("thumbConcurrent")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: -1,
step: 1,
}}
value={options.thumb_max_task_count}
onChange={handleChange(
"thumb_max_task_count"
)}
required
/>
<FormHelperText id="component-helper-text">
{t("thumbConcurrentDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("thumbFormat")}
</InputLabel>
<Input
type={"test"}
value={options.thumb_encode_method}
onChange={handleChange(
"thumb_encode_method"
)}
required
/>
<FormHelperText id="component-helper-text">
{t("thumbFormatDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("thumbQuality")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
max: 100,
}}
value={options.thumb_encode_quality}
onChange={handleChange(
"thumb_encode_quality"
)}
required
/>
<FormHelperText id="component-helper-text">
{t("thumbQualityDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
{options.thumb_max_src_size !== "" && (
<SizeInput
value={options.thumb_max_src_size}
onChange={handleChange(
"thumb_max_src_size"
)}
required
min={0}
max={2147483647}
label={t("thumbMaxSize")}
/>
)}
<FormHelperText id="component-helper-text">
{t("thumbMaxSizeDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.thumb_gc_after_gen ===
"1"
}
onChange={handleCheckChange(
"thumb_gc_after_gen"
)}
/>
}
label={t("thumbGC")}
/>
</FormControl>
</div>
</div>
<Typography variant="subtitle1" gutterBottom>
{t("generators")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<ThumbGenerators
options={options}
setOptions={setOptions}
/>
</div>
</div>
<Typography variant="subtitle1" gutterBottom>
{t("generatorProxy")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<Alert severity="info">
{t("generatorProxyWarning")}
</Alert>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.thumb_proxy_enabled ===
"1"
}
onChange={handleCheckChange(
"thumb_proxy_enabled"
)}
/>
}
label={t("enableThumbProxy")}
/>
</FormControl>
</div>
{options.thumb_proxy_enabled === "1" && (
<>
<div className={classes.form}>
<PolicySelector
value={options.thumb_proxy_policy}
onChange={handleChange(
"thumb_proxy_policy"
)}
filter={(t) => t.Type !== "local"}
label={t("proxyPolicyList")}
helperText={t("proxyPolicyListDes")}
/>
</div>
</>
)}
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("save")}
</Button>
</div>
</form>
</div>
);
}

@ -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 (
<div>
<Dialog
open={test}
onClose={() => setTest(false)}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title">
{t("testSMTPSettings")}
</DialogTitle>
<DialogContent>
<DialogContentText>
<Typography>{t("testSMTPTooltip")}</Typography>
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="name"
label={t("recipient")}
value={tesInput}
onChange={(e) => setTestInput(e.target.value)}
type="email"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setTest(false)} color="default">
{tGlobal("cancel")}
</Button>
<Button
onClick={() => sendTestMail()}
disabled={loading}
color="primary"
>
{t("send")}
</Button>
</DialogActions>
</Dialog>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("smtp")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("senderName")}
</InputLabel>
<Input
value={options.fromName}
onChange={handleChange("fromName")}
required
/>
<FormHelperText id="component-helper-text">
{t("senderNameDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("senderAddress")}
</InputLabel>
<Input
type={"email"}
required
value={options.fromAdress}
onChange={handleChange("fromAdress")}
/>
<FormHelperText id="component-helper-text">
{t("senderAddressDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("smtpServer")}
</InputLabel>
<Input
value={options.smtpHost}
onChange={handleChange("smtpHost")}
required
/>
<FormHelperText id="component-helper-text">
{t("smtpServerDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("smtpPort")}
</InputLabel>
<Input
inputProps={{ min: 1, step: 1 }}
type={"number"}
value={options.smtpPort}
onChange={handleChange("smtpPort")}
required
/>
<FormHelperText id="component-helper-text">
{t("smtpPortDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("smtpUsername")}
</InputLabel>
<Input
value={options.smtpUser}
onChange={handleChange("smtpUser")}
required
/>
<FormHelperText id="component-helper-text">
{t("smtpUsernameDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("smtpPassword")}
</InputLabel>
<Input
type={"password"}
value={options.smtpPass}
onChange={handleChange("smtpPass")}
required
/>
<FormHelperText id="component-helper-text">
{t("smtpPasswordDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("replyToAddress")}
</InputLabel>
<Input
value={options.replyTo}
onChange={handleChange("replyTo")}
required
/>
<FormHelperText id="component-helper-text">
{t("replyToAddressDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.smtpEncryption === "1"
}
onChange={handleCheckChange(
"smtpEncryption"
)}
/>
}
label={t("enforceSSL")}
/>
<FormHelperText id="component-helper-text">
{t("enforceSSLDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("smtpTTL")}
</InputLabel>
<Input
inputProps={{ min: 1, step: 1 }}
type={"number"}
value={options.mail_keepalive}
onChange={handleChange("mail_keepalive")}
required
/>
<FormHelperText id="component-helper-text">
{t("smtpTTLDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("emailTemplates")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("activateNewUser")}
</InputLabel>
<Input
value={options.mail_activation_template}
onChange={handleChange(
"mail_activation_template"
)}
multiline
rowsMax="10"
required
/>
<FormHelperText id="component-helper-text">
{t("activateNewUserDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tVas("overuseReminder")}
</InputLabel>
<Input
value={options.over_used_template}
onChange={handleChange(
"over_used_template"
)}
multiline
rowsMax="10"
required
/>
<FormHelperText id="component-helper-text">
{tVas("overuseReminderDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("resetPassword")}
</InputLabel>
<Input
value={options.mail_reset_pwd_template}
onChange={handleChange(
"mail_reset_pwd_template"
)}
multiline
rowsMax="10"
required
/>
<FormHelperText id="component-helper-text">
{t("resetPasswordDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("save")}
</Button>
{" "}
<Button
className={classes.buttonMargin}
variant={"outlined"}
color={"primary"}
onClick={() => setTest(true)}
>
{t("sendTestEmail")}
</Button>
</div>
</form>
</div>
);
}

@ -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 (
<div>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("basicInformation")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("mainTitle")}
</InputLabel>
<Input
value={options.siteName}
onChange={handleChange("siteName")}
required
/>
<FormHelperText id="component-helper-text">
{t("mainTitleDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("subTitle")}
</InputLabel>
<Input
value={options.siteTitle}
onChange={handleChange("siteTitle")}
/>
<FormHelperText id="component-helper-text">
{t("subTitleDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("siteKeywords")}
</InputLabel>
<Input
value={options.siteKeywords}
onChange={handleChange("siteKeywords")}
/>
<FormHelperText id="component-helper-text">
{t("siteKeywordsDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("siteDescription")}
</InputLabel>
<Input
value={options.siteDes}
onChange={handleChange("siteDes")}
/>
<FormHelperText id="component-helper-text">
{t("siteDescriptionDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("siteURL")}
</InputLabel>
<Input
type={"url"}
value={options.siteURL}
onChange={handleChange("siteURL")}
required
/>
<FormHelperText id="component-helper-text">
{t("siteURLDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("customFooterHTML")}
</InputLabel>
<Input
multiline
value={options.siteScript}
onChange={handleChange("siteScript")}
/>
<FormHelperText id="component-helper-text">
{t("customFooterHTMLDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("announcement")}
</InputLabel>
<Input
placeholder={t("supportHTML")}
multiline
value={options.siteNotice}
onChange={handleChange("siteNotice")}
/>
<FormHelperText id="component-helper-text">
{t("announcementDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{tVas("mobileApp")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<Alert severity="info">
<Typography variant="body2">
<Trans
ns={"dashboard"}
i18nKey={"vas.volPurchase"}
components={[
<Link
key={0}
href={
"https://cloudreve.org/login"
}
target={"_blank"}
/>,
<Link
key={1}
href={
"https://cloudreve.org/ios"
}
target={"_blank"}
/>,
]}
/>
</Typography>
</Alert>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tVas("iosVol")}
</InputLabel>
<Input
startAdornment={
<InputAdornment position="start">
{vol ? (
<CheckCircle
style={{
color: green[500],
}}
/>
) : (
<Cancel color={"error"} />
)}
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<Tooltip
title={tVas("syncLicense")}
>
<IconButton
disabled={loading}
onClick={() => syncVol()}
aria-label="toggle password visibility"
>
<Sync />
</IconButton>
</Tooltip>
</InputAdornment>
}
readOnly
value={
vol ? vol.domain : tGlobal("share.none")
}
/>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.show_app_promotion ===
"1"
}
onChange={handleOptionChange(
"show_app_promotion"
)}
/>
}
label={tVas("showAppPromotion")}
/>
<FormHelperText id="component-helper-text">
{tVas("showAppPromotionDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tVas("appFeedback")}
</InputLabel>
<Input
value={options.app_feedback_link}
onChange={handleChange("app_feedback_link")}
/>
<FormHelperText id="component-helper-text">
{tVas("appLinkDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{tVas("appForum")}
</InputLabel>
<Input
value={options.app_forum_link}
onChange={handleChange("app_forum_link")}
/>
<FormHelperText id="component-helper-text">
{tVas("appLinkDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("pwa")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("smallIcon")}
</InputLabel>
<Input
value={options.pwa_small_icon}
onChange={handleChange("pwa_small_icon")}
/>
<FormHelperText id="component-helper-text">
{t("smallIconDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("mediumIcon")}
</InputLabel>
<Input
value={options.pwa_medium_icon}
onChange={handleChange("pwa_medium_icon")}
/>
<FormHelperText id="component-helper-text">
{t("mediumIconDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("largeIcon")}
</InputLabel>
<Input
value={options.pwa_large_icon}
onChange={handleChange("pwa_large_icon")}
/>
<FormHelperText id="component-helper-text">
{t("largeIconDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("displayMode")}
</InputLabel>
<Select
value={options.pwa_display}
onChange={handleChange("pwa_display")}
>
<MenuItem value={"fullscreen"}>
fullscreen
</MenuItem>
<MenuItem value={"standalone"}>
standalone
</MenuItem>
<MenuItem value={"minimal-ui"}>
minimal-ui
</MenuItem>
<MenuItem value={"browser"}>
browser
</MenuItem>
</Select>
<FormHelperText id="component-helper-text">
{t("displayModeDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("themeColor")}
</InputLabel>
<Input
value={options.pwa_theme_color}
onChange={handleChange("pwa_theme_color")}
/>
<FormHelperText id="component-helper-text">
{t("themeColorDes")}
</FormHelperText>
</FormControl>
</div>
</div>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("backgroundColor")}
</InputLabel>
<Input
value={options.pwa_background_color}
onChange={handleChange(
"pwa_background_color"
)}
/>
<FormHelperText id="component-helper-text">
{t("backgroundColorDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("save")}
</Button>
</div>
</form>
</div>
);
}

@ -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 (
<div>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("themes")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>{t("colors")}</TableCell>
<TableCell>
{t("themeConfig")}
</TableCell>
<TableCell>{t("actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.keys(theme).map((k) => (
<TableRow key={k}>
<TableCell
component="th"
scope="row"
>
<div
className={
classes.colorContainer
}
>
<div
style={{
backgroundColor:
theme[k].palette
.primary
.main,
}}
className={
classes.colorDot
}
/>
<div
style={{
backgroundColor:
theme[k].palette
.secondary
.main,
}}
className={
classes.colorDot
}
/>
</div>
</TableCell>
<TableCell>
<TextField
error={themeConfigError[k]}
helperText={
themeConfigError[k] &&
t("wrongFormat")
}
fullWidth
multiline
onChange={(e) => {
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]}
/>
</TableCell>
<TableCell>
<IconButton
onClick={() =>
deleteTheme(k)
}
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div>
<Button
variant="outlined"
color="primary"
style={{ marginTop: 8 }}
onClick={() => setCreate(true)}
>
{t("createNewTheme")}
</Button>
</div>
<Alert severity="info" style={{ marginTop: 8 }}>
<Typography variant="body2">
<Trans
i18nKey={"settings.themeConfigDes"}
ns={"dashboard"}
components={[
<Link
key={0}
href={t("themeConfigDoc")}
target={"_blank"}
/>,
]}
/>
</Typography>
</Alert>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("defaultTheme")}
</InputLabel>
<Select
value={options.defaultTheme}
onChange={handleChange("defaultTheme")}
>
{Object.keys(theme).map((k) => (
<MenuItem key={k} value={k}>
<div
className={
classes.colorContainer
}
>
<div
style={{
backgroundColor:
theme[k].palette
.primary.main,
}}
className={classes.colorDot}
/>
<div
style={{
backgroundColor:
theme[k].palette
.secondary.main,
}}
className={classes.colorDot}
/>
</div>
</MenuItem>
))}
</Select>
<FormHelperText id="component-helper-text">
{t("defaultThemeDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("appearance")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("personalFileListView")}
</InputLabel>
<Select
value={options.home_view_method}
onChange={handleChange("home_view_method")}
required
>
<MenuItem value={"icon"}>
{tApp("fileManager.gridViewLarge")}
</MenuItem>
<MenuItem value={"smallIcon"}>
{tApp("fileManager.gridViewSmall")}
</MenuItem>
<MenuItem value={"list"}>
{tApp("fileManager.listView")}
</MenuItem>
</Select>
<FormHelperText id="component-helper-text">
{t("personalFileListViewDes")}
</FormHelperText>
</FormControl>
</div>
</div>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("sharedFileListView")}
</InputLabel>
<Select
value={options.share_view_method}
onChange={handleChange("share_view_method")}
required
>
<MenuItem value={"icon"}>
{tApp("fileManager.gridViewLarge")}
</MenuItem>
<MenuItem value={"smallIcon"}>
{tApp("fileManager.gridViewSmall")}
</MenuItem>
<MenuItem value={"list"}>
{tApp("fileManager.listView")}
</MenuItem>
</Select>
<FormHelperText id="component-helper-text">
{t("sharedFileListViewDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("save")}
</Button>
</div>
</form>
<CreateTheme
onSubmit={addTheme}
open={create}
onClose={() => setCreate(false)}
/>
</div>
);
}

@ -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 (
<div className={classes.root}>
{generators.map((generator) => (
<Accordion key={generator.name}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-label="Expand"
aria-controls="additional-actions1-content"
id="additional-actions1-header"
>
<FormControlLabel
aria-label="Acknowledge"
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.stopPropagation()}
control={
<Checkbox
checked={
generator.readOnly ||
options[generator.enableFlag] === "1"
}
onChange={handleEnableChange(
generator.enableFlag
)}
/>
}
label={t(generator.name)}
disabled={generator.readOnly}
/>
</AccordionSummary>
<AccordionDetails className={classes.details}>
<Typography color="textSecondary">
{t(generator.des)}
</Typography>
{generator.executableSetting && (
<FormControl margin="normal" fullWidth>
<TextField
label={t("executable")}
variant="outlined"
value={options[generator.executableSetting]}
onChange={handleChange(
generator.executableSetting
)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Button
disabled={loading}
onClick={() =>
testExecutable(
generator.name,
options[
generator
.executableSetting
]
)
}
color="primary"
>
{t("executableTest")}
</Button>
</InputAdornment>
),
}}
required
/>
<FormHelperText id="component-helper-text">
{t("executableDes")}
</FormHelperText>
</FormControl>
)}
{generator.inputs &&
generator.inputs.map((input) => (
<FormControl
key={input.name}
margin="normal"
fullWidth
>
<TextField
label={t(input.label)}
variant="outlined"
value={options[input.name]}
onChange={handleChange(input.name)}
required={!!input.required}
/>
<FormHelperText id="component-helper-text">
{t(input.des)}
</FormHelperText>
</FormControl>
))}
</AccordionDetails>
</Accordion>
))}
</div>
);
}

@ -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 (
<div>
<form onSubmit={submit}>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("transportation")}
</Typography>
<div className={classes.formContainer}>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("workerNum")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.max_worker_num}
onChange={handleChange("max_worker_num")}
required
/>
<FormHelperText id="component-helper-text">
{t("workerNumDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("transitParallelNum")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options.max_parallel_transfer}
onChange={handleChange(
"max_parallel_transfer"
)}
required
/>
<FormHelperText id="component-helper-text">
{t("transitParallelNumDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<InputLabel htmlFor="component-helper">
{t("tempFolder")}
</InputLabel>
<Input
value={options.temp_path}
onChange={handleChange("temp_path")}
required
/>
<FormHelperText id="component-helper-text">
{t("tempFolderDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t("failedChunkRetry")}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 0,
step: 1,
}}
value={options.chunk_retries}
onChange={handleChange("chunk_retries")}
required
/>
<FormHelperText id="component-helper-text">
{t("failedChunkRetryDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.use_temp_chunk_buffer ===
"1"
}
onChange={handleCheckChange(
"use_temp_chunk_buffer"
)}
/>
}
label={t("cacheChunks")}
/>
<FormHelperText id="component-helper-text">
{t("cacheChunksDes")}
</FormHelperText>
</FormControl>
</div>
<div className={classes.form}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={
options.reset_after_upload_failed ===
"1"
}
onChange={handleCheckChange(
"reset_after_upload_failed"
)}
/>
}
label={t("resetConnection")}
/>
<FormHelperText id="component-helper-text">
{t("resetConnectionDes")}
</FormHelperText>
</FormControl>
</div>
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("expirationDuration")}
</Typography>
<div className={classes.formContainer}>
{[
{
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) => (
<div key={input.name} className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t(input.name)}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options[input.field]}
onChange={handleChange(input.field)}
required
/>
{input.des && (
<FormHelperText id="component-helper-text">
{t(input.des)}
</FormHelperText>
)}
</FormControl>
</div>
))}
</div>
</div>
<div className={classes.root}>
<Typography variant="h6" gutterBottom>
{t("nodesCommunication")}
</Typography>
<div className={classes.formContainer}>
{[
{
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) => (
<div key={input.name} className={classes.form}>
<FormControl>
<InputLabel htmlFor="component-helper">
{t(input.name)}
</InputLabel>
<Input
type={"number"}
inputProps={{
min: 1,
step: 1,
}}
value={options[input.field]}
onChange={handleChange(input.field)}
required
/>
<FormHelperText id="component-helper-text">
{t(input.des)}
</FormHelperText>
</FormControl>
</div>
))}
</div>
</div>
<div className={classes.root}>
<Button
disabled={loading}
type={"submit"}
variant={"contained"}
color={"primary"}
>
{t("save")}
</Button>
</div>
</form>
</div>
);
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More