├── .babelrc ├── .electron-vue ├── build.js ├── dev-client.js ├── dev-runner.js ├── webpack.main.config.js ├── webpack.renderer.config.js └── webpack.web.config.js ├── .gitignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── appveyor.yml ├── build └── icons │ ├── 256x256.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── dist ├── electron │ └── .gitkeep └── web │ └── .gitkeep ├── package-lock.json ├── package.json ├── src ├── index.ejs ├── main │ ├── index.dev.js │ └── index.js └── renderer │ ├── App.vue │ ├── api │ ├── electron.js │ ├── job.js │ ├── message.js │ ├── notification.js │ ├── profile.js │ ├── session.js │ ├── thumnail.js │ ├── tool.js │ ├── upyunClient.js │ ├── upyunFtp.js │ └── user.js │ ├── assets │ ├── iconfont.js │ └── icons │ │ ├── css.svg │ │ ├── file.svg │ │ ├── folder.svg │ │ ├── html.svg │ │ ├── image.svg │ │ ├── javascript.svg │ │ ├── json.svg │ │ ├── markdown.svg │ │ ├── movie.svg │ │ ├── music.svg │ │ └── zip.svg │ ├── components │ ├── ConfirmModal.vue │ ├── Icon.vue │ ├── LocalImage.vue │ ├── ProgressBar.vue │ ├── ResIcon.vue │ └── Spinner.vue │ ├── fonts │ └── SourceHanSansCN-Light.ttf │ ├── imgs │ └── updrive.svg │ ├── main.js │ ├── router │ └── index.js │ ├── store │ ├── actions.js │ ├── getters.js │ ├── index.js │ ├── modules │ │ ├── auth.js │ │ ├── index.js │ │ ├── list.js │ │ ├── modal.js │ │ ├── profile.js │ │ └── task.js │ └── mutation-types.js │ ├── styles │ ├── bulma.scss │ ├── common.scss │ ├── custom.scss │ ├── icons.scss │ ├── index.scss │ ├── layout.scss │ ├── listView.scss │ ├── modal.scss │ ├── scroll.scss │ └── task.scss │ └── views │ ├── download │ └── Download.vue │ ├── layout │ ├── LayoutBody.vue │ ├── LayoutMenu.vue │ ├── LayoutNav.vue │ └── Main.vue │ ├── list │ └── List.vue │ ├── login │ └── Login.vue │ ├── modal │ ├── CreateFolder.vue │ ├── DomainSetting.vue │ ├── FileProgress.vue │ ├── FormatUrl.vue │ ├── RenameFile.vue │ └── UploadHandle.vue │ └── upload │ └── Upload.vue ├── static ├── .gitkeep ├── screenshot1.png ├── screenshot2.png └── screenshot3.png └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "env": { 4 | "main": { 5 | "presets": [ 6 | ["env", { 7 | "targets": { "node": 7 } 8 | }], 9 | "stage-0" 10 | ] 11 | }, 12 | "renderer": { 13 | "presets": [ 14 | ["env", { 15 | "modules": false 16 | }], 17 | "stage-0" 18 | ] 19 | }, 20 | "web": { 21 | "presets": [ 22 | ["env", { 23 | "modules": false 24 | }], 25 | "stage-0" 26 | ] 27 | } 28 | }, 29 | "plugins": ["transform-runtime"] 30 | } 31 | -------------------------------------------------------------------------------- /.electron-vue/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.NODE_ENV = 'production' 4 | 5 | const { say } = require('cfonts') 6 | const chalk = require('chalk') 7 | const del = require('del') 8 | const { spawn } = require('child_process') 9 | const webpack = require('webpack') 10 | const Multispinner = require('multispinner') 11 | 12 | 13 | const mainConfig = require('./webpack.main.config') 14 | const rendererConfig = require('./webpack.renderer.config') 15 | const webConfig = require('./webpack.web.config') 16 | 17 | const doneLog = chalk.bgGreen.white(' DONE ') + ' ' 18 | const errorLog = chalk.bgRed.white(' ERROR ') + ' ' 19 | const okayLog = chalk.bgBlue.white(' OKAY ') + ' ' 20 | const isCI = process.env.CI || false 21 | 22 | if (process.env.BUILD_TARGET === 'clean') clean() 23 | else if (process.env.BUILD_TARGET === 'web') web() 24 | else build() 25 | 26 | function clean () { 27 | del.sync(['build/*', '!build/icons', '!build/icons/icon.*']) 28 | console.log(`\n${doneLog}\n`) 29 | process.exit() 30 | } 31 | 32 | function build () { 33 | greeting() 34 | 35 | del.sync(['dist/electron/*', '!.gitkeep']) 36 | 37 | const tasks = ['main', 'renderer'] 38 | const m = new Multispinner(tasks, { 39 | preText: 'building', 40 | postText: 'process' 41 | }) 42 | 43 | let results = '' 44 | 45 | m.on('success', () => { 46 | process.stdout.write('\x1B[2J\x1B[0f') 47 | console.log(`\n\n${results}`) 48 | console.log(`${okayLog}take it away ${chalk.yellow('`electron-builder`')}\n`) 49 | process.exit() 50 | }) 51 | 52 | pack(mainConfig).then(result => { 53 | results += result + '\n\n' 54 | m.success('main') 55 | }).catch(err => { 56 | m.error('main') 57 | console.log(`\n ${errorLog}failed to build main process`) 58 | console.error(`\n${err}\n`) 59 | process.exit(1) 60 | }) 61 | 62 | pack(rendererConfig).then(result => { 63 | results += result + '\n\n' 64 | m.success('renderer') 65 | }).catch(err => { 66 | m.error('renderer') 67 | console.log(`\n ${errorLog}failed to build renderer process`) 68 | console.error(`\n${err}\n`) 69 | process.exit(1) 70 | }) 71 | } 72 | 73 | function pack (config) { 74 | return new Promise((resolve, reject) => { 75 | webpack(config, (err, stats) => { 76 | if (err) reject(err.stack || err) 77 | else if (stats.hasErrors()) { 78 | let err = '' 79 | 80 | stats.toString({ 81 | chunks: false, 82 | colors: true 83 | }) 84 | .split(/\r?\n/) 85 | .forEach(line => { 86 | err += ` ${line}\n` 87 | }) 88 | 89 | reject(err) 90 | } else { 91 | resolve(stats.toString({ 92 | chunks: false, 93 | colors: true 94 | })) 95 | } 96 | }) 97 | }) 98 | } 99 | 100 | function web () { 101 | del.sync(['dist/web/*', '!.gitkeep']) 102 | webpack(webConfig, (err, stats) => { 103 | if (err || stats.hasErrors()) console.log(err) 104 | 105 | console.log(stats.toString({ 106 | chunks: false, 107 | colors: true 108 | })) 109 | 110 | process.exit() 111 | }) 112 | } 113 | 114 | function greeting () { 115 | const cols = process.stdout.columns 116 | let text = '' 117 | 118 | if (cols > 85) text = 'lets-build' 119 | else if (cols > 60) text = 'lets-|build' 120 | else text = false 121 | 122 | if (text && !isCI) { 123 | say(text, { 124 | colors: ['yellow'], 125 | font: 'simple3d', 126 | space: false 127 | }) 128 | } else console.log(chalk.yellow.bold('\n lets-build')) 129 | console.log() 130 | } 131 | -------------------------------------------------------------------------------- /.electron-vue/dev-client.js: -------------------------------------------------------------------------------- 1 | const hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 2 | 3 | hotClient.subscribe(event => { 4 | /** 5 | * Reload browser when HTMLWebpackPlugin emits a new index.html 6 | * 7 | * Currently disabled until jantimon/html-webpack-plugin#680 is resolved. 8 | * https://github.com/SimulatedGREG/electron-vue/issues/437 9 | * https://github.com/jantimon/html-webpack-plugin/issues/680 10 | */ 11 | // if (event.action === 'reload') { 12 | // window.location.reload() 13 | // } 14 | 15 | /** 16 | * Notify `mainWindow` when `main` process is compiling, 17 | * giving notice for an expected reload of the `electron` process 18 | */ 19 | if (event.action === 'compiling') { 20 | document.body.innerHTML += ` 21 | 34 | 35 |
36 | Compiling Main Process... 37 |
38 | ` 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /.electron-vue/dev-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const chalk = require('chalk') 4 | const electron = require('electron') 5 | const path = require('path') 6 | const { say } = require('cfonts') 7 | const { spawn } = require('child_process') 8 | const webpack = require('webpack') 9 | const WebpackDevServer = require('webpack-dev-server') 10 | const webpackHotMiddleware = require('webpack-hot-middleware') 11 | 12 | const mainConfig = require('./webpack.main.config') 13 | const rendererConfig = require('./webpack.renderer.config') 14 | 15 | let electronProcess = null 16 | let manualRestart = false 17 | let hotMiddleware 18 | 19 | function logStats (proc, data) { 20 | let log = '' 21 | 22 | log += chalk.yellow.bold(`┏ ${proc} Process ${new Array((19 - proc.length) + 1).join('-')}`) 23 | log += '\n\n' 24 | 25 | if (typeof data === 'object') { 26 | data.toString({ 27 | colors: true, 28 | chunks: false 29 | }).split(/\r?\n/).forEach(line => { 30 | log += ' ' + line + '\n' 31 | }) 32 | } else { 33 | log += ` ${data}\n` 34 | } 35 | 36 | log += '\n' + chalk.yellow.bold(`┗ ${new Array(28 + 1).join('-')}`) + '\n' 37 | 38 | console.log(log) 39 | } 40 | 41 | function startRenderer () { 42 | return new Promise((resolve, reject) => { 43 | rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client')].concat(rendererConfig.entry.renderer) 44 | 45 | const compiler = webpack(rendererConfig) 46 | hotMiddleware = webpackHotMiddleware(compiler, { 47 | log: false, 48 | heartbeat: 2500 49 | }) 50 | 51 | compiler.plugin('compilation', compilation => { 52 | compilation.plugin('html-webpack-plugin-after-emit', (data, cb) => { 53 | hotMiddleware.publish({ action: 'reload' }) 54 | cb() 55 | }) 56 | }) 57 | 58 | compiler.plugin('done', stats => { 59 | logStats('Renderer', stats) 60 | }) 61 | 62 | const server = new WebpackDevServer( 63 | compiler, 64 | { 65 | contentBase: path.join(__dirname, '../'), 66 | quiet: true, 67 | before (app, ctx) { 68 | app.use(hotMiddleware) 69 | ctx.middleware.waitUntilValid(() => { 70 | resolve() 71 | }) 72 | } 73 | } 74 | ) 75 | 76 | server.listen(9080) 77 | }) 78 | } 79 | 80 | function startMain () { 81 | return new Promise((resolve, reject) => { 82 | mainConfig.entry.main = [path.join(__dirname, '../src/main/index.dev.js')].concat(mainConfig.entry.main) 83 | 84 | const compiler = webpack(mainConfig) 85 | 86 | compiler.plugin('watch-run', (compilation, done) => { 87 | logStats('Main', chalk.white.bold('compiling...')) 88 | hotMiddleware.publish({ action: 'compiling' }) 89 | done() 90 | }) 91 | 92 | compiler.watch({}, (err, stats) => { 93 | if (err) { 94 | console.log(err) 95 | return 96 | } 97 | 98 | logStats('Main', stats) 99 | 100 | if (electronProcess && electronProcess.kill) { 101 | manualRestart = true 102 | process.kill(electronProcess.pid) 103 | electronProcess = null 104 | startElectron() 105 | 106 | setTimeout(() => { 107 | manualRestart = false 108 | }, 5000) 109 | } 110 | 111 | resolve() 112 | }) 113 | }) 114 | } 115 | 116 | function startElectron () { 117 | electronProcess = spawn(electron, ['--inspect=5858', path.join(__dirname, '../dist/electron/main.js')]) 118 | 119 | electronProcess.stdout.on('data', data => { 120 | electronLog(data, 'blue') 121 | }) 122 | electronProcess.stderr.on('data', data => { 123 | electronLog(data, 'red') 124 | }) 125 | 126 | electronProcess.on('close', () => { 127 | if (!manualRestart) process.exit() 128 | }) 129 | } 130 | 131 | function electronLog (data, color) { 132 | let log = '' 133 | data = data.toString().split(/\r?\n/) 134 | data.forEach(line => { 135 | log += ` ${line}\n` 136 | }) 137 | if (/[0-9A-z]+/.test(log)) { 138 | console.log( 139 | chalk[color].bold('┏ Electron -------------------') + 140 | '\n\n' + 141 | log + 142 | chalk[color].bold('┗ ----------------------------') + 143 | '\n' 144 | ) 145 | } 146 | } 147 | 148 | function greeting () { 149 | const cols = process.stdout.columns 150 | let text = '' 151 | 152 | if (cols > 104) text = 'electron-vue' 153 | else if (cols > 76) text = 'electron-|vue' 154 | else text = false 155 | 156 | if (text) { 157 | say(text, { 158 | colors: ['yellow'], 159 | font: 'simple3d', 160 | space: false 161 | }) 162 | } else console.log(chalk.yellow.bold('\n electron-vue')) 163 | console.log(chalk.blue(' getting ready...') + '\n') 164 | } 165 | 166 | function init () { 167 | greeting() 168 | 169 | Promise.all([startRenderer(), startMain()]) 170 | .then(() => { 171 | startElectron() 172 | }) 173 | .catch(err => { 174 | console.error(err) 175 | }) 176 | } 177 | 178 | init() 179 | -------------------------------------------------------------------------------- /.electron-vue/webpack.main.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'main' 4 | 5 | const path = require('path') 6 | const { dependencies } = require('../package.json') 7 | const webpack = require('webpack') 8 | 9 | const BabiliWebpackPlugin = require('babili-webpack-plugin') 10 | 11 | let mainConfig = { 12 | entry: { 13 | main: path.join(__dirname, '../src/main/index.js') 14 | }, 15 | externals: [ 16 | ...Object.keys(dependencies || {}) 17 | ], 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.js$/, 22 | use: 'babel-loader', 23 | exclude: /node_modules/ 24 | }, 25 | { 26 | test: /\.node$/, 27 | use: 'node-loader' 28 | } 29 | ] 30 | }, 31 | node: { 32 | __dirname: process.env.NODE_ENV !== 'production', 33 | __filename: process.env.NODE_ENV !== 'production' 34 | }, 35 | output: { 36 | filename: '[name].js', 37 | libraryTarget: 'commonjs2', 38 | path: path.join(__dirname, '../dist/electron') 39 | }, 40 | plugins: [ 41 | new webpack.NoEmitOnErrorsPlugin() 42 | ], 43 | resolve: { 44 | extensions: ['.js', '.json', '.node'] 45 | }, 46 | target: 'electron-main' 47 | } 48 | 49 | /** 50 | * Adjust mainConfig for development settings 51 | */ 52 | if (process.env.NODE_ENV !== 'production') { 53 | mainConfig.plugins.push( 54 | new webpack.DefinePlugin({ 55 | '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 56 | }) 57 | ) 58 | } 59 | 60 | /** 61 | * Adjust mainConfig for production settings 62 | */ 63 | if (process.env.NODE_ENV === 'production') { 64 | mainConfig.plugins.push( 65 | new BabiliWebpackPlugin(), 66 | new webpack.DefinePlugin({ 67 | 'process.env.NODE_ENV': '"production"' 68 | }) 69 | ) 70 | } 71 | 72 | module.exports = mainConfig 73 | -------------------------------------------------------------------------------- /.electron-vue/webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'renderer' 4 | 5 | const path = require('path') 6 | const { dependencies } = require('../package.json') 7 | const webpack = require('webpack') 8 | 9 | const BabiliWebpackPlugin = require('babili-webpack-plugin') 10 | const CopyWebpackPlugin = require('copy-webpack-plugin') 11 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 12 | const HtmlWebpackPlugin = require('html-webpack-plugin') 13 | 14 | /** 15 | * List of node_modules to include in webpack bundle 16 | * 17 | * Required for specific packages like Vue UI libraries 18 | * that provide pure *.vue files that need compiling 19 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/webpack-configurations.html#white-listing-externals 20 | */ 21 | let whiteListedModules = ['vue'] 22 | 23 | let rendererConfig = { 24 | devtool: '#cheap-module-eval-source-map', 25 | entry: { 26 | renderer: path.join(__dirname, '../src/renderer/main.js') 27 | }, 28 | externals: [ 29 | ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d)) 30 | ], 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.css$/, 35 | use: ExtractTextPlugin.extract({ 36 | fallback: 'style-loader', 37 | use: 'css-loader' 38 | }) 39 | }, 40 | { 41 | test: /\.html$/, 42 | use: 'vue-html-loader' 43 | }, 44 | { 45 | test: /\.js$/, 46 | use: 'babel-loader', 47 | exclude: /node_modules/ 48 | }, 49 | { 50 | test: /\.node$/, 51 | use: 'node-loader' 52 | }, 53 | { 54 | test: /\.vue$/, 55 | use: { 56 | loader: 'vue-loader', 57 | options: { 58 | extractCSS: process.env.NODE_ENV === 'production', 59 | loaders: { 60 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 61 | scss: 'vue-style-loader!css-loader!sass-loader' 62 | } 63 | } 64 | } 65 | }, 66 | { 67 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 68 | use: { 69 | loader: 'url-loader', 70 | query: { 71 | limit: 10000, 72 | name: 'imgs/[name]--[folder].[ext]' 73 | } 74 | } 75 | }, 76 | { 77 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 78 | loader: 'url-loader', 79 | options: { 80 | limit: 10000, 81 | name: 'media/[name]--[folder].[ext]' 82 | } 83 | }, 84 | { 85 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 86 | use: { 87 | loader: 'url-loader', 88 | query: { 89 | limit: 10000, 90 | name: 'fonts/[name]--[folder].[ext]' 91 | } 92 | } 93 | } 94 | ] 95 | }, 96 | node: { 97 | __dirname: process.env.NODE_ENV !== 'production', 98 | __filename: process.env.NODE_ENV !== 'production' 99 | }, 100 | plugins: [ 101 | new ExtractTextPlugin('styles.css'), 102 | new HtmlWebpackPlugin({ 103 | filename: 'index.html', 104 | template: path.resolve(__dirname, '../src/index.ejs'), 105 | minify: { 106 | collapseWhitespace: true, 107 | removeAttributeQuotes: true, 108 | removeComments: true 109 | }, 110 | nodeModules: process.env.NODE_ENV !== 'production' 111 | ? path.resolve(__dirname, '../node_modules') 112 | : false 113 | }), 114 | new webpack.HotModuleReplacementPlugin(), 115 | new webpack.NoEmitOnErrorsPlugin() 116 | ], 117 | output: { 118 | filename: '[name].js', 119 | libraryTarget: 'commonjs2', 120 | path: path.join(__dirname, '../dist/electron') 121 | }, 122 | resolve: { 123 | alias: { 124 | '@': path.join(__dirname, '../src/renderer'), 125 | 'vue$': 'vue/dist/vue.esm.js' 126 | }, 127 | extensions: ['.js', '.vue', '.json', '.css', '.node'] 128 | }, 129 | target: 'electron-renderer' 130 | } 131 | 132 | /** 133 | * Adjust rendererConfig for development settings 134 | */ 135 | if (process.env.NODE_ENV !== 'production') { 136 | rendererConfig.plugins.push( 137 | new webpack.DefinePlugin({ 138 | '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 139 | }) 140 | ) 141 | } 142 | 143 | /** 144 | * Adjust rendererConfig for production settings 145 | */ 146 | if (process.env.NODE_ENV === 'production') { 147 | rendererConfig.devtool = '' 148 | 149 | rendererConfig.plugins.push( 150 | new BabiliWebpackPlugin(), 151 | new CopyWebpackPlugin([ 152 | { 153 | from: path.join(__dirname, '../static'), 154 | to: path.join(__dirname, '../dist/electron/static'), 155 | ignore: ['.*'] 156 | } 157 | ]), 158 | new webpack.DefinePlugin({ 159 | 'process.env.NODE_ENV': '"production"' 160 | }), 161 | new webpack.LoaderOptionsPlugin({ 162 | minimize: true 163 | }) 164 | ) 165 | } 166 | 167 | module.exports = rendererConfig 168 | -------------------------------------------------------------------------------- /.electron-vue/webpack.web.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'web' 4 | 5 | const path = require('path') 6 | const webpack = require('webpack') 7 | 8 | const BabiliWebpackPlugin = require('babili-webpack-plugin') 9 | const CopyWebpackPlugin = require('copy-webpack-plugin') 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const HtmlWebpackPlugin = require('html-webpack-plugin') 12 | 13 | let webConfig = { 14 | devtool: '#cheap-module-eval-source-map', 15 | entry: { 16 | web: path.join(__dirname, '../src/renderer/main.js') 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.css$/, 22 | use: ExtractTextPlugin.extract({ 23 | fallback: 'style-loader', 24 | use: 'css-loader' 25 | }) 26 | }, 27 | { 28 | test: /\.html$/, 29 | use: 'vue-html-loader' 30 | }, 31 | { 32 | test: /\.js$/, 33 | use: 'babel-loader', 34 | include: [ path.resolve(__dirname, '../src/renderer') ], 35 | exclude: /node_modules/ 36 | }, 37 | { 38 | test: /\.vue$/, 39 | use: { 40 | loader: 'vue-loader', 41 | options: { 42 | extractCSS: true, 43 | loaders: { 44 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 45 | scss: 'vue-style-loader!css-loader!sass-loader' 46 | } 47 | } 48 | } 49 | }, 50 | { 51 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 52 | use: { 53 | loader: 'url-loader', 54 | query: { 55 | limit: 10000, 56 | name: 'imgs/[name].[ext]' 57 | } 58 | } 59 | }, 60 | { 61 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 62 | use: { 63 | loader: 'url-loader', 64 | query: { 65 | limit: 10000, 66 | name: 'fonts/[name].[ext]' 67 | } 68 | } 69 | } 70 | ] 71 | }, 72 | plugins: [ 73 | new ExtractTextPlugin('styles.css'), 74 | new HtmlWebpackPlugin({ 75 | filename: 'index.html', 76 | template: path.resolve(__dirname, '../src/index.ejs'), 77 | minify: { 78 | collapseWhitespace: true, 79 | removeAttributeQuotes: true, 80 | removeComments: true 81 | }, 82 | nodeModules: false 83 | }), 84 | new webpack.DefinePlugin({ 85 | 'process.env.IS_WEB': 'true' 86 | }), 87 | new webpack.HotModuleReplacementPlugin(), 88 | new webpack.NoEmitOnErrorsPlugin() 89 | ], 90 | output: { 91 | filename: '[name].js', 92 | path: path.join(__dirname, '../dist/web') 93 | }, 94 | resolve: { 95 | alias: { 96 | '@': path.join(__dirname, '../src/renderer'), 97 | 'vue$': 'vue/dist/vue.esm.js' 98 | }, 99 | extensions: ['.js', '.vue', '.json', '.css'] 100 | }, 101 | target: 'web' 102 | } 103 | 104 | /** 105 | * Adjust webConfig for production settings 106 | */ 107 | if (process.env.NODE_ENV === 'production') { 108 | webConfig.devtool = '' 109 | 110 | webConfig.plugins.push( 111 | new BabiliWebpackPlugin(), 112 | new CopyWebpackPlugin([ 113 | { 114 | from: path.join(__dirname, '../static'), 115 | to: path.join(__dirname, '../dist/web/static'), 116 | ignore: ['.*'] 117 | } 118 | ]), 119 | new webpack.DefinePlugin({ 120 | 'process.env.NODE_ENV': '"production"' 121 | }), 122 | new webpack.LoaderOptionsPlugin({ 123 | minimize: true 124 | }) 125 | ) 126 | } 127 | 128 | module.exports = webConfig 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/electron/* 3 | dist/web/* 4 | build/* 5 | !build/icons 6 | node_modules/ 7 | npm-debug.log 8 | npm-debug.log.* 9 | thumbs.db 10 | !.gitkeep 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | osx_image: xcode8.3 2 | sudo: required 3 | dist: trusty 4 | language: c 5 | matrix: 6 | include: 7 | - os: osx 8 | - os: linux 9 | env: CC=clang CXX=clang++ npm_config_clang=1 10 | compiler: clang 11 | cache: 12 | directories: 13 | - node_modules 14 | - "$HOME/.electron" 15 | - "$HOME/.cache" 16 | addons: 17 | apt: 18 | packages: 19 | - libgnome-keyring-dev 20 | - icnsutils 21 | before_install: 22 | - | 23 | if [ "$TRAVIS_OS_NAME" == "osx" ]; then 24 | mkdir -p /tmp/git-lfs && curl -L https://github.com/github/git-lfs/releases/download/v2.3.1/git-lfs-$([ "$TRAVIS_OS_NAME" == "linux" ] && echo "linux" || echo "darwin")-amd64-2.3.1.tar.gz | tar -xz -C /tmp/git-lfs --strip-components 1 25 | export PATH="/tmp/git-lfs:$PATH" 26 | fi 27 | before_script: 28 | - git lfs pull 29 | install: 30 | - nvm install 8 31 | - curl -o- -L https://yarnpkg.com/install.sh | bash 32 | - source ~/.bashrc 33 | - npm install -g xvfb-maybe 34 | - yarn 35 | script: 36 | - yarn run build 37 | branches: 38 | only: 39 | - master 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## 0.10.0 4 | 5 | ### 新增 6 | - 增加复制粘贴功能 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # updrive 2 | 3 | > 又拍云文件管理桌面客户端 4 | #### ChangeLog 5 | 6 | #### v0.38.0(2018-05-23) 7 |  - 修复无法设置加速域名问题 8 | 9 | #### v0.37.0(2018-05-20) 10 | - 上传下载列表,显示本地图片 11 | 12 | ##### v0.36.0(2018-05-05) 13 | - 修复退出应用快捷键错误 14 | - 重构任务列表模块 15 | - 修改应用默认的宽高 16 | 17 | ##### v0.34.0(2018-04-22) 18 | - 获取链接可编辑。 19 | - 增加获取任务列表里面上传完成后的文件获取链接的功能 20 | - 上传下载完成后,增加桌面通知功能 21 | - 任务列表的文件可以双击和单击文件名用操作系统默认程序打开 22 | 23 | ##### v0.33.0 24 | - 增加自定义加速域名功能 25 | 26 | ##### v0.31.0 27 | - 绕过防盗链限制预览图片 28 | 29 | ##### v0.30.0 30 | - 修复不同账号切换存在的一些 BUG 31 | - 手动更新功能 32 | - 增加用户名服务名展示以及设置功能 33 | - 增加云存储服务使用量展示 34 | 35 | #### Download 36 | [下载地址](https://github.com/aniiantt/updrive/releases) 37 | 38 | #### Usage 39 | [云存储服务及操作员账号创建](https://console.upyun.com/services/create/file/) 40 | 41 | #### Screenshot 42 | ![截图1](https://github.com/aniiantt/updrive/blob/develop/static/screenshot1.png?raw=true) 43 | ![截图2](https://github.com/aniiantt/updrive/blob/develop/static/screenshot2.png?raw=true) 44 | ![截图3](https://github.com/aniiantt/updrive/blob/develop/static/screenshot3.png?raw=true) 45 | 46 | #### Build 47 | 48 | ``` bash 49 | # 安装依赖 50 | yarn 51 | 52 | # 启动 53 | yarn dev 54 | 55 | # 打包 56 | yarn build 57 | 58 | ``` 59 | 60 | #### Feature 61 | - 基础的文件上传、下载、删除、重命名、查看功能 62 | - 按名称、日期、类型、大小排序 63 | - 批量删除、新建和上传 64 | - 拖曳操作 65 | - 复制链接 66 | - 查看文件响应头 67 | - 多选删除上传 68 | - 上传下载展示,以及历史记录 69 | - 账号历史 70 | - 右键菜单 71 | - 快捷键操作 72 | - 前进,后退功能 73 | - 版本号显示以及检查更新功能 74 | - 切换用户 75 | - 额外链接 76 | - 绕过防盗链 77 | 78 | #### TODO 79 | - [ ] 上传优化 80 | - [ ] 优化快捷键操作。 81 | - [ ] 拆分任务列表,分为上传列表和下载列表 82 | - [ ] 列表筛选 83 | - [ ] 收藏列表 84 | - [ ] 升级下载进度,自动选择文件夹 85 | - [ ] 自定义缩略图版本查看 86 | - [ ] 优化文件查看体验,双击查看详情,文件编辑 87 | - [ ] 托盘图标 88 | - [ ] 列表卡片查看模式,以及瀑布流加载 89 | - [ ] 优化上传下载模块,使用 indexDB 重构 90 | - [ ] 截图上传 91 | - [ ] 文件拉取 92 | - [ ] 云处理功能 93 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 0.1.{build} 2 | 3 | branches: 4 | only: 5 | - master 6 | 7 | image: Visual Studio 2017 8 | platform: 9 | - x64 10 | 11 | cache: 12 | - node_modules 13 | - '%APPDATA%\npm-cache' 14 | - '%USERPROFILE%\.electron' 15 | - '%USERPROFILE%\AppData\Local\Yarn\cache' 16 | 17 | init: 18 | - git config --global core.autocrlf input 19 | 20 | install: 21 | - ps: Install-Product node 8 x64 22 | - git reset --hard HEAD 23 | - yarn 24 | - node --version 25 | 26 | build_script: 27 | - yarn build 28 | 29 | test: off 30 | -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/build/icons/icon.icns -------------------------------------------------------------------------------- /build/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/build/icons/icon.ico -------------------------------------------------------------------------------- /build/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/build/icons/icon.png -------------------------------------------------------------------------------- /dist/electron/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/dist/electron/.gitkeep -------------------------------------------------------------------------------- /dist/web/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/dist/web/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "updrive", 3 | "version": "0.38.0", 4 | "author": "nsso ", 5 | "description": "upyun file manager", 6 | "engines": { 7 | "node": ">=8.2.1" 8 | }, 9 | "main": "./dist/electron/main.js", 10 | "scripts": { 11 | "build": "node .electron-vue/build.js && electron-builder", 12 | "build:dir": "node .electron-vue/build.js && electron-builder --dir", 13 | "build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js", 14 | "build:web": "cross-env BUILD_TARGET=web node .electron-vue/build.js", 15 | "dev": "node .electron-vue/dev-runner.js", 16 | "pack": "npm run pack:main && npm run pack:renderer", 17 | "pack:main": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.main.config.js", 18 | "pack:renderer": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.renderer.config.js", 19 | "postinstall": "" 20 | }, 21 | "build": { 22 | "productName": "updrive", 23 | "artifactName": "${productName}.${ext}", 24 | "appId": "org.upyun.updrive", 25 | "directories": { 26 | "output": "build" 27 | }, 28 | "files": [ 29 | "dist/electron/**/*" 30 | ], 31 | "dmg": { 32 | "contents": [ 33 | { 34 | "x": 410, 35 | "y": 150, 36 | "type": "link", 37 | "path": "/Applications" 38 | }, 39 | { 40 | "x": 130, 41 | "y": 150, 42 | "type": "file" 43 | } 44 | ] 45 | }, 46 | "mac": { 47 | "icon": "build/icons/icon.icns" 48 | }, 49 | "win": { 50 | "icon": "build/icons/icon.ico", 51 | "target": [ 52 | "nsis", 53 | "portable", 54 | "zip" 55 | ] 56 | }, 57 | "linux": { 58 | "icon": "build/icons" 59 | }, 60 | "nsis": { 61 | "createDesktopShortcut": true 62 | } 63 | }, 64 | "dependencies": { 65 | "axios": "^0.17.1", 66 | "balloon-css": "^0.5.0", 67 | "bulma": "^0.7.0", 68 | "ftp": "^0.3.10", 69 | "iview": "^2.7.2", 70 | "localforage": "^1.5.3", 71 | "mime": "^2.0.3", 72 | "moment": "^2.19.2", 73 | "node-sass": "^4.6.1", 74 | "progressbar.js": "^1.0.1", 75 | "ramda": "^0.25.0", 76 | "request": "^2.83.0", 77 | "sass-loader": "^6.0.6", 78 | "semver": "^5.5.0", 79 | "vue": "^2.5.3", 80 | "vue-electron": "^1.0.6", 81 | "vue-router": "^3.0.1", 82 | "vuex": "^3.0.1" 83 | }, 84 | "devDependencies": { 85 | "babel-core": "^6.26.0", 86 | "babel-loader": "^7.1.2", 87 | "babel-plugin-transform-runtime": "^6.23.0", 88 | "babel-preset-env": "^1.6.1", 89 | "babel-preset-stage-0": "^6.24.1", 90 | "babel-register": "^6.26.0", 91 | "babili-webpack-plugin": "^0.1.2", 92 | "cfonts": "^1.1.3", 93 | "chalk": "^2.3.0", 94 | "copy-webpack-plugin": "^4.2.0", 95 | "cross-env": "^5.1.1", 96 | "css-loader": "^0.28.7", 97 | "del": "^3.0.0", 98 | "devtron": "^1.4.0", 99 | "electron": "^2.0.0", 100 | "electron-builder": "^19.45.4", 101 | "electron-debug": "^1.4.0", 102 | "electron-devtools-installer": "^2.2.4", 103 | "extract-text-webpack-plugin": "^3.0.2", 104 | "file-loader": "^1.1.5", 105 | "html-webpack-plugin": "^2.30.1", 106 | "json-loader": "^0.5.7", 107 | "multispinner": "^0.2.1", 108 | "style-loader": "^0.19.0", 109 | "url-loader": "^0.6.2", 110 | "vue-html-loader": "^1.2.4", 111 | "vue-loader": "^13.5.0", 112 | "vue-style-loader": "^3.0.3", 113 | "vue-template-compiler": "^2.5.3", 114 | "webpack": "^3.8.1", 115 | "webpack-dev-server": "^2.9.4", 116 | "webpack-hot-middleware": "^2.20.0" 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | updrive 7 | <% if (htmlWebpackPlugin.options.nodeModules) { %> 8 | 9 | 12 | <% } %> 13 | 14 | 15 | 16 |
17 | 18 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/main/index.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used specifically and only for development. It installs 3 | * `electron-debug` & `vue-devtools`. There shouldn't be any need to 4 | * modify this file, but it can be used to extend your development 5 | * environment. 6 | */ 7 | 8 | /* eslint-disable */ 9 | 10 | // Set environment for development 11 | process.env.NODE_ENV = 'development' 12 | 13 | // Install `electron-debug` with `devtron` 14 | require('electron-debug')({ showDevTools: true }) 15 | 16 | // Install `vue-devtools` 17 | require('electron').app.on('ready', () => { 18 | let installExtension = require('electron-devtools-installer') 19 | installExtension.default(installExtension.VUEJS_DEVTOOLS) 20 | .then(() => {}) 21 | .catch(err => { 22 | console.log('Unable to install `vue-devtools`: \n', err) 23 | }) 24 | }) 25 | 26 | // Require `main` process to boot app 27 | require('./index') 28 | -------------------------------------------------------------------------------- /src/main/index.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | 3 | /** 4 | * Set `__static` path to static files in production 5 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-static-assets.html 6 | */ 7 | if (process.env.NODE_ENV !== 'development') { 8 | global.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\') 9 | } 10 | 11 | let mainWindow 12 | const winURL = process.env.NODE_ENV === 'development' 13 | ? `http://localhost:9080` 14 | : `file://${__dirname}/index.html` 15 | 16 | function createWindow () { 17 | /** 18 | * Initial window options 19 | */ 20 | mainWindow = new BrowserWindow({ 21 | useContentSize: true, 22 | height: 625, 23 | width: 980, 24 | minHeight: 525, 25 | minWidth: 980, 26 | webPreferences: { webSecurity: false}, 27 | }) 28 | 29 | mainWindow.loadURL(winURL) 30 | 31 | mainWindow.on('closed', () => { 32 | mainWindow = null 33 | }) 34 | } 35 | 36 | app.on('ready', createWindow) 37 | 38 | app.on('window-all-closed', () => { 39 | if (process.platform !== 'darwin') { 40 | app.quit() 41 | } 42 | }) 43 | 44 | app.on('activate', () => { 45 | if (mainWindow === null) { 46 | createWindow() 47 | } 48 | }) 49 | 50 | /** 51 | * Auto Updater 52 | * 53 | * Uncomment the following code below and install `electron-updater` to 54 | * support auto updating. Code Signing with a valid certificate is required. 55 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-electron-builder.html#auto-updating 56 | */ 57 | 58 | /* 59 | import { autoUpdater } from 'electron-updater' 60 | 61 | autoUpdater.on('update-downloaded', () => { 62 | autoUpdater.quitAndInstall() 63 | }) 64 | 65 | app.on('ready', () => { 66 | if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates() 67 | }) 68 | */ 69 | -------------------------------------------------------------------------------- /src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 28 | 29 | 32 | -------------------------------------------------------------------------------- /src/renderer/api/electron.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer, shell, clipboard, remote, webFrame } from 'electron' 2 | 3 | import Router from '@/router' 4 | import Store from '@/store' 5 | import { externalUrls } from '@/api/tool' 6 | 7 | const { app, dialog, Menu, MenuItem, BrowserWindow, getCurrentWindow } = remote 8 | 9 | const currentWin = getCurrentWindow() 10 | 11 | const session = currentWin.webContents.session 12 | 13 | const userAgent = `${process.env.npm_package_build_productName}/${process.env.npm_package_version}` 14 | 15 | // 禁止缩放 16 | webFrame.setVisualZoomLevelLimits(1, 1) 17 | 18 | // img 标签注入授权头 19 | session.webRequest.onBeforeSendHeaders( 20 | { 21 | urls: ['*://v0.api.upyun.com/*'], 22 | }, 23 | (details, callback) => { 24 | if (details.resourceType === 'image') { 25 | const authHeaders = Store.getters.upyunClient.getHeaders(details.url) 26 | callback({ 27 | requestHeaders: { 28 | ...details.requestHeaders, 29 | ...authHeaders, 30 | }, 31 | }) 32 | } else { 33 | callback({}) 34 | } 35 | }, 36 | ) 37 | 38 | // 聚焦 39 | export const winShow = currentWin.show 40 | 41 | // 设置菜单 42 | export const setApplicationMenu = () => { 43 | const menu = [ 44 | { 45 | label: '文件', 46 | submenu: [ 47 | { 48 | label: '切换账号', 49 | click() { 50 | Router.push({ name: 'login' }) 51 | Store.dispatch('LOGOUT') 52 | }, 53 | }, 54 | { 55 | label: '退出', 56 | role: 'quit', 57 | }, 58 | ], 59 | }, 60 | { 61 | label: ' 编辑', 62 | submenu: [ 63 | { 64 | label: '撤销', 65 | role: 'undo', 66 | }, 67 | { 68 | label: '恢复', 69 | role: 'redo', 70 | }, 71 | { 72 | type: 'separator', 73 | }, 74 | { 75 | label: '复制', 76 | role: 'copy', 77 | }, 78 | { 79 | label: '粘贴', 80 | role: 'paste', 81 | }, 82 | { 83 | label: '剪切', 84 | role: 'cut', 85 | }, 86 | ], 87 | }, 88 | { 89 | label: '查看', 90 | submenu: [ 91 | { 92 | label: '刷新', 93 | role: 'reload', 94 | }, 95 | ], 96 | }, 97 | { 98 | label: '帮助', 99 | role: 'help', 100 | submenu: [ 101 | { 102 | label: '切换开发人员工具', 103 | role: 'toggledevtools', 104 | }, 105 | { 106 | label: '报告一个问题', 107 | click() { 108 | shell.openExternal(externalUrls.issues) 109 | }, 110 | }, 111 | { 112 | type: 'separator', 113 | }, 114 | { 115 | label: '关于', 116 | click() { 117 | shell.openExternal(externalUrls.repository) 118 | }, 119 | }, 120 | ], 121 | }, 122 | ] 123 | Menu.setApplicationMenu(Menu.buildFromTemplate(menu)) 124 | } 125 | 126 | export const writeText = clipboard.writeText 127 | 128 | // 打开外部链接 129 | export const openExternal = shell.openExternal 130 | 131 | export const windowOpen = (url, frameName, features) => { 132 | let child = new BrowserWindow({ parent: currentWin, modal: true, show: false }) 133 | child.loadURL(url) 134 | child.once('ready-to-show', () => { 135 | child.show() 136 | }) 137 | } 138 | 139 | // 创建右键菜单 140 | export const createContextmenu = ({ appendItems } = {}) => { 141 | const menu = new Menu() 142 | for (const menuItem of appendItems) { 143 | if (!menuItem.hide) menu.append(new MenuItem(menuItem)) 144 | } 145 | return menu 146 | } 147 | 148 | // 显示右键菜单 149 | export const showContextmenu = (items, opts = {}) => { 150 | const menu = createContextmenu(items) 151 | setTimeout(() => menu.popup(currentWin)) 152 | } 153 | 154 | // 获取版本号 155 | export const getVersion = app.getVersion 156 | 157 | // 获取产品名称 158 | export const getName = app.getName 159 | 160 | // 监听 Ctrl + A 161 | export const listenSelectAll = callback => ipcRenderer.on('SHORTCUT_SELECT_ALL', callback) 162 | 163 | // 上传文件 164 | export const uploadFileDialog = (option = {}) => { 165 | return new Promise((resolve, reject) => { 166 | dialog.showOpenDialog( 167 | currentWin, 168 | { 169 | title: '选择要上传的文件', 170 | buttonLabel: '上传', 171 | properties: ['openFile', 'multiSelections'], 172 | ...option, 173 | }, 174 | resolve, 175 | ) 176 | }) 177 | } 178 | 179 | // 上传文件夹 180 | export const uploadDirectoryDialog = (option = {}) => { 181 | return new Promise((resolve, reject) => { 182 | dialog.showOpenDialog( 183 | currentWin, 184 | { 185 | title: '选择要上传的文件夹', 186 | buttonLabel: '上传', 187 | properties: ['openDirectory', 'createDirectory', 'multiSelections', 'showHiddenFiles'], 188 | ...option, 189 | }, 190 | resolve, 191 | ) 192 | }) 193 | } 194 | 195 | // 下载 196 | export const downloadFileDialog = (option = {}) => { 197 | return new Promise((resolve, reject) => { 198 | dialog.showOpenDialog( 199 | currentWin, 200 | { 201 | title: '下载到', 202 | buttonLabel: '保存', 203 | properties: ['openDirectory', 'createDirectory', 'showHiddenFiles'], 204 | ...option, 205 | }, 206 | folderPaths => { 207 | resolve(folderPaths && folderPaths[0]) 208 | }, 209 | ) 210 | }) 211 | } 212 | 213 | export const showItemInFolder = fullPath => { 214 | return shell.showItemInFolder(fullPath) 215 | } 216 | 217 | export const openItem = fullPath => { 218 | return shell.openItem(fullPath) 219 | } 220 | -------------------------------------------------------------------------------- /src/renderer/api/job.js: -------------------------------------------------------------------------------- 1 | import Request from 'request' 2 | import EventEmitter from 'events' 3 | import Fs from 'fs' 4 | import { basename } from 'path' 5 | import { prepend, groupBy } from 'ramda' 6 | import localforage from 'localforage' 7 | import moment from 'moment' 8 | 9 | import { base64, throttle } from '@/api/tool' 10 | 11 | // 上传和下载应该分开 12 | // 大量文件下性能问题未知 13 | 14 | const Job = { 15 | initStore: { 16 | version: 0.1, 17 | data: [], 18 | }, 19 | 20 | status: { 21 | downloading: { name: '下载中...', value: 'downloading' }, 22 | uploading: { name: '上传中...', value: 'uploading' }, 23 | interrupted: { name: '已暂停', value: 'interrupted' }, 24 | completed: { name: '已完成', value: 'completed' }, 25 | error: { name: '错误', value: 'error' }, 26 | }, 27 | 28 | setup(keyPre, onChange) { 29 | this.storeKey = `${keyPre}:job` 30 | this.on('change', onChange) 31 | }, 32 | 33 | async createDownloadItem(url, localPath) { 34 | const filename = basename(localPath) 35 | const startTime = moment().unix() 36 | const id = base64(`${filename}:${startTime}`) 37 | const item = { 38 | id: id, // 唯一ID 39 | url: url, // 远程路径 40 | connectType: 'download', // 类型 download upload 41 | localPath: localPath, // 下载本地路径 42 | startTime: startTime, // 下载开始时间 43 | filename: basename(localPath), // 下载的文件名 44 | status: this.status.downloading.value, // 下载状态: "uploading", "downloading", "interrupted", "completed", "error" 45 | errorMessage: '', 46 | transferred: 0, // 已下载大小 47 | total: -1, // 总共大小 48 | endTime: -1, // 下载结束时间 49 | } 50 | // await this.setItem(id, item) 一开始不存储任务 51 | this.emit('change', { ...item }) 52 | return item 53 | }, 54 | 55 | async createUploadItem(url, localPath) { 56 | const filename = decodeURIComponent(new URL(url).pathname.split('/').reverse()[0]) 57 | const startTime = moment().unix() 58 | const total = Fs.statSync(localPath).size 59 | const id = base64(`${filename}:${startTime}`) 60 | const item = { 61 | id: id, // 唯一ID 62 | url: url, // 上传路径 63 | connectType: 'upload', // 类型 download upload 64 | localPath: localPath, // 上传本地文件路径 65 | startTime: startTime, // 上传开始时间 66 | filename: filename, // 上传的文件名 67 | status: this.status.uploading.value, // 传输状态: "downloading", "interrupted", "completed", "error" 68 | errorMessage: '', 69 | transferred: 0, // 已上传大小 70 | total: total, // 总共大小 71 | endTime: -1, // 下载结束时间 72 | } 73 | // await this.setItem(id, item) 一开始不存储任务 74 | this.emit('change', { ...item }) 75 | return item 76 | }, 77 | 78 | async setItem(id, item) { 79 | const store = await this.getStore() 80 | const existedItemIndex = store.data.findIndex(_item => _item.id === id) 81 | if (~existedItemIndex) { 82 | store.data[existedItemIndex] = { ...item } 83 | } else { 84 | store.data = prepend(item, store.data) 85 | } 86 | return await localforage.setItem(this.storeKey, store) 87 | }, 88 | 89 | async createDownloadTask({ url, headers, localPath }) { 90 | // @TODO 并发 91 | return new Promise(async (resolve, reject) => { 92 | const item = await this.createDownloadItem(url, localPath) 93 | 94 | const emitChange = () => { 95 | this.emit('change', { ...item }) 96 | } 97 | 98 | let percentage = 0 99 | const calTrans = () => { 100 | const newPercentage = (item.transferred / item.total).toFixed(2) 101 | if (percentage !== newPercentage) { 102 | percentage = newPercentage 103 | emitChange() 104 | } 105 | } 106 | 107 | const throttleChunk = throttle(calTrans, 100) 108 | 109 | const request = Request({ url: url, headers: headers }) 110 | .on('response', response => { 111 | item.total = window.parseInt(response.headers['content-length'], 10) 112 | emitChange() 113 | }) 114 | .on('data', chunk => { 115 | item.transferred += chunk.length 116 | if (item.transferred === item.total) { 117 | calTrans() 118 | } else { 119 | throttleChunk() 120 | } 121 | }) 122 | .on('error', async error => { 123 | item.status = this.status.error.value 124 | item.errorMessage = error && error.message 125 | await this.setItem(item.id, item) 126 | emitChange() 127 | reject(error) 128 | }) 129 | 130 | const localStream = Fs.createWriteStream(localPath).once('finish', async () => { 131 | item.status = this.status.completed.value 132 | item.endTime = moment().unix() 133 | request.removeAllListeners() 134 | await this.setItem(item.id, item) 135 | emitChange() 136 | resolve('success') 137 | }) 138 | 139 | request.pipe(localStream) 140 | }) 141 | }, 142 | 143 | async createUploadTask({ url, headers, localPath }) { 144 | // @TODO 并发 145 | return new Promise(async (resolve, reject) => { 146 | const item = await this.createUploadItem(url, localPath) 147 | const emitChange = () => { 148 | this.emit('change', { ...item }) 149 | } 150 | 151 | let percentage = 0 152 | const calTrans = () => { 153 | const newPercentage = (item.transferred / item.total).toFixed(2) 154 | if (percentage !== newPercentage) { 155 | percentage = newPercentage 156 | emitChange() 157 | } 158 | } 159 | 160 | const throttleChunk = throttle(calTrans, 100) 161 | 162 | const readStream = Fs.createReadStream(localPath).on('data', chunk => { 163 | item.transferred += chunk.length 164 | if (item.transferred === item.total) { 165 | calTrans() 166 | } else { 167 | throttleChunk() 168 | } 169 | }) 170 | 171 | const request = Request({ 172 | url: url, 173 | headers: { 174 | ...headers, 175 | 'Content-Length': item.total, 176 | }, 177 | method: 'PUT', 178 | }) 179 | .on('response', async response => { 180 | console.log(response) 181 | if (response.statusCode === 200) { 182 | item.status = this.status.completed.value 183 | item.endTime = moment().unix() 184 | readStream.removeAllListeners() 185 | await this.setItem(item.id, item) 186 | emitChange() 187 | resolve('success') 188 | } else { 189 | item.status = this.status.error.value 190 | item.errorMessage = `${response.statusCode}` 191 | readStream.removeAllListeners() 192 | await this.setItem(item.id, item) 193 | emitChange() 194 | reject(`${response.statusCode}`) 195 | } 196 | }) 197 | .on('error', async error => { 198 | item.status = this.status.error.value 199 | item.errorMessage = error && error.message 200 | readStream.removeAllListeners() 201 | await this.setItem(item.id, item) 202 | emitChange() 203 | reject(error) 204 | }) 205 | 206 | readStream.pipe(request) 207 | }) 208 | }, 209 | 210 | async deleteJob({ connectType, id }) { 211 | const store = await this.getStore() 212 | store.data = store.data.filter(item => { 213 | if (id) { 214 | return item.id !== id 215 | } 216 | if (connectType) { 217 | return item.connectType !== connectType || item.status !== this.status.completed.value 218 | } 219 | }) 220 | return await localforage.setItem(this.storeKey, store) 221 | }, 222 | 223 | async getStore() { 224 | const data = await localforage.getItem(this.storeKey) 225 | return data && data.version === this.initStore.version ? data : { ...this.initStore } 226 | }, 227 | } 228 | 229 | export default Object.assign(Object.create(EventEmitter.prototype), Job) 230 | -------------------------------------------------------------------------------- /src/renderer/api/message.js: -------------------------------------------------------------------------------- 1 | import message from 'iview/src/components/message' 2 | 3 | export default { 4 | info(config) { 5 | return message.info(config) 6 | }, 7 | success(config) { 8 | return message.success(config) 9 | }, 10 | warning(config) { 11 | if (typeof config === 'string') 12 | return message.warning({ 13 | content: config, 14 | duration: 5, 15 | }) 16 | if (typeof config === 'object') 17 | return message.warning({ 18 | duration: 5, 19 | ...config, 20 | }) 21 | }, 22 | error(config) { 23 | if (typeof config === 'string') 24 | return message.error({ 25 | content: config, 26 | duration: 5, 27 | }) 28 | if (typeof config === 'object') 29 | return message.error({ 30 | duration: 5, 31 | ...config, 32 | }) 33 | }, 34 | loading(config) { 35 | return message.loading(config) 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/api/notification.js: -------------------------------------------------------------------------------- 1 | import { winShow } from '@/api/electron' 2 | 3 | export default { 4 | notify(title = 'Updrive', options = {}, onClick) { 5 | const notify = new Notification(title, options) 6 | 7 | notify.onclick = (...arg) => { 8 | onClick(...arg) 9 | winShow() 10 | } 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/api/profile.js: -------------------------------------------------------------------------------- 1 | import localforage from 'localforage' 2 | 3 | const Profile = { 4 | initStore: { 5 | version: 0.2, 6 | data: { 7 | domain: '', 8 | urlCopyType: 'url', 9 | sortInfo: { 10 | isReverse: true, 11 | key: 'lastModified', 12 | }, 13 | }, 14 | }, 15 | 16 | urlCopyType: { 17 | url: 'url', 18 | markdown: 'markdown', 19 | html: 'html', 20 | }, 21 | 22 | setup(keyPre) { 23 | this.storeKey = `${keyPre}:profile` 24 | }, 25 | 26 | async setStoreData(_profile) { 27 | const store = await this.getStore() 28 | store.data = { ...store.data, ..._profile } 29 | return await localforage.setItem(this.storeKey, store) 30 | }, 31 | 32 | async getStore() { 33 | const store = await localforage.getItem(this.storeKey) 34 | if (!store || !store.version) return await localforage.setItem(this.storeKey, this.initStore) 35 | if (store.version !== this.initStore.version) return await this.upgrade(store) 36 | return store 37 | }, 38 | 39 | async upgrade(store) { 40 | const data = { ...this.initStore.data } 41 | const oldData = { ...store.data } 42 | if (oldData.domain !== undefined) { 43 | data.domain = oldData.domain 44 | } 45 | if (oldData.urlCopyType !== undefined) { 46 | data.urlCopyType = oldData.urlCopyType 47 | } 48 | if (oldData.sortInfo !== undefined) { 49 | data.sortInfo = oldData.sortInfo 50 | } 51 | return await localforage.setItem(this.storeKey, { ...this.initStore, data }) 52 | }, 53 | } 54 | 55 | export default Profile 56 | -------------------------------------------------------------------------------- /src/renderer/api/session.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setUser(userInfo) { 3 | sessionStorage.setItem('currentUser', JSON.stringify(userInfo)) 4 | }, 5 | getUser() { 6 | let userInfo 7 | try { 8 | const _userInfo = sessionStorage.getItem('currentUser') 9 | if (_userInfo) userInfo = JSON.parse(_userInfo) 10 | } catch (err) { 11 | userInfo = null 12 | } 13 | return userInfo 14 | }, 15 | clear() { 16 | sessionStorage.removeItem('currentUser') 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/api/thumnail.js: -------------------------------------------------------------------------------- 1 | export default { 2 | createThumbnail(originalUrl, targetWidth) { 3 | 4 | const onload = new Promise((resolve, reject) => { 5 | const image = new Image() 6 | image.src = originalUrl 7 | image.onload = () => { 8 | URL.revokeObjectURL(originalUrl) 9 | resolve(image) 10 | } 11 | image.onerror = () => { 12 | URL.revokeObjectURL(originalUrl) 13 | reject(new Error('Could not create thumbnail')) 14 | } 15 | }) 16 | 17 | return onload 18 | .then(image => { 19 | const targetHeight = this.getProportionalHeight(image, targetWidth) 20 | const canvas = this.resizeImage(image, targetWidth, targetHeight) 21 | return this.canvasToBlob(canvas, 'image/png') 22 | }) 23 | .then(blob => { 24 | if(blob) { 25 | return URL.createObjectURL(blob) 26 | } else { 27 | return '' 28 | } 29 | }) 30 | }, 31 | 32 | canvasToBlob(canvas, type, quality) { 33 | if (canvas.toBlob) { 34 | return new Promise(resolve => { 35 | canvas.toBlob(resolve, type, quality) 36 | }) 37 | } 38 | return Promise.resolve().then(() => { 39 | return Utils.dataURItoBlob(canvas.toDataURL(type, quality), {}) 40 | }) 41 | }, 42 | 43 | resizeImage(image, targetWidth, targetHeight) { 44 | // Resizing in steps refactored to use a solution from 45 | // https://blog.uploadcare.com/image-resize-in-browsers-is-broken-e38eed08df01 46 | 47 | image = this.protect(image) 48 | 49 | let steps = Math.ceil(Math.log2(image.width / targetWidth)) 50 | if (steps < 1) { 51 | steps = 1 52 | } 53 | let sW = targetWidth * Math.pow(2, steps - 1) 54 | let sH = targetHeight * Math.pow(2, steps - 1) 55 | const x = 2 56 | 57 | while (steps--) { 58 | let canvas = document.createElement('canvas') 59 | canvas.width = sW 60 | canvas.height = sH 61 | canvas.getContext('2d').drawImage(image, 0, 0, sW, sH) 62 | image = canvas 63 | 64 | sW = Math.round(sW / x) 65 | sH = Math.round(sH / x) 66 | } 67 | 68 | return image 69 | }, 70 | 71 | getProportionalHeight(img, width) { 72 | const aspect = img.width / img.height 73 | return Math.round(width / aspect) 74 | }, 75 | 76 | protect (image) { 77 | // https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element 78 | 79 | var ratio = image.width / image.height 80 | 81 | var maxSquare = 5000000 // ios max canvas square 82 | var maxSize = 4096 // ie max canvas dimensions 83 | 84 | var maxW = Math.floor(Math.sqrt(maxSquare * ratio)) 85 | var maxH = Math.floor(maxSquare / Math.sqrt(maxSquare * ratio)) 86 | if (maxW > maxSize) { 87 | maxW = maxSize 88 | maxH = Math.round(maxW / ratio) 89 | } 90 | if (maxH > maxSize) { 91 | maxH = maxSize 92 | maxW = Math.round(ratio * maxH) 93 | } 94 | if (image.width > maxW) { 95 | var canvas = document.createElement('canvas') 96 | canvas.width = maxW 97 | canvas.height = maxH 98 | canvas.getContext('2d').drawImage(image, 0, 0, maxW, maxH) 99 | image.src = 'about:blank' 100 | image.width = 1 101 | image.height = 1 102 | image = canvas 103 | } 104 | 105 | return image 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/renderer/api/tool.js: -------------------------------------------------------------------------------- 1 | import { replace, compose, pipe, split, filter, reverse, sort, identity } from 'ramda' 2 | import Crypto from 'crypto' 3 | import Path from 'path' 4 | import { URL } from 'url' 5 | import Moment from 'moment' 6 | import { existsSync } from 'fs' 7 | import Message from '@/api/message' 8 | 9 | const userAgent = `${process.env.npm_package_build_productName}/${process.env.npm_package_version}` 10 | 11 | export const errorHandler = error => { 12 | if (error && error.response && error.response.data && error.response.data.msg) { 13 | Message.error(error.response.data.msg) 14 | } else { 15 | Message.error(error.message) 16 | } 17 | throw error 18 | } 19 | 20 | export const mandatory = parameter => { 21 | throw new Error(parameter ? `Missing parameter ${parameter}` : 'Missing parameter') 22 | } 23 | 24 | export const md5sum = data => { 25 | return Crypto.createHash('md5') 26 | .update(data, 'utf8') 27 | .digest('hex') 28 | } 29 | 30 | export const hmacSha1 = (secret = mandatory('secret'), data = mandatory('data')) => { 31 | return Crypto.createHmac('sha1', secret) 32 | .update(data, 'utf8') 33 | .digest() 34 | .toString('base64') 35 | } 36 | 37 | export const standardUri = (path = '') => { 38 | const pathStr = Array.isArray(path) ? path.join('/') : path 39 | return compose(replace(/(\/*)$/, '/'), replace(/^(\/*)/, '/'))(pathStr) 40 | } 41 | 42 | export const makeSign = ({ 43 | method = mandatory('method'), 44 | uri = mandatory('uri'), 45 | date = mandatory('date'), 46 | passwordMd5 = mandatory('passwordMd5'), 47 | operatorName = mandatory('operatorName'), 48 | } = {}) => { 49 | return `UPYUN ${operatorName}:${hmacSha1(passwordMd5, [method, uri, date].join('&'))}` 50 | } 51 | 52 | // @TODO 实现 Content-MD5 校验 53 | export const getAuthorizationHeader = ({ 54 | method = 'GET', 55 | url = '', 56 | passwordMd5 = mandatory('passwordMd5'), 57 | operatorName = mandatory('operatorName'), 58 | } = {}) => { 59 | const date = new Date().toGMTString() 60 | 61 | return { 62 | Authorization: makeSign({ 63 | operatorName, 64 | passwordMd5, 65 | date, 66 | uri: new URL(url).pathname, 67 | method: method.toUpperCase(), 68 | }), 69 | 'x-date': date, 70 | } 71 | } 72 | 73 | export const base64 = (str = '') => new Buffer(str).toString('base64') 74 | 75 | // 以固定间隔时间立即执行的 throttle,和普通的不一样 76 | export const throttle = (fn, ms) => { 77 | let time = 0 78 | return (...args) => { 79 | const nowTime = +new Date() 80 | if (nowTime - time > ms) { 81 | time = nowTime 82 | fn(...args) 83 | } 84 | } 85 | } 86 | 87 | export const sleep = (ms = 0) => { 88 | return new Promise(r => setTimeout(r, ms)) 89 | } 90 | 91 | export const isDir = (path = '') => { 92 | return /\/$/.test(path) 93 | } 94 | 95 | export const timestamp = (input, pattern = 'YYYY-MM-DD HH:mm:ss') => 96 | isNaN(input) ? input : Moment.unix(input).format(pattern) 97 | 98 | export const digiUnit = input => { 99 | if (input === '-') return '' 100 | if (isNaN(input)) return input 101 | if (+input === 0) return '0 B' 102 | const getSizes = () => ['B', 'KB', 'MB', 'GB', 'TB'] 103 | const getByte = input => Number(Math.abs(input)) 104 | const getIndex = byte => Math.floor(Math.log(byte) / Math.log(1024)) 105 | const getUnitIndex = (sizes = []) => index => (index > sizes.length - 1 ? sizes.length - 1 : index) 106 | const getResult = sizes => byte => index => `${(byte / Math.pow(1024, index)).toFixed(1)} ${sizes[index]}` 107 | return compose( 108 | compose(compose(getResult, getSizes)(), getByte)(input), 109 | compose(compose(getUnitIndex, getSizes)(), getIndex, getByte), 110 | )(input) 111 | } 112 | 113 | export const percent = (input, precision = 0) => { 114 | const num = parseFloat(input * 100, 10).toFixed(precision) 115 | return `${num} %` 116 | } 117 | 118 | export const uploadStatus = input => { 119 | return { '0': '未开始', '1': '进行中', '2': '已完成', '-1': '出错', '-2': '已取消' }[input] 120 | } 121 | 122 | // 递归获取不重复名字 123 | export const getLocalName = (fileName = '', init = true) => { 124 | if (!existsSync(fileName)) return fileName 125 | const match = /\((\d+)\)$/ 126 | if (init && match.test(fileName)) { 127 | return getLocalName(fileName.replace(match, (match, p1) => `(${parseInt(p1) + 1})`), false) 128 | } else { 129 | return getLocalName(fileName + '(1)', false) 130 | } 131 | } 132 | 133 | // 获取文件类型 134 | export const getFileType = (IMME = '') => { 135 | const imagelist = ['image/gif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/bmp', 'image/webp'] 136 | if (imagelist.includes(IMME.toLowerCase())) { 137 | return 'image' 138 | } 139 | } 140 | 141 | export const getFileTypeFromName = (filename = '', folderType) => { 142 | if (folderType === 'F' || folderType === 'B') return 'folder' 143 | const fileTypeMap = { 144 | image: ['.bmp', '.gif', '.ico', '.jpg', '.jpeg', '.png', '.svg', '.webp', '.gifv'], 145 | music: ['.mp3', '.m4a', '.ogg'], 146 | zip: ['.zip', '.rar', '.7z'], 147 | movie: ['.avi', '.mp4', '.flv', '.mov', '.3gp', '.asf', '.wmv', '.mpg', '.f4v', '.m4v', '.mkv'], 148 | html: ['.htm', '.html', '.vue'], 149 | json: ['.json'], 150 | javascript: ['.js', '.jsx'], 151 | style: ['.css', '.sass', '.less', '.stylus'], 152 | markdown: ['.md', '.markdown'], 153 | } 154 | const extensionName = Path.extname(filename).toLocaleLowerCase() 155 | return Object.keys(fileTypeMap).find(key => { 156 | return fileTypeMap[key].includes(extensionName) 157 | }) 158 | } 159 | 160 | export const getFileIconClass = (filename = '', folderType) => { 161 | const filetype = getFileTypeFromName(filename, folderType) 162 | return `icon-${filetype}` 163 | } 164 | 165 | export const externalUrls = { 166 | repository: 'https://github.com/aniiantt/updrive', 167 | issues: 'https://github.com/aniiantt/updrive/issues/new', 168 | releases: 'https://github.com/aniiantt/updrive/releases', 169 | latest: 'https://github.com/aniiantt/updrive/releases/latest', 170 | } 171 | 172 | export const listSort = (data = [], key, isReverse) => { 173 | if (!key) return data 174 | 175 | const naturalCompareString = (a = '', b = '') => { 176 | try { 177 | const splitByNumber = pipe(split(/(\d+)/), filter(identity)) 178 | const [aArr, bArr] = [splitByNumber(a), splitByNumber(b)] 179 | for (let i = 0; i < aArr.length; i++) { 180 | if (aArr[i] !== bArr[i]) { 181 | if (bArr[i] === undefined) return 1 182 | if (!isNaN(aArr[i]) && !isNaN(bArr[i])) { 183 | return parseInt(aArr[i]) - parseInt(bArr[i]) 184 | } else { 185 | return aArr[i].localeCompare(bArr[i]) 186 | } 187 | } 188 | } 189 | return 0 190 | } catch (err) { 191 | return a.localeCompare(b) 192 | } 193 | } 194 | 195 | const sortData = sort((ObjA, ObjB) => { 196 | if (ObjA.folderType !== ObjB.folderType) { 197 | if (!isReverse) return ObjA.folderType === 'F' ? -1 : 1 198 | if (isReverse) return ObjA.folderType === 'F' ? 1 : -1 199 | } 200 | if (key === 'lastModified' || key === 'size') { 201 | return ObjA[key] !== ObjB[key] 202 | ? Number(ObjA[key]) - Number(ObjB[key]) 203 | : naturalCompareString(ObjA.filename, ObjB.filename) 204 | } 205 | if (key === 'filetype' || key === 'filename') { 206 | return ObjA[key] !== ObjB[key] 207 | ? naturalCompareString(String(ObjA[key]), String(ObjB[key])) 208 | : naturalCompareString(ObjA.filename, ObjB.filename) 209 | } 210 | }, data) 211 | 212 | return isReverse ? reverse(sortData) : sortData 213 | } 214 | 215 | export const createImage = () => { 216 | 217 | console.log('大大说') 218 | } -------------------------------------------------------------------------------- /src/renderer/api/upyunClient.js: -------------------------------------------------------------------------------- 1 | import { 2 | tail, 3 | head, 4 | pipe, 5 | uniq, 6 | range, 7 | path, 8 | split, 9 | map, 10 | zipObj, 11 | compose, 12 | objOf, 13 | ifElse, 14 | isEmpty, 15 | assoc, 16 | replace, 17 | converge, 18 | always, 19 | prop, 20 | concat, 21 | identity, 22 | __, 23 | equals, 24 | } from 'ramda' 25 | import { readFileSync, createReadStream, createWriteStream, readdirSync, statSync, mkdirSync, existsSync } from 'fs' 26 | import Request from 'request' 27 | import Path from 'path' 28 | import mime from 'mime' 29 | import axios from 'axios' 30 | 31 | import { mandatory, base64, md5sum, sleep, isDir, getLocalName, getAuthorizationHeader } from '@/api/tool' 32 | import UpyunFtp from '@/api/upyunFtp' 33 | 34 | export default { 35 | bucketName: '', 36 | operatorName: '', 37 | passwordMd5: '', 38 | ftp: Object.create(UpyunFtp), 39 | 40 | setup(bucketName, operatorName, password) { 41 | this.bucketName = bucketName 42 | this.operatorName = operatorName 43 | this.passwordMd5 = md5sum(password) 44 | this.ftp.setup(bucketName, operatorName, password) 45 | }, 46 | 47 | // // fetch 请求获取不了自定义响应头 48 | // requestWithFetch(input, config = {}, responseHandle = response => response) { 49 | // const url = this.getUrl(input) 50 | // config.headers = { ...config.headers, ...this.getHeaders(url, config.method) } 51 | // return window 52 | // .fetch(url, config) 53 | // .then(res => res.text()) 54 | // .then(responseHandle) 55 | // }, 56 | 57 | request(input, config = {}, responseHandle = response => response.data) { 58 | const url = this.getUrl(input) 59 | config.url = url 60 | config.headers = { ...config.headers, ...this.getHeaders(url, config.method) } 61 | return axios({ 62 | responseType: 'text', 63 | ...config, 64 | }).then(responseHandle) 65 | }, 66 | 67 | getUrl(input) { 68 | const uri = typeof input === 'object' ? input.uri : input 69 | const search = typeof input === 'object' ? input.search : '' 70 | const urlObject = new URL(`${this.bucketName}${uri}`, `https://v0.api.upyun.com`) 71 | if (search) urlObject.search = search 72 | return urlObject.href 73 | }, 74 | 75 | getHeaders(url, method = 'GET') { 76 | return { 77 | ...getAuthorizationHeader({ 78 | passwordMd5: this.passwordMd5, 79 | operatorName: this.operatorName, 80 | method: method, 81 | url, 82 | }), 83 | } 84 | }, 85 | 86 | makeRequestOpts({ search = '', uri = '', method, headers = {} } = {}) { 87 | const url = this.getUrl(uri, { search }) 88 | 89 | const _headers = { ...headers, ...this.getHeaders(url, method) } 90 | 91 | return { 92 | method, 93 | url, 94 | headers: _headers, 95 | } 96 | }, 97 | 98 | // 遍历目录 99 | async traverseDir(uris = '', opts = {}) { 100 | let files = [] 101 | // 递归遍历目录 102 | const parseDir = async (paths, fromPath = '') => { 103 | for (const _path of paths) { 104 | try { 105 | if (isDir(_path)) { 106 | files.push({ 107 | absolutePath: _path, 108 | relativePath: fromPath + Path.basename(_path) + '/', 109 | }) 110 | const dirData = await this.getListDirInfo(_path) 111 | if (dirData && dirData.data && dirData.data.length) 112 | await parseDir(dirData.data.map(fileObj => fileObj.uri), fromPath + Path.basename(_path) + '/') 113 | } else { 114 | files.push({ 115 | absolutePath: _path, 116 | relativePath: fromPath + Path.basename(_path), 117 | }) 118 | } 119 | } catch (err) { 120 | console.error(err) 121 | } 122 | } 123 | } 124 | 125 | await parseDir(uris) 126 | 127 | // 文件顺序 128 | if (opts.reverse === true) { 129 | files = files.reverse() 130 | } 131 | 132 | if (opts.type === 'file') { 133 | files = files.filter(f => !isDir(f.absolutePath)) 134 | } 135 | 136 | if (opts.type === 'folder') { 137 | files = files.filter(f => isDir(f.absolutePath)) 138 | } 139 | 140 | if (opts.relative !== true) { 141 | files = files.map(o => o.absolutePath) 142 | } 143 | 144 | return files 145 | }, 146 | 147 | 148 | // HEAD 请求 149 | async head(uri) { 150 | return this.request(uri, { method: 'HEAD' }, response => response.headers) 151 | }, 152 | 153 | // GET 请求 154 | async get(uri) { 155 | return this.request(uri, { method: 'GET' }) 156 | }, 157 | 158 | // 授权认证 159 | async checkAuth() { 160 | return this.request({ search: '?usage', uri: '/' }) 161 | }, 162 | 163 | // 获取使用量 164 | async getUsage() { 165 | return this.request({ search: '?usage', uri: '/' }) 166 | }, 167 | 168 | // 获取目录列表信息 169 | async getListDirInfo(uri = '/') { 170 | return this.request(uri, { method: 'GET' }).then( 171 | compose( 172 | assoc('path', uri), 173 | ifElse( 174 | isEmpty, 175 | () => ({ data: [] }), 176 | compose( 177 | objOf('data'), 178 | compose( 179 | map(obj => { 180 | obj.filetype = obj.folderType === 'F' ? '' : mime.getType(obj.filename) 181 | obj.uri = uri + obj.filename + (obj.folderType === 'F' ? '/' : '') 182 | return obj 183 | }), 184 | map(compose(zipObj(['filename', 'folderType', 'size', 'lastModified']), split(/\t/))), 185 | split(/\n/), 186 | ), 187 | ), 188 | ), 189 | ), 190 | ) 191 | }, 192 | 193 | // 创建目录 194 | async createFolder(location = '', folderName = '') { 195 | return this.request(`${location}${folderName}/`, { method: 'POST', headers: { folder: true } }) 196 | }, 197 | 198 | // 上传文件 199 | async uploadFiles(uri, localFilePaths = [], jobObj) { 200 | const results = [] 201 | 202 | // 上传单个文件 203 | const uploadFile = async (uploadLocation, localFilePath) => { 204 | const localFileStat = statSync(localFilePath) 205 | const basename = Path.basename(localFilePath) 206 | if(!localFileStat.isFile()) return Promise.resolve(this.createFolder(uploadLocation, basename)) 207 | const url = this.getUrl(uploadLocation + basename) 208 | const headers = { ...this.getHeaders(url, 'PUT') } 209 | return await jobObj.createUploadTask({ 210 | url: url, 211 | headers: headers, 212 | localPath: localFilePath, 213 | }) 214 | } 215 | 216 | // 广度优先遍历 217 | const uploadList = [] 218 | let list = localFilePaths.slice().map(path => ({ localFilePath: path, relativePath: '' })) 219 | 220 | while (list.length) { 221 | const node = list.shift() 222 | const { localFilePath, relativePath } = node 223 | if (statSync(localFilePath).isDirectory() && readdirSync(localFilePath).length) { 224 | list = list.concat( 225 | readdirSync(localFilePath).map(name => ({ 226 | localFilePath: Path.join(localFilePath, name), 227 | relativePath: relativePath + Path.basename(localFilePath) + '/', 228 | })), 229 | ) 230 | } else { 231 | uploadList.push(node) 232 | } 233 | } 234 | 235 | for (const pathObj of uploadList) { 236 | const uploadLocation = uri + pathObj.relativePath 237 | try { 238 | results.push({ 239 | result: true, 240 | location: uploadLocation, 241 | localPath: pathObj.localFilePath, 242 | message: await uploadFile(uploadLocation, pathObj.localFilePath), 243 | }) 244 | } catch (err) { 245 | console.error(err) 246 | results.push({ 247 | result: false, 248 | location: uploadLocation, 249 | localPath: pathObj.localFilePath, 250 | message: err && err.message, 251 | }) 252 | } 253 | } 254 | 255 | return results 256 | }, 257 | 258 | // 删除文件 259 | async deleteFiles(uris) { 260 | const results = [] 261 | const waitDeleteInit = await this.traverseDir(uris, { reverse: true }) 262 | 263 | for (const uri of waitDeleteInit) { 264 | try { 265 | results.push({ 266 | uri: uri, 267 | result: true, 268 | message: await this.request(uri, { method: 'DELETE' }), 269 | }) 270 | } catch (err) { 271 | results.push({ 272 | uri: uri, 273 | result: false, 274 | message: err && err.message, 275 | }) 276 | } 277 | } 278 | 279 | return results 280 | }, 281 | 282 | // 下载文件 283 | async downloadFiles(destPath, uris, jobObj) { 284 | // 下载单个文件 285 | const downloadFile = async (localPath, uri) => { 286 | if (!uri && !existsSync(localPath)) return Promise.resolve(mkdirSync(localPath)) 287 | const url = this.getUrl(uri) 288 | const headers = { ...this.getHeaders(url, 'GET') } 289 | return await jobObj.createDownloadTask({ 290 | url: url, 291 | headers: headers, 292 | localPath: localPath, 293 | }) 294 | } 295 | 296 | const results = [] 297 | 298 | const dir = await this.traverseDir(uris, { relative: true }) 299 | const dirAll = dir.map(pathObj => { 300 | return { 301 | uri: isDir(pathObj.absolutePath) ? '' : pathObj.absolutePath, 302 | localPath: Path.join( 303 | getLocalName(Path.join(destPath, pipe(prop('relativePath'), split('/'), head)(pathObj))), 304 | ...pipe(prop('relativePath'), split('/'), tail)(pathObj), 305 | ), 306 | } 307 | }) 308 | 309 | for (const pathObj of dirAll) { 310 | try { 311 | results.push({ 312 | uri: pathObj.uri, 313 | localPath: pathObj.localPath, 314 | result: true, 315 | message: await downloadFile(pathObj.localPath, pathObj.uri), 316 | }) 317 | } catch (err) { 318 | results.push({ 319 | uri: pathObj.uri, 320 | localPath: pathObj.localPath, 321 | result: false, 322 | message: err && err.message, 323 | }) 324 | } 325 | } 326 | 327 | return results 328 | }, 329 | 330 | // 重命名文件 331 | async renameFile(oldPath, newPath) { 332 | await this.ftp.renameFile(oldPath, newPath) 333 | }, 334 | } 335 | 336 | -------------------------------------------------------------------------------- /src/renderer/api/upyunFtp.js: -------------------------------------------------------------------------------- 1 | import { 2 | path, 3 | split, 4 | map, 5 | zipObj, 6 | compose, 7 | objOf, 8 | ifElse, 9 | isEmpty, 10 | assoc, 11 | replace, 12 | converge, 13 | always, 14 | prop, 15 | concat, 16 | identity, 17 | __, 18 | equals, 19 | } from 'ramda' 20 | import Moment from 'moment' 21 | import Ftp from 'ftp' 22 | 23 | export default { 24 | setup(bucketName, operatorName, password) { 25 | const ftpClient = new Ftp() 26 | ftpClient.on('ready', () => { 27 | console.info('--------------- ftp 连接成功 ---------------') 28 | }) 29 | ftpClient.on('close', error => { 30 | console.info('--------------- ftp 已关闭 ---------------') 31 | }) 32 | 33 | const connect = async () => { 34 | return new Promise((resolve, reject) => { 35 | ftpClient.connect({ 36 | host: 'v0.ftp.upyun.com', 37 | user: `${operatorName}/${bucketName}`, 38 | password: password, 39 | }) 40 | ftpClient.once('ready', resolve) 41 | }) 42 | } 43 | 44 | const renamePromise = (oldPath, newPath) => { 45 | return new Promise(async (resolve, reject) => { 46 | ftpClient.rename(oldPath, newPath, err => { 47 | if (err) reject(err) 48 | resolve() 49 | }) 50 | }) 51 | } 52 | 53 | this.renameFile = async (oldPath, newPath) => { 54 | await connect() 55 | return renamePromise(oldPath, newPath) 56 | .then(() => { 57 | console.info('路径修改成功', `${oldPath} => ${newPath}`) 58 | ftpClient.end() 59 | return Promise.resolve(newPath) 60 | }) 61 | .catch(err => { 62 | ftpClient.end() 63 | return Promise.reject(err) 64 | }) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/api/user.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import localforage from 'localforage' 3 | import { remove, prepend } from 'ramda' 4 | 5 | import UpyunClient from '@/api/upyunClient' 6 | 7 | export default { 8 | storeKey: 'authHistory', 9 | 10 | initStore: { 11 | version: 0.1, 12 | data: [], 13 | }, 14 | 15 | bucketName: '', 16 | operatorName: '', 17 | password: '', 18 | key: '', 19 | client: Object.create(UpyunClient), 20 | 21 | setup(bucketName, operatorName, password) { 22 | this.bucketName = bucketName 23 | this.operatorName = operatorName 24 | this.password = password 25 | this.key = `${this.operatorName}/${this.bucketName}` 26 | this.client.setup(this.bucketName, this.operatorName, this.password) 27 | }, 28 | 29 | save() { 30 | this.getAuthHistory().then(data => { 31 | const authHistory = data 32 | const record = { 33 | bucketName: this.bucketName, 34 | operatorName: this.operatorName, 35 | password: this.password, 36 | key: this.key, 37 | lastModified: moment().unix(), 38 | remark: '', 39 | } 40 | const recordIndex = authHistory.data.findIndex(u => u.key === this.key) 41 | if (~recordIndex) { 42 | authHistory[recordIndex] = { ...record } 43 | } else { 44 | authHistory.data = prepend(record, authHistory.data) 45 | } 46 | 47 | return localforage.setItem(this.storeKey, authHistory) 48 | }) 49 | }, 50 | 51 | getAuthHistory() { 52 | return localforage.getItem(this.storeKey).then(data => { 53 | return data && data.version === this.initStore.version ? data : { ...this.initStore } 54 | }) 55 | }, 56 | 57 | deleteAuthHistory(key) { 58 | return this.getAuthHistory().then(data => { 59 | const authHistory = data 60 | authHistory.data = authHistory.data.filter(u => u.key !== key) 61 | return localforage.setItem(this.storeKey, authHistory) 62 | }) 63 | }, 64 | } 65 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/css.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/html.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/javascript.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/json.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/markdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/movie.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/music.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/assets/icons/zip.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 55 | -------------------------------------------------------------------------------- /src/renderer/components/ConfirmModal.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 68 | 69 | 80 | -------------------------------------------------------------------------------- /src/renderer/components/Icon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | -------------------------------------------------------------------------------- /src/renderer/components/LocalImage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /src/renderer/components/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 53 | -------------------------------------------------------------------------------- /src/renderer/components/ResIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 89 | 90 | 97 | 98 | -------------------------------------------------------------------------------- /src/renderer/components/Spinner.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 35 | -------------------------------------------------------------------------------- /src/renderer/fonts/SourceHanSansCN-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/src/renderer/fonts/SourceHanSansCN-Light.ttf -------------------------------------------------------------------------------- /src/renderer/imgs/updrive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | background 4 | 5 | 6 | 7 | 8 | Layer 1 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/renderer/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import iviewCSS from 'iview/dist/styles/iview.css' 3 | import balloonCss from 'balloon-css/balloon.css' 4 | 5 | import App from '@/App' 6 | import Router from '@/router' 7 | import Store from '@/store' 8 | 9 | if (!process.env.IS_WEB) Vue.use(require('vue-electron')) 10 | Vue.config.productionTip = false 11 | 12 | /* eslint-disable no-new */ 13 | new Vue({ 14 | components: { App }, 15 | router: Router, 16 | store: Store, 17 | template: '', 18 | }).$mount('#app') 19 | -------------------------------------------------------------------------------- /src/renderer/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import { path } from 'ramda' 4 | 5 | import Store from '@/store' 6 | 7 | import Main from '@/views/layout/Main' 8 | import Login from '@/views/login/Login' 9 | import List from '@/views/list/List' 10 | import Upload from '@/views/upload/Upload' 11 | import Download from '@/views/download/Download' 12 | import Session from '@/api/session.js' 13 | 14 | Vue.use(Router) 15 | 16 | const router = new Router({ 17 | routes: [ 18 | { 19 | path: '/', 20 | component: Main, 21 | children: [ 22 | { 23 | path: '', 24 | name: 'main', 25 | components: { 26 | body: List, 27 | }, 28 | }, 29 | { 30 | path: 'upload', 31 | name: 'upload', 32 | components: { 33 | body: Upload, 34 | }, 35 | meta: { 36 | pageTitle: '上传列表', 37 | }, 38 | }, 39 | { 40 | path: 'download', 41 | name: 'download', 42 | components: { 43 | body: Download, 44 | }, 45 | meta: { 46 | pageTitle: '下载列表', 47 | }, 48 | }, 49 | ], 50 | }, 51 | { 52 | path: '/login', 53 | name: 'login', 54 | component: Login, 55 | }, 56 | { 57 | path: '*', 58 | redirect: '/', 59 | }, 60 | ], 61 | }) 62 | 63 | router.beforeEach((to, from, next) => { 64 | const isLogined = path(['state', 'auth', 'isLogined'], Store) 65 | 66 | if (to.name === 'login' || isLogined) { 67 | next() 68 | } else { 69 | const userInfo = Session.getUser() 70 | if (userInfo) { 71 | Store.dispatch({ 72 | type: 'VERIFICATION_ACCOUNT', 73 | ...userInfo, 74 | }) 75 | .then(() => { 76 | next() 77 | }) 78 | .catch(() => { 79 | next('/login') 80 | }) 81 | } else { 82 | next('/login') 83 | } 84 | } 85 | }) 86 | 87 | export default router 88 | -------------------------------------------------------------------------------- /src/renderer/store/actions.js: -------------------------------------------------------------------------------- 1 | import { join, append, compose, unless, isEmpty } from 'ramda' 2 | 3 | import { errorHandler, getFileType } from '@/api/tool.js' 4 | import * as Types from '@/store/mutation-types' 5 | import * as UpyunFtp from '@/api/upyunFtp.js' 6 | import UpyunClient from '@/api/upyunClient.js' 7 | import Message from '@/api/message.js' 8 | import Session from '@/api/session.js' 9 | import Notification from '@/api/notification' 10 | import Router from '@/router' 11 | 12 | export default { 13 | // 登录 14 | [Types.VERIFICATION_ACCOUNT]({ getters, commit }, payload) { 15 | const userInfo = { 16 | bucketName: payload.bucketName, 17 | operatorName: payload.operatorName, 18 | password: payload.password, 19 | } 20 | commit(Types.SET_USER_INFO, userInfo) 21 | 22 | return getters.upyunClient 23 | .checkAuth() 24 | .then(() => { 25 | Session.setUser(userInfo) 26 | return userInfo 27 | }) 28 | .catch(error => { 29 | commit(Types.CLEAR_USER_INFO) 30 | Session.clear() 31 | return Promise.reject(error) 32 | }) 33 | }, 34 | // 获取文件目录信息 35 | [Types.GET_LIST_DIR_INFO]({ getters, commit }, { remotePath, spinner = true, action }) { 36 | if (spinner) commit({ type: Types.SET_LOADING_LIST, data: true }) 37 | return getters.upyunClient 38 | .getListDirInfo(remotePath) 39 | .then(result => { 40 | commit({ 41 | type: Types.SET_CURRENT_LIST, 42 | data: result, 43 | action: action, 44 | }) 45 | return result 46 | }) 47 | .catch(errorHandler) 48 | }, 49 | // 创建目录 50 | [Types.CREATE_FOLDER]({ getters, commit, dispatch }, { remotePath, folderName }) { 51 | return getters.upyunClient 52 | .createFolder(remotePath, folderName) 53 | .then(() => Message.success('文件夹创建成功')) 54 | .then(() => dispatch({ type: Types.REFRESH_LIST, spinner: false })) 55 | .catch(errorHandler) 56 | }, 57 | // 刷新当前目录 58 | [Types.REFRESH_LIST]({ state, getters, commit, dispatch }, { remotePath, spinner = true } = {}) { 59 | return dispatch({ type: Types.GET_LIST_DIR_INFO, remotePath: remotePath || state.list.dirInfo.path, spinner }) 60 | }, 61 | // 删除文件 62 | [Types.DELETE_FILE]({ getters, commit, dispatch }, { selectedPaths } = {}) { 63 | return getters.upyunClient 64 | .deleteFiles(selectedPaths) 65 | .then(results => { 66 | const isAllSuccess = !results.some(r => !r.result) 67 | if (isAllSuccess) { 68 | Message.success('删除成功') 69 | } else { 70 | for (const result of results) Message.warning(`删除失败:${result.uri}: ${result.message}`) 71 | } 72 | }) 73 | .then(() => dispatch({ type: Types.REFRESH_LIST, spinner: false })) 74 | .catch(errorHandler) 75 | }, 76 | // 重命名 77 | [Types.RENAME_FILE]({ getters, commit, dispatch }, { oldPath, newPath, isFolder } = {}) { 78 | return getters.upyunClient 79 | .renameFile(oldPath, newPath) 80 | .then(() => Message.success('操作成功')) 81 | .then(() => dispatch({ type: Types.REFRESH_LIST, spinner: false })) 82 | .catch(errorHandler) 83 | }, 84 | // 下载文件 85 | [Types.DOWNLOAD_FILES]({ getters, commit, dispatch }, { destPath, downloadPath } = {}) { 86 | return ( 87 | getters.upyunClient 88 | .downloadFiles(destPath, downloadPath, getters.job) 89 | .then(results => { 90 | const isAllSuccess = !results.some(r => !r.result) 91 | const notify = title => 92 | Notification.notify( 93 | title, 94 | { 95 | body: '点击查看详情', 96 | }, 97 | () => { 98 | Router.push({ name: 'download' }) 99 | }, 100 | ) 101 | 102 | if (isAllSuccess) { 103 | Message.success('下载成功') 104 | notify('下载成功') 105 | } else { 106 | for (const result of results) Message.warning(`下载失败:${result.uri}: ${result.message}`) 107 | notify('下载失败') 108 | } 109 | return dispatch(Types.SYNC_JOB_LIST) 110 | }) 111 | .catch(errorHandler) 112 | // 同步错误信息 113 | .catch(() => dispatch(Types.SYNC_JOB_LIST)) 114 | ) 115 | }, 116 | // 上传文件 117 | [Types.UPLOAD_FILES]({ getters, commit, dispatch }, { localFilePaths, remotePath } = {}) { 118 | return ( 119 | getters.upyunClient 120 | .uploadFiles(remotePath, localFilePaths, getters.job) 121 | .then(results => { 122 | const isAllSuccess = !results.some(r => !r.result) 123 | const notify = title => 124 | Notification.notify( 125 | title, 126 | { 127 | body: '点击查看详情', 128 | }, 129 | () => { 130 | Router.push({ name: 'upload' }) 131 | }, 132 | ) 133 | if (isAllSuccess) { 134 | Message.success('上传成功') 135 | notify('上传成功') 136 | } else { 137 | for (const result of results) Message.warning(`上传失败:${result.localPath}: ${result.message}`) 138 | notify('上传失败') 139 | } 140 | return dispatch(Types.SYNC_JOB_LIST) 141 | }) 142 | .then(() => dispatch({ type: Types.REFRESH_LIST, spinner: false })) 143 | .catch(errorHandler) 144 | // 同步错误信息 145 | .catch(() => dispatch(Types.SYNC_JOB_LIST)) 146 | ) 147 | }, 148 | // 获取文件详情信息 149 | [Types.GET_FILE_DETAIL_INFO]({ getters, commit }, { uri, basicInfo } = {}) { 150 | return Promise.resolve() 151 | .then(() => { 152 | if (basicInfo.folderType === 'F') return Promise.resolve() 153 | return getters.upyunClient.head(uri) 154 | }) 155 | .then(data => { 156 | const fileType = data && getFileType(data['content-type']) 157 | commit({ 158 | type: Types.SET_FILE_DETAIL_INFO, 159 | data: { 160 | headerInfo: data, 161 | fileType: fileType, 162 | basicInfo: basicInfo, 163 | }, 164 | }) 165 | }) 166 | }, 167 | // 同步任务列表 168 | [Types.SYNC_JOB_LIST]({ getters, commit }, {} = {}) { 169 | getters.job.getStore().then(store => { 170 | commit(Types.SET_JOB_LIST, store ? store.data : []) 171 | }) 172 | }, 173 | // 清空已完成任务 174 | [Types.DELETE_JOB]({ getters, commit, dispatch }, { connectType, id } = {}) { 175 | getters.job 176 | .deleteJob({ connectType, id }) 177 | .then(() => { 178 | Message.success('操作成功') 179 | dispatch('SYNC_JOB_LIST') 180 | }) 181 | .catch(errorHandler) 182 | }, 183 | // 获取空间使用量 184 | [Types.GET_USAGE]({ state, getters, commit, dispatch }, {} = {}) { 185 | return getters.upyunClient.getUsage().then(data => { 186 | commit(Types.SET_USAGE, { data }) 187 | }) 188 | }, 189 | // 退出登录 190 | [Types.LOGOUT]({ commit }) { 191 | const mutations = ['RESET_AUTH', 'RESET_LIST', 'RESET_MODAL', 'RESET_TASK'] 192 | for (const m of mutations) commit(m) 193 | }, 194 | // 设置 profile 存储数据 195 | [Types.SET_PROFILE_STORE]({ getters, dispatch }, { data } = {}) { 196 | getters.profile.setStoreData(data).then(() => { 197 | dispatch('SYNC_PROFILE_DATA') 198 | }) 199 | }, 200 | // 同步 profile 数据 201 | [Types.SYNC_PROFILE_DATA]({ getters, commit }, {} = {}) { 202 | getters.profile.getStore().then(store => { 203 | commit(Types.SET_PROFILE_DATA, store ? store.data : {}) 204 | }) 205 | }, 206 | } 207 | -------------------------------------------------------------------------------- /src/renderer/store/getters.js: -------------------------------------------------------------------------------- 1 | import { path, last } from 'ramda' 2 | 3 | import { externalUrls as __externalUrls } from '@/api/tool' 4 | 5 | export const bucketName = (state, getters) => { 6 | return path(['auth', 'user', 'bucketName'])(state) || '' 7 | } 8 | 9 | export const baseHref = (state, getters) => { 10 | return path(['profile', 'data', 'domain'])(state) || '' 11 | } 12 | 13 | export const externalUrls = (state, getters) => { 14 | return { 15 | domain: `https://console.upyun.com/services/${getters.bucketName}/domainsFile/`, 16 | createBucket: `https://console.upyun.com/services/create/file/`, 17 | ...__externalUrls, 18 | } 19 | } 20 | 21 | // upyunClient 对象 22 | export const upyunClient = state => { 23 | return path(['auth', 'user', 'client'], state) || null 24 | } 25 | 26 | // 获取 upyun api url 27 | export const getUpyunApiUrl = (state, getters) => uri => { 28 | return getters.upyunClient.getUrl(uri) 29 | } 30 | 31 | // job 对象 32 | export const job = state => { 33 | return path(['task', 'job'], state) || null 34 | } 35 | 36 | // profile handler 对象 37 | export const profile = state => { 38 | return path(['profile', 'handler'], state) || null 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | import Actions from '@/store/actions' 5 | import * as Getters from '@/store/getters' 6 | import Modules from '@/store/modules' 7 | 8 | Vue.use(Vuex) 9 | 10 | export default new Vuex.Store({ 11 | actions: Actions, 12 | getters: Getters, 13 | modules: Modules, 14 | strict: process.env.NODE_ENV !== 'production', 15 | }) 16 | -------------------------------------------------------------------------------- /src/renderer/store/modules/auth.js: -------------------------------------------------------------------------------- 1 | import * as Types from '@/store/mutation-types' 2 | import { pickAll } from 'ramda' 3 | 4 | import User from '@/api/user' 5 | 6 | const user = Object.create(User) 7 | 8 | const initState = { 9 | user: user, 10 | usage: 0, 11 | isLogined: false, 12 | } 13 | 14 | const mutations = { 15 | [Types.SET_USER_INFO](state, payload = {}) { 16 | state.user.setup(payload.bucketName, payload.operatorName, payload.password) 17 | state.isLogined = true 18 | }, 19 | [Types.SET_USAGE](state, { data }) { 20 | state.usage = data 21 | }, 22 | [Types.CLEAR_USER_INFO](state) { 23 | sessionStorage.removeItem('key') 24 | Object.assign(state, { ...initState }, { user: Object.create(User) }) 25 | }, 26 | [Types.RESET_AUTH](state) { 27 | Object.assign(state, { ...initState }, { user: Object.create(User) }) 28 | }, 29 | } 30 | 31 | export default { 32 | state: { ...initState }, 33 | mutations, 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/store/modules/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The file enables `@/store/index.js` to import all vuex modules 3 | * in a one-shot manner. There should not be any reason to edit this file. 4 | */ 5 | 6 | const files = require.context('.', false, /\.js$/) 7 | const modules = {} 8 | 9 | files.keys().forEach(key => { 10 | if (key === './index.js') return 11 | modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default 12 | }) 13 | 14 | export default modules 15 | -------------------------------------------------------------------------------- /src/renderer/store/modules/list.js: -------------------------------------------------------------------------------- 1 | import { 2 | last, 3 | dropLast, 4 | path, 5 | merge, 6 | append, 7 | pluck, 8 | } from 'ramda' 9 | import * as Types from '@/store/mutation-types' 10 | 11 | const initState = { 12 | dirInfo: { 13 | data: [], 14 | loading: false, 15 | path: '', 16 | }, 17 | history: { 18 | forwardStack: [], 19 | backStack: [], 20 | }, 21 | fileDetail: { 22 | basicInfo: {}, 23 | headerInfo: {}, 24 | }, 25 | selected: [], 26 | } 27 | 28 | 29 | 30 | const mutations = { 31 | [Types.SET_CURRENT_LIST](state, { data, action }) { 32 | const historyPath = path(['dirInfo', 'path'], state) 33 | let forwardStack = path(['history', 'forwardStack'], state) 34 | let backStack = path(['history', 'backStack'], state) 35 | 36 | state.dirInfo = { 37 | ...data, 38 | loading: false, 39 | } 40 | 41 | state.selected = [] 42 | 43 | // action 0 表示打开新目录 44 | if (action === 0) { 45 | state.history.forwardStack = [] 46 | if (last(state.history.backStack) !== historyPath) { 47 | state.history.backStack = append(historyPath, backStack) 48 | } 49 | } 50 | // action 1 表示前进 51 | if (action === 1) { 52 | if (state.history.forwardStack.length) { 53 | state.history.backStack = append(historyPath, backStack) 54 | state.history.forwardStack = dropLast(1, forwardStack) 55 | } 56 | } 57 | // action -1 表示后退 58 | if (action === -1) { 59 | if (state.history.backStack.length) { 60 | state.history.forwardStack = append(historyPath, forwardStack) 61 | state.history.backStack = dropLast(1, backStack) 62 | } 63 | } 64 | }, 65 | [Types.SET_LOADING_LIST](state, { data }) { 66 | state.dirInfo.loading = data 67 | }, 68 | [Types.SHORTCUT_SELECT_ALL](state, data) { 69 | state.selected = pluck('uri', state.dirInfo.data) 70 | }, 71 | [Types.SET_SELECT_LIST](state, { selected }) { 72 | state.selected = selected 73 | }, 74 | [Types.SET_FILE_DETAIL_INFO](state, { data }) { 75 | state.fileDetail = data 76 | }, 77 | [Types.RESET_LIST](state) { 78 | Object.assign(state, { ...initState }) 79 | }, 80 | } 81 | 82 | export default { 83 | state: { ...initState }, 84 | mutations, 85 | } 86 | -------------------------------------------------------------------------------- /src/renderer/store/modules/modal.js: -------------------------------------------------------------------------------- 1 | import * as Types from '@/store/mutation-types' 2 | 3 | const initState = { 4 | createFolder: { 5 | show: false, 6 | }, 7 | renameFile: { 8 | show: false, 9 | oldPath: '', 10 | }, 11 | domainSetting: { 12 | show: false, 13 | }, 14 | formatUrl: { 15 | show: false, 16 | url: '', 17 | }, 18 | uploadHandle: { 19 | show: false, 20 | }, 21 | } 22 | 23 | const mutations = { 24 | [Types.OPEN_UPLOAD_HANDLE_MODAL](state, { data }) { 25 | state.uploadHandle.show = true 26 | }, 27 | [Types.CLOSE_UPLOAD_HANDLE_MODAL](state) { 28 | state.uploadHandle.show = false 29 | }, 30 | [Types.OPEN_FORMAT_URL_MODAL](state, { data }) { 31 | state.formatUrl.show = true 32 | state.formatUrl.url = data 33 | }, 34 | [Types.CLOSE_FORMAT_URL_MODAL](state) { 35 | state.formatUrl.show = false 36 | state.formatUrl.url = '' 37 | }, 38 | [Types.OPEN_CREATE_FOLDER_MODAL](state) { 39 | state.createFolder.show = true 40 | }, 41 | [Types.CLOSE_CREATE_FOLDER_MODAL](state) { 42 | state.createFolder.show = false 43 | }, 44 | [Types.OPEN_DOMAIN_SETTING_MODAL](state) { 45 | state.domainSetting.show = true 46 | }, 47 | [Types.CLOSE_DOMAIN_SETTING_MODAL](state) { 48 | state.domainSetting.show = false 49 | }, 50 | [Types.OPEN_RENAME_FILE_MODAL](state) { 51 | state.renameFile.show = true 52 | }, 53 | [Types.CLOSE_RENAME_FILE_MODAL](state) { 54 | state.renameFile.show = false 55 | }, 56 | [Types.RENAME_FILE_SET_OLD_PATH](state, oldPath) { 57 | state.renameFile.oldPath = oldPath 58 | }, 59 | [Types.RENAME_FILE_CLEAR_OLD_PATH](state) { 60 | state.renameFile.oldPath = '' 61 | }, 62 | [Types.RESET_MODAL](state) { 63 | Object.assign(state, { ...initState }) 64 | }, 65 | } 66 | 67 | export default { 68 | state: { ...initState }, 69 | mutations, 70 | } 71 | -------------------------------------------------------------------------------- /src/renderer/store/modules/profile.js: -------------------------------------------------------------------------------- 1 | import * as Types from '@/store/mutation-types' 2 | 3 | const initState = { 4 | handler: null, 5 | data: {}, 6 | } 7 | 8 | const mutations = { 9 | [Types.INIT_PROFILE](state, handler) { 10 | state.handler = handler 11 | }, 12 | [Types.SET_PROFILE_DATA](state, data) { 13 | state.data = data 14 | }, 15 | [Types.RESET_PROFILE](state) { 16 | Object.assign(state, { ...initState }) 17 | }, 18 | } 19 | 20 | export default { 21 | state: { ...initState }, 22 | mutations, 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/store/modules/task.js: -------------------------------------------------------------------------------- 1 | import { append, drop, prepend, update } from 'ramda' 2 | 3 | import * as Types from '@/store/mutation-types' 4 | 5 | const initState = { 6 | taskType: { 7 | upload: '上传', 8 | download: '下载', 9 | }, 10 | status: {}, 11 | job: null, 12 | list: [], 13 | taskList: [], 14 | showModal: false, 15 | } 16 | 17 | const mutations = { 18 | [Types.INIT_JOB](state, job) { 19 | state.job = job 20 | state.status = job.status 21 | }, 22 | [Types.SET_JOB_LIST](state, list) { 23 | state.list = list 24 | }, 25 | [Types.UPDATE_JOB_ITEM](state, { item }) { 26 | const existedItemIndex = state.list.findIndex(_item => _item.id === item.id) 27 | if (~existedItemIndex) { 28 | state.list = update(existedItemIndex, item, state.list) 29 | } else { 30 | state.list = prepend(item, state.list) 31 | } 32 | }, 33 | [Types.SHOW_TASK_MODAL](state) { 34 | state.showModal = true 35 | }, 36 | [Types.HIDE_TASK_MODAL](state) { 37 | state.taskList = state.taskList.filter(item => { 38 | return item.status === '1' 39 | }) 40 | state.showModal = false 41 | }, 42 | [Types.RESET_TASK](state) { 43 | Object.assign(state, { ...initState }) 44 | }, 45 | } 46 | 47 | export default { 48 | state: { ...initState }, 49 | mutations, 50 | } 51 | -------------------------------------------------------------------------------- /src/renderer/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | // mutations 2 | 3 | export const CLEAR_USER_INFO = 'CLEAR_USER_INFO' // 清除用户信息 4 | 5 | 6 | export const RESET_AUTH = 'RESET_AUTH' 7 | export const RESET_LIST = 'RESET_LIST' 8 | export const RESET_MODAL = 'RESET_MODAL' 9 | export const RESET_TASK = 'RESET_TASK' 10 | export const RESET_PROFILE = 'RESET_PROFILE' 11 | 12 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS' // 登录成功 13 | export const LOGOUT = 'LOGOUT' // 退出登录 14 | export const SET_USER_INFO = 'SET_USER_INFO' // 设置用户信息 15 | export const SET_LOADING_LIST = 'SET_LOADING_LIST' // 目录正在加载中 16 | export const SET_CURRENT_LIST = 'SET_CURRENT_LIST' // 设置当前目录列表 17 | export const SET_SORT_INFO = 'SET_SORT_INFO' // 排序key 18 | export const CHANGE_DIR = 'CHANGE_DIR' // 改变当前目录 19 | export const SHORTCUT_SELECT_ALL = 'SHORTCUT_SELECT_ALL' // 选择所有文件 20 | export const SET_SELECT_LIST = 'SET_SELECT_LIST' // 选择所有文件 21 | 22 | export const OPEN_CREATE_FOLDER_MODAL = 'OPEN_CREATE_FOLDER_MODAL' // 打开创建文件夹 modal 23 | export const CLOSE_CREATE_FOLDER_MODAL = 'CLOSE_CREATE_FOLDER_MODAL' // 关闭创建文件夹 modal 24 | 25 | export const OPEN_RENAME_FILE_MODAL = 'OPEN_RENAME_FILE_MODAL' // 打开重命名 modal 26 | export const CLOSE_RENAME_FILE_MODAL = 'CLOSE_RENAME_FILE_MODAL' // 关闭重命名 modal 27 | 28 | export const RENAME_FILE_SET_OLD_PATH = 'RENAME_FILE_SET_OLD_PATH' // 设置 oldpath 29 | export const RENAME_FILE_CLEAR_OLD_PATH = 'RENAME_FILE_CLEAR_OLD_PATH' // 清除 oldpath 30 | 31 | export const OPEN_DOMAIN_SETTING_MODAL = 'OPEN_DOMAIN_SETTING_MODAL' // 打开域名设置框 32 | export const CLOSE_DOMAIN_SETTING_MODAL = 'CLOSE_DOMAIN_SETTING_MODAL' // 关闭域名设置框 33 | 34 | export const OPEN_UPLOAD_HANDLE_MODAL = 'OPEN_UPLOAD_HANDLE_MODAL' // 打开上传操作框 35 | export const CLOSE_UPLOAD_HANDLE_MODAL = 'CLOSE_UPLOAD_HANDLE_MODAL' // 关闭上传操作框 36 | 37 | export const OPEN_FORMAT_URL_MODAL = 'OPEN_FORMAT_URL_MODAL' // 打开链接格式化框 38 | export const CLOSE_FORMAT_URL_MODAL = 'CLOSE_FORMAT_URL_MODAL' // 关闭链接格式化框 39 | export const SET_FORMAT_TYPE = 'SET_FORMAT_TYPE' // 设置链接的格式 40 | export const SET_COPY_URL = 'SET_COPY_URL' // 设置需要复制的URL 41 | 42 | export const SHOW_TASK_MODAL = 'SHOW_TASK_MODAL' // 显示任务框 43 | export const HIDE_TASK_MODAL = 'HIDE_TASK_MODAL' // 隐藏任务框 44 | 45 | export const INIT_JOB = 'INIT_JOB' // 初始化任务实例 46 | export const SET_JOB_LIST = 'SET_JOB_LIST' // 设置任务列表 47 | export const UPDATE_JOB_ITEM = 'UPDATE_JOB_ITEM' // 更新一个任务 48 | export const DELETE_JOB = 'DELETE_JOB' // 删除任务 49 | export const SYNC_JOB_LIST = 'SYNC_JOB_LIST' // 同步下载任务列表 50 | 51 | // actions 52 | export const VERIFICATION_ACCOUNT = 'VERIFICATION_ACCOUNT' // 验证账号 53 | export const GET_LIST_DIR_INFO = 'GET_LIST_DIR_INFO' // 获取列表信息 54 | export const UPLOAD_FILES = 'UPLOAD_FILES' // 上传文件 55 | export const CREATE_FOLDER = 'CREATE_FOLDER' // 创建文件夹 56 | export const REFRESH_LIST = 'REFRESH_LIST' // 刷新当前目录 57 | export const DELETE_FILE = 'DELETE_FILE' // 删除文件 58 | export const RENAME_FILE = 'RENAME_FILE' // 重命名文件 59 | export const DOWNLOAD_FILES = 'DOWNLOAD_FILES' // 下载文件 60 | export const GET_FILE_DETAIL_INFO = 'GET_FILE_DETAIL_INFO' // 获取文件详情信息 61 | export const SET_FILE_DETAIL_INFO = 'SET_FILE_DETAIL_INFO' // 设置文件详情信息 62 | 63 | export const INIT_PROFILE = 'INIT_PROFILE' // 初始化 profile 64 | export const SET_PROFILE_STORE = 'SET_PROFILE_STORE' // 设置 profile 存储数据 65 | export const SET_PROFILE_DATA = 'SET_PROFILE_DATA' // 同步 profile 数据 66 | export const SYNC_PROFILE_DATA = 'SYNC_PROFILE_DATA' // 设置 profile 数据 67 | 68 | export const SET_USAGE = 'SET_USAGE' // 设置空间使用量 69 | export const GET_USAGE = 'GET_USAGE' // 获取空间使用量 70 | -------------------------------------------------------------------------------- /src/renderer/styles/bulma.scss: -------------------------------------------------------------------------------- 1 | @import '~bulma/sass/utilities/initial-variables'; 2 | @import '~@/styles/custom.scss'; 3 | 4 | @font-face { 5 | font-family: SourceHanSansCN-Light; 6 | src: url('./fonts/SourceHanSansCN-Light.ttf'); 7 | font-weight: normal; 8 | } 9 | 10 | $family-sans-serif: 'Arial', 'SourceHanSansCN-Light', 'Microsoft YaHei', BlinkMacSystemFont, -apple-system, 'Segoe UI', 11 | 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Helvetica', sans-serif; 12 | 13 | $family-primary: $family-sans-serif; 14 | 15 | $primary: $upyunPrimaryColor; 16 | 17 | $menu-item-hover-background-color: #edf4fc; 18 | $menu-item-color: #000; 19 | $menu-item-active-background-color: #cce8ff; 20 | $menu-item-active-color: #000; 21 | 22 | $dropdown-item-hover-background-color: #edf4fc; 23 | @import '~bulma/bulma.sass'; 24 | 25 | .menu { 26 | color: findColorInvert(#000); 27 | } 28 | 29 | .message a.message-link:not(.button):not(.tag) { 30 | color: #3273dc; 31 | &:hover { 32 | color: #363636; 33 | } 34 | } 35 | 36 | .message { 37 | user-select: auto; 38 | } 39 | 40 | .content { 41 | user-select: auto; 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/styles/common.scss: -------------------------------------------------------------------------------- 1 | html { 2 | overflow-y: hidden; 3 | height: 100%; 4 | font-size: 14px; 5 | } 6 | 7 | body { 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | user-select: none; 12 | } 13 | 14 | .modal-footer .button { 15 | padding-left: 20px; 16 | padding-right: 20px; 17 | } 18 | 19 | .svg-icon { 20 | width: 1em; 21 | height: 1em; 22 | vertical-align: -0.15em; 23 | fill: currentColor; 24 | overflow: hidden; 25 | } 26 | 27 | .login-form { 28 | .label { 29 | margin-bottom: 0; 30 | } 31 | } 32 | 33 | .drag-over { 34 | background-color: #f5fbff; 35 | } 36 | 37 | /* 38 | * 覆盖 bulma 39 | */ 40 | 41 | .breadcrumb li.is-active a { 42 | cursor: pointer; 43 | pointer-events: inherit; 44 | } 45 | 46 | .breadcrumb a { 47 | padding: 0.5em 0.5em; 48 | display: block; 49 | overflow: hidden; 50 | text-overflow: ellipsis; 51 | max-width: 150px; 52 | } 53 | 54 | .breadcrumb > ul { 55 | padding-left: 15px; 56 | } 57 | 58 | /* 59 | * 覆盖 balloon-css 60 | */ 61 | 62 | [data-balloon]:after { 63 | font-family: inherit !important; 64 | } 65 | 66 | /* 67 | * 进度条提示 68 | */ 69 | 70 | .file-progress { 71 | position: absolute; 72 | width: 360px; 73 | right: 12px; 74 | font-size: 12px; 75 | bottom: 0; 76 | z-index: 10; 77 | box-shadow: 0 2px 8px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); 78 | .card-block { 79 | cursor: pointer; 80 | .card-block-title { 81 | overflow: hidden; 82 | white-space: nowrap; 83 | text-overflow: ellipsis; 84 | flex-grow: 1; 85 | } 86 | .card-block-icon { 87 | padding-right: 8px; 88 | padding-left: 8px; 89 | } 90 | } 91 | .progress-bar { 92 | width: 20px; 93 | height: 20px; 94 | } 95 | } 96 | 97 | .card-block { 98 | display: flex; 99 | align-items: center; 100 | height: 50px; 101 | padding: 0.5em 0.7em; 102 | border-bottom: 1px solid rgba(0, 0, 0, 0.12); 103 | } 104 | 105 | .account-history { 106 | .account-history-list { 107 | max-height: 320px; 108 | overflow: auto; 109 | } 110 | .record { 111 | cursor: default; 112 | &:hover .record-delete { 113 | display: block; 114 | } 115 | .record-delete { 116 | &:hover { 117 | text-decoration: underline; 118 | } 119 | color: #3273dc; 120 | display: none; 121 | cursor: pointer; 122 | float: right; 123 | font-size: 12px; 124 | line-height: 18px; 125 | } 126 | } 127 | } 128 | 129 | 130 | .dropdown-background { 131 | z-index: 1; 132 | position: fixed; 133 | top: 0; 134 | bottom: 0; 135 | left: 0; 136 | right: 0; 137 | } 138 | -------------------------------------------------------------------------------- /src/renderer/styles/custom.scss: -------------------------------------------------------------------------------- 1 | $upyunPrimaryColor: #00A0FF; 2 | // $upyunPrimaryColor: #15bdf9; 3 | $upyunDisabledColor: #7fd0ff; -------------------------------------------------------------------------------- /src/renderer/styles/icons.scss: -------------------------------------------------------------------------------- 1 | .res-icon { 2 | vertical-align: -7px; 3 | font-size: 24px; 4 | display: inline-block; 5 | line-height: 1; 6 | width: 24px; 7 | height: 24px; 8 | text-align: center; 9 | margin-right: 5px; 10 | &:before { 11 | content: url("assets/icons/file.svg"); 12 | } 13 | &.icon-folder { 14 | &:before { 15 | content: url("assets/icons/folder.svg"); 16 | } 17 | } 18 | &.icon-image { 19 | &:before { 20 | content: url("assets/icons/image.svg"); 21 | } 22 | } 23 | &.icon-html { 24 | &:before { 25 | content: url("assets/icons/html.svg"); 26 | } 27 | } 28 | &.icon-javascript { 29 | &:before { 30 | content: url("assets/icons/javascript.svg"); 31 | } 32 | } 33 | &.icon-style { 34 | &:before { 35 | content: url("assets/icons/css.svg"); 36 | } 37 | } 38 | &.icon-json { 39 | &:before { 40 | content: url("assets/icons/json.svg"); 41 | } 42 | } 43 | &.icon-markdown { 44 | &:before { 45 | content: url("assets/icons/markdown.svg"); 46 | } 47 | } 48 | &.icon-music { 49 | &:before { 50 | content: url("assets/icons/music.svg"); 51 | } 52 | } 53 | &.icon-movie { 54 | &:before { 55 | content: url("assets/icons/movie.svg"); 56 | } 57 | } 58 | &.icon-zip { 59 | &:before { 60 | content: url("assets/icons/zip.svg"); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/renderer/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "~@/styles/bulma.scss"; 2 | @import "~@/styles/common.scss"; 3 | @import "~@/styles/scroll.scss"; 4 | @import "~@/styles/icons.scss"; 5 | @import "~@/styles/layout.scss"; 6 | @import "~@/styles/listView.scss"; 7 | @import "~@/styles/modal.scss"; 8 | @import "~@/styles/task.scss"; -------------------------------------------------------------------------------- /src/renderer/styles/layout.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | width: 100%; 6 | overflow: hidden; 7 | background: #fcfcfc; 8 | .layout-inner { 9 | display: flex; 10 | flex: 1; 11 | } 12 | .layout-body { 13 | display: flex; 14 | flex: 1; 15 | } 16 | } 17 | 18 | .profile { 19 | cursor: pointer; 20 | position: relative; 21 | color: rgba(0, 0, 0, 0.8); 22 | height: 65px; 23 | padding-left: 20px; 24 | &:hover { 25 | background: #edf4fc; 26 | } 27 | 28 | .dropdown-content { 29 | cursor: default; 30 | position: absolute; 31 | font-size: 18px; 32 | top: 39px; 33 | z-index: 1; 34 | width: 240px; 35 | padding: 16px; 36 | .dropdown-item { 37 | cursor: pointer; 38 | } 39 | } 40 | } 41 | 42 | .profile-name { 43 | display: flex; 44 | align-items: center; 45 | line-height: 39px; 46 | color: rgba(0, 0, 0, 0.8); 47 | font-size: 16px; 48 | } 49 | 50 | .profile-name-content { 51 | white-space: nowrap; 52 | overflow: hidden; 53 | text-overflow: ellipsis; 54 | } 55 | 56 | .profile-usage { 57 | color: rgba(0, 0, 0, 0.4); 58 | font-size: 12px; 59 | } 60 | 61 | .menu { 62 | display: flex; 63 | flex-direction: column; 64 | color: rgba(0, 0, 0, 0.8); 65 | width: 160px; 66 | background: #ffffff; 67 | border-right: 1px solid rgba(0, 0, 0, 0.12); 68 | .icon-angle-down.svg-icon { 69 | color: rgba(0, 0, 0, 0.54); 70 | width: 10px; 71 | height: 10px; 72 | margin: 4px 8px 0 4px; 73 | } 74 | .menu-list { 75 | flex: 1; 76 | .svg-icon { 77 | margin-right: 5px; 78 | width: 1.3em; 79 | height: 1.3em; 80 | vertical-align: -0.25em; 81 | } 82 | a { 83 | padding: 8px 20px; 84 | padding-right: 8px; 85 | } 86 | } 87 | } 88 | 89 | .dropdown-content-profile-name { 90 | padding: 0.375rem 1rem; 91 | word-break: break-all; 92 | line-height: 1; 93 | } 94 | // page-header 95 | .page-header { 96 | align-items: center; 97 | display: flex; 98 | padding: 0 12px; 99 | background: #fff; 100 | } 101 | 102 | .page-title { 103 | font-size: 1.5em; 104 | line-height: 1; 105 | margin: 15px 0; 106 | } 107 | 108 | .logo { 109 | line-height: 50px; 110 | display: inline-block; 111 | } 112 | 113 | .logo img { 114 | height: 25px; 115 | vertical-align: middle; 116 | } 117 | 118 | // page-nav 119 | .bar { 120 | align-items: center; 121 | display: flex; 122 | z-index: 1; 123 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 124 | background: #fafafa; 125 | .bar-right { 126 | display: flex; 127 | font-size: 24px; 128 | margin-left: 0.75em; 129 | margin-right: 0.75em; 130 | } 131 | } 132 | 133 | .button-zone { 134 | width: 160px; 135 | display: flex; 136 | justify-content: center; 137 | .dropdown-trigger .button { 138 | width: 100px; 139 | position: relative; 140 | } 141 | } 142 | 143 | .nav { 144 | align-items: center; 145 | background: #fafafa; 146 | flex: 1; 147 | padding-right: 16px; 148 | font-size: 1.5em; 149 | overflow: hidden; 150 | } 151 | 152 | .task-tag.tag { 153 | font-size: 12px; 154 | height: 16px; 155 | line-height: 16px; 156 | padding: 0 8px; 157 | margin-left: 8px; 158 | transform: translateY(-1px); 159 | } 160 | 161 | .app-info { 162 | display: flex; 163 | align-items: center; 164 | justify-content: center; 165 | color: rgba(0, 0, 0, 0.12); 166 | padding: 8px 8px; 167 | font-size: 12px; 168 | cursor: pointer; 169 | &:hover { 170 | background-color: #edf4fc; 171 | } 172 | .app-upgrade-tip { 173 | width: 5px; 174 | height: 5px; 175 | border-radius: 50%; 176 | background-color: #ff8a80; 177 | margin-bottom: 10px; 178 | margin-left: 2px; 179 | } 180 | } 181 | 182 | .about-modal { 183 | .modal-content { 184 | width: 480px; 185 | } 186 | .brand-block { 187 | display: flex; 188 | align-items: center; 189 | flex-direction: column; 190 | .brand-img img { 191 | width: 48px; 192 | height: 48px; 193 | } 194 | .brand-name { 195 | font-size: 20px; 196 | color: rgba(0, 0, 0, 0.84); 197 | } 198 | .brand-version { 199 | display: flex; 200 | align-items: center; 201 | font-size: 16px; 202 | margin-bottom: 16px; 203 | .button { 204 | padding: 0 4px; 205 | margin-left: 4px; 206 | font-size: 12px; 207 | height: 18px; 208 | } 209 | } 210 | padding-bottom: 15px; 211 | } 212 | .change-logs { 213 | display: flex; 214 | flex-direction: column; 215 | padding-top: 15px; 216 | border-top: 1px solid rgba(0, 0, 0, 0.08); 217 | } 218 | .change-logs-content { 219 | h3 { 220 | font-size: 16px; 221 | } 222 | ul { 223 | font-size: 12px; 224 | } 225 | .version-message:not(:last-child) { 226 | margin-bottom: 16px; 227 | } 228 | } 229 | .message-body { 230 | max-height: 224px; 231 | overflow-y: scroll; 232 | } 233 | .upgrade-download-tip { 234 | font-size: 12px; 235 | margin-bottom: 8px; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/renderer/styles/listView.scss: -------------------------------------------------------------------------------- 1 | @import '~@/styles/custom.scss'; 2 | 3 | @media (max-width: 1200px) { 4 | .hide-2000 { 5 | display: none !important; 6 | } 7 | } 8 | 9 | .list-view { 10 | flex: 1; 11 | display: flex; 12 | .list-view-main { 13 | position: relative; 14 | display: flex; 15 | flex: 1; 16 | flex-direction: column; 17 | background: #fff; 18 | outline: none; 19 | } 20 | .list-view-detail { 21 | width: 350px; 22 | background: #fff; 23 | position: relative; 24 | user-select: auto; 25 | box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.14); 26 | border-left: 1px solid rgba(0, 0, 0, 0.12); 27 | .list-view-detail-header { 28 | padding-left: 10px; 29 | padding-right: 10px; 30 | .res-icon { 31 | margin-right: 0; 32 | } 33 | h4 { 34 | width: 275px; 35 | font-size: 16px; 36 | line-height: 39px; 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | white-space: nowrap; 40 | } 41 | } 42 | .separate-line-wrap { 43 | margin-right: -10px; 44 | margin-left: -10px; 45 | } 46 | .list-view-detail-close { 47 | cursor: pointer; 48 | opacity: 0.5; 49 | position: absolute; 50 | height: 14px; 51 | right: 14px; 52 | top: 8px; 53 | width: 14px; 54 | } 55 | .list-view-detail-content { 56 | padding: 10px; 57 | height: calc(100% - 40px); 58 | overflow: auto; 59 | overflow-x: hidden; 60 | 61 | .image-preview { 62 | height: 216px; 63 | display: flex; 64 | align-items: center; 65 | justify-content: center; 66 | img { 67 | max-height: 200px; 68 | max-width: 320px; 69 | min-width: 80px; 70 | min-height: 80px; 71 | } 72 | } 73 | 74 | .list-view-detail-content-item { 75 | margin-bottom: 5px; 76 | .list-view-detail-content-item-label { 77 | color: rgba(0, 0, 0, 0.6); 78 | font-size: 12px; 79 | } 80 | .list-view-detail-content-item-value { 81 | font-size: 14px; 82 | color: rgba(0, 0, 0, 1); 83 | word-break: break-all; 84 | &.head-request-info { 85 | color: rgba(0, 0, 0, 0.8); 86 | padding-left: 5px; 87 | font-size: 12px; 88 | line-height: 20px; 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | .separate-line { 97 | border-top: 1px solid rgba(0, 0, 0, 0.12); 98 | } 99 | 100 | .list-operation { 101 | height: 40px; 102 | display: flex; 103 | border-bottom: 1px solid rgba(0, 0, 0, 0.12); 104 | align-items: center; 105 | // padding-left: 4px; 106 | .list-operation-item { 107 | color: rgba(0, 0, 0, 0.88); 108 | min-width: 22px; 109 | font-size: 14px; 110 | line-height: 24px; 111 | padding: 8px; 112 | margin-right: 8px; 113 | &:hover:not(.disabled) { 114 | cursor: pointer; 115 | background-color: #edf4fc; 116 | } 117 | &.list-operation-item-hover { 118 | cursor: pointer; 119 | background-color: #edf4fc; 120 | } 121 | &.disabled { 122 | color: rgba(0, 0, 0, 0.2); 123 | .svg-icon { 124 | opacity: 0.2; 125 | } 126 | } 127 | .svg-icon { 128 | margin-right: 3px; 129 | color: $upyunPrimaryColor; 130 | } 131 | } 132 | } 133 | 134 | .list { 135 | display: flex; 136 | flex: 1; 137 | overflow-y: scroll; 138 | overflow-x: hidden; 139 | } 140 | 141 | .empty-list { 142 | display: flex; 143 | align-items: center; 144 | flex: 1; 145 | .empty-list-content { 146 | text-align: center; 147 | flex: 1; 148 | font-size: 22px; 149 | } 150 | } 151 | 152 | .files-list { 153 | cursor: default; 154 | 155 | display: table; 156 | table-layout: fixed; 157 | padding-top: 25px; 158 | height: 100%; 159 | width: 100%; 160 | 161 | .files-list-header { 162 | background: #FAFAFA; 163 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08); 164 | position: absolute; 165 | color: #4c607a; 166 | font-size: 14px; 167 | display: flex; 168 | top: 40px; 169 | width: calc(100% - 16px); 170 | .column-file-name { 171 | flex: 1; 172 | } 173 | .file-info-item,.file-info-header { 174 | padding-top: 0; 175 | padding-bottom: 0; 176 | line-height: 25px; 177 | padding-left: 6px; 178 | &:first-child { 179 | padding-left: 12px; 180 | } 181 | &:not(:last-child) { 182 | border-right: 1px solid rgba(0, 0, 0, 0.08); 183 | } 184 | } 185 | 186 | .file-info-header { 187 | &:hover { 188 | color: rgba(0, 0, 0, 1); 189 | } 190 | .svg-icon { 191 | display: none; 192 | &.is-active { 193 | display: inline; 194 | } 195 | } 196 | } 197 | } 198 | 199 | .files-list-body { 200 | color: rgba(0, 0, 0, 1); 201 | display: table-row-group; 202 | } 203 | 204 | .files-list-column { 205 | display: table-column-group; 206 | } 207 | 208 | .table-column { 209 | display: table-column; 210 | } 211 | 212 | .column-file-name { 213 | width: 100%; 214 | } 215 | .column-last-modified { 216 | width: 170px; 217 | } 218 | .column-file-type { 219 | width: 150px; 220 | } 221 | .column-file-size { 222 | width: 120px; 223 | } 224 | 225 | .files-list-item { 226 | display: table-row; 227 | outline: none; 228 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04); 229 | &:hover { 230 | background: #edf4fc; 231 | } 232 | &.item-selected { 233 | background: #cce8ff; 234 | } 235 | } 236 | 237 | .file-info-item { 238 | padding: 5px 6px 5px 10px; 239 | line-height: 24px; 240 | display: table-cell; 241 | &:first-child { 242 | padding-left: 12px; 243 | } 244 | } 245 | 246 | .mime, 247 | .last-modified, 248 | .size { 249 | color: rgba(0, 0, 0, 0.6); 250 | white-space: nowrap; 251 | overflow: hidden; 252 | text-overflow: ellipsis; 253 | } 254 | 255 | .name { 256 | overflow: hidden; 257 | text-overflow: ellipsis; 258 | white-space: nowrap; 259 | } 260 | 261 | .size { 262 | padding-right: 30px; 263 | text-align: right; 264 | } 265 | } 266 | 267 | .item-hover { 268 | background: #e5f3ff; 269 | } 270 | 271 | .empty-list-table { 272 | width: 100%; 273 | .empty-list-row { 274 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08); 275 | display: flex; 276 | min-height: 56px; 277 | } 278 | .empty-content { 279 | flex: 1; 280 | display: flex; 281 | flex-direction: column; 282 | justify-content: center; 283 | text-align: center; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/renderer/styles/modal.scss: -------------------------------------------------------------------------------- 1 | .basic-modal.modal { 2 | .modal-content { 3 | padding: 24px 24px; 4 | background-color: white; 5 | box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); 6 | color: rgba(0, 0, 0, 0.8); 7 | max-width: 100%; 8 | position: relative; 9 | } 10 | .modal-header { 11 | position: relative; 12 | line-height: 24px; 13 | margin-bottom: 24px; 14 | .modal-title { 15 | font-size: 20px; 16 | vertical-align: top; 17 | } 18 | .modal-close-button { 19 | outline: none; 20 | height: 14px; 21 | width: 14px; 22 | font-size: 14px; 23 | opacity: 0.7; 24 | position: absolute; 25 | right: 5px; 26 | top: 5px; 27 | cursor: pointer; 28 | .svg-icon { 29 | vertical-align: top; 30 | } 31 | } 32 | } 33 | .modal-footer { 34 | display: flex; 35 | justify-content: flex-end; 36 | margin-top: 24px; 37 | } 38 | } 39 | 40 | .modal-sm.modal { 41 | .modal-content { 42 | width: 320px; 43 | } 44 | } 45 | 46 | .modal-md.modal { 47 | .modal-content { 48 | width: 480px; 49 | } 50 | } 51 | 52 | .fade-enter-active, 53 | .fade-leave-active { 54 | transition: opacity 0.2s; 55 | } 56 | .fade-enter, 57 | .fade-leave-active { 58 | opacity: 0; 59 | } 60 | 61 | .modal-full .modal-content { 62 | height: 100%; 63 | max-height: 100vh; 64 | width: 100%; 65 | max-width: 100vw; 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/styles/scroll.scss: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | height: 16px; 3 | overflow: visible; 4 | width: 16px 5 | } 6 | 7 | ::-webkit-scrollbar-track { 8 | background-clip: padding-box; 9 | border: solid transparent; 10 | border-width: 0 0 0 4px 11 | } 12 | 13 | ::-webkit-scrollbar-track:enabled { 14 | background-color: rgba(0, 0, 0, .05); 15 | } 16 | 17 | ::-webkit-scrollbar-track:hover { 18 | background-color: rgba(0, 0, 0, .05); 19 | box-shadow: inset 1px 0 0 rgba(0, 0, 0, .1) 20 | } 21 | 22 | ::-webkit-scrollbar-track:active { 23 | background-color: rgba(0, 0, 0, .05); 24 | box-shadow: inset 1px 0 0 rgba(0, 0, 0, .14), inset -1px 0 0 rgba(0, 0, 0, .07) 25 | } 26 | 27 | ::-webkit-scrollbar-thumb { 28 | background-color: rgba(0, 0, 0, .2); 29 | background-clip: padding-box; 30 | border: solid transparent; 31 | border-width: 1px 1px 1px 6px; 32 | min-height: 28px; 33 | padding: 100px 0 0; 34 | box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1), inset 0 -1px 0 rgba(0, 0, 0, .07) 35 | } 36 | 37 | ::-webkit-scrollbar-thumb:hover { 38 | background-color: rgba(0, 0, 0, .4); 39 | box-shadow: inset 1px 1px 1px rgba(0, 0, 0, .25) 40 | } 41 | 42 | ::-webkit-scrollbar-thumb:active { 43 | background-color: rgba(0, 0, 0, 0.5); 44 | box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.35) 45 | } -------------------------------------------------------------------------------- /src/renderer/styles/task.scss: -------------------------------------------------------------------------------- 1 | .task-container { 2 | width: 100%; 3 | background: #ffffff; 4 | position: relative; 5 | display: flex; 6 | flex-direction: column; 7 | .tabs { 8 | border-bottom: 1px solid rgba(0, 0, 0, 0.12); 9 | } 10 | .tabs a { 11 | height: 40px; 12 | } 13 | .tabs .handle a { 14 | &:hover { 15 | // 和正常时一样 16 | border-bottom-color: #dbdbdb; 17 | } 18 | } 19 | .tabs:not(:last-child) { 20 | margin-bottom: 0; 21 | } 22 | .files-list { 23 | .column-file-name { 24 | width: 100%; 25 | } 26 | .column-file-size { 27 | width: 150px; 28 | } 29 | .column-file-status { 30 | width: 250px; 31 | } 32 | .column-file-handle { 33 | width: 150px; 34 | } 35 | } 36 | .files-list-item { 37 | height: 56px; 38 | .file-info-item, .file-info-header { 39 | vertical-align: middle; 40 | } 41 | } 42 | .name.file-info-item { 43 | a { 44 | color: rgb(0, 0, 0); 45 | &:hover { 46 | color: rgba(0, 0, 0, 0.6); 47 | } 48 | } 49 | } 50 | .size.file-info-item { 51 | font-size: 12px; 52 | text-align: left; 53 | padding-right: 15px; 54 | } 55 | .status.file-info-item { 56 | font-size: 12px; 57 | text-align: left; 58 | padding-right: 15px; 59 | color: rgba(0, 0, 0, 0.8); 60 | line-height: 18px; 61 | .task-state { 62 | display: flex; 63 | } 64 | .task-state-time { 65 | margin-left: 16px; 66 | color: rgba(0, 0, 0, 0.4); 67 | } 68 | } 69 | .handle.file-info-item { 70 | line-height: 18px; 71 | } 72 | } 73 | 74 | .file-info-item { 75 | .img-mini-preview { 76 | width: 24px; 77 | height: 24px; 78 | object-fit: cover; 79 | vertical-align: -7px; 80 | } 81 | } 82 | 83 | -------------------------------------------------------------------------------- /src/renderer/views/download/Download.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 163 | -------------------------------------------------------------------------------- /src/renderer/views/layout/LayoutBody.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/renderer/views/layout/LayoutMenu.vue: -------------------------------------------------------------------------------- 1 | 110 | 111 | 201 | -------------------------------------------------------------------------------- /src/renderer/views/layout/LayoutNav.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 118 | -------------------------------------------------------------------------------- /src/renderer/views/layout/Main.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 56 | -------------------------------------------------------------------------------- /src/renderer/views/list/List.vue: -------------------------------------------------------------------------------- 1 | 186 | 187 | 575 | -------------------------------------------------------------------------------- /src/renderer/views/login/Login.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 127 | -------------------------------------------------------------------------------- /src/renderer/views/modal/CreateFolder.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 80 | -------------------------------------------------------------------------------- /src/renderer/views/modal/DomainSetting.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 111 | -------------------------------------------------------------------------------- /src/renderer/views/modal/FileProgress.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 74 | -------------------------------------------------------------------------------- /src/renderer/views/modal/FormatUrl.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 128 | -------------------------------------------------------------------------------- /src/renderer/views/modal/RenameFile.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 89 | -------------------------------------------------------------------------------- /src/renderer/views/modal/UploadHandle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/renderer/views/upload/Upload.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 174 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/static/.gitkeep -------------------------------------------------------------------------------- /static/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/static/screenshot1.png -------------------------------------------------------------------------------- /static/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/static/screenshot2.png -------------------------------------------------------------------------------- /static/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntduan/updrive/8000819a68671e52e1ad802e0b50a686e5a3817e/static/screenshot3.png --------------------------------------------------------------------------------