1. 前端UI框架更改: 弃用ElementUI,改用iView

2. 更新大量陈旧组件,使用新版vue-cli的build文件
3. 加入LazyLoad机制,提升首次打开页面的速度
4. 文件结构改动,更合理更工程化。
This commit is contained in:
zemal 2017-06-24 22:04:07 +08:00
parent a3049ce285
commit 8ff520fdc0
34 changed files with 5960 additions and 931 deletions

View File

@ -1,5 +1,14 @@
{
"presets": ["es2015", "stage-2"],
"presets": [
["env", { "modules": false }],
"stage-2"
],
"plugins": ["transform-runtime"],
"env": {
"test": {
"presets": ["env", "stage-2"],
"plugins": [ "istanbul" ]
}
},
"comments": false
}

8
oj/.postcssrc.js Normal file
View File

@ -0,0 +1,8 @@
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
"plugins": {
// to edit target browsers: use "browserlist" field in package.json
"autoprefixer": {}
}
}

View File

@ -1,31 +1,22 @@
// https://github.com/shelljs/shelljs
require('./check-versions')()
require('shelljs/global')
env.NODE_ENV = 'production'
var path = require('path')
var config = require('../config')
process.env.NODE_ENV = 'production'
var ora = require('ora')
var rm = require('rimraf')
var path = require('path')
var chalk = require('chalk')
var webpack = require('webpack')
var config = require('../config')
var webpackConfig = require('./webpack.prod.conf')
console.log(
' Tip:\n' +
' Built files are meant to be served over an HTTP server.\n' +
' Opening index.html over file:// won\'t work.\n'
)
var spinner = ora('building for production...')
spinner.start()
var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory)
rm('-rf', assetsPath)
mkdir('-p', assetsPath)
cp('-R', 'static/*', assetsPath)
webpack(webpackConfig, function (err, stats) {
rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
if (err) throw err
webpack(webpackConfig, function (err, stats) {
spinner.stop()
require('fs').writeFileSync('stat.json', JSON.stringify(stats.toJson()))
if (err) throw err
process.stdout.write(stats.toString({
colors: true,
@ -33,5 +24,12 @@ webpack(webpackConfig, function (err, stats) {
children: false,
chunks: false,
chunkModules: false
}) + '\n')
}) + '\n\n')
console.log(chalk.cyan(' Build complete.\n'))
console.log(chalk.yellow(
' Tip: built files are meant to be served over an HTTP server.\n' +
' Opening index.html over file:// won\'t work.\n'
))
})
})

View File

@ -1,9 +1,9 @@
var semver = require('semver')
var chalk = require('chalk')
var semver = require('semver')
var packageConfig = require('../package.json')
var exec = function (cmd) {
return require('child_process')
.execSync(cmd).toString().trim()
var shell = require('shelljs')
function exec (cmd) {
return require('child_process').execSync(cmd).toString().trim()
}
var versionRequirements = [
@ -12,12 +12,15 @@ var versionRequirements = [
currentVersion: semver.clean(process.version),
versionRequirement: packageConfig.engines.node
},
{
]
if (shell.which('npm')) {
versionRequirements.push({
name: 'npm',
currentVersion: exec('npm --version'),
versionRequirement: packageConfig.engines.npm
}
]
})
}
module.exports = function () {
var warnings = []

View File

@ -1,17 +1,21 @@
require('./check-versions')()
var config = require('../config')
if (!process.env.NODE_ENV) process.env.NODE_ENV = config.dev.env
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV)
}
var opn = require('opn')
var path = require('path')
var express = require('express')
var webpack = require('webpack')
var opn = require('opn')
var proxyMiddleware = require('http-proxy-middleware')
var webpackConfig = process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf')
: require('./webpack.dev.conf')
var webpackConfig = require('./webpack.dev.conf')
// default port where dev server listens for incoming traffic
var port = process.env.PORT || config.dev.port
// automatically open browser, if not set will be false
var autoOpenBrowser = !!config.dev.autoOpenBrowser
// Define HTTP proxies to your custom API backend
// https://github.com/chimurai/http-proxy-middleware
var proxyTable = config.dev.proxyTable
@ -21,13 +25,12 @@ var compiler = webpack(webpackConfig)
var devMiddleware = require('webpack-dev-middleware')(compiler, {
publicPath: webpackConfig.output.publicPath,
stats: {
colors: true,
chunks: false
}
quiet: true
})
var hotMiddleware = require('webpack-hot-middleware')(compiler)
var hotMiddleware = require('webpack-hot-middleware')(compiler, {
log: () => {}
})
// force page reload when html-webpack-plugin template changes
compiler.plugin('compilation', function (compilation) {
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
@ -42,7 +45,7 @@ Object.keys(proxyTable).forEach(function (context) {
if (typeof options === 'string') {
options = { target: options }
}
app.use(proxyMiddleware(context, options))
app.use(proxyMiddleware(options.filter || context, options))
})
// handle fallback for HTML5 history API
@ -59,12 +62,28 @@ app.use(hotMiddleware)
var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
app.use(staticPath, express.static('./static'))
module.exports = app.listen(port, function (err) {
if (err) {
console.log(err)
return
}
var uri = 'http://localhost:' + port
console.log('Listening at ' + uri + '\n')
opn(uri)
var uri = 'http://localhost:' + port
var _resolve
var readyPromise = new Promise(resolve => {
_resolve = resolve
})
console.log('> Starting dev server...')
devMiddleware.waitUntilValid(() => {
console.log('> Listening at ' + uri + '\n')
// when env is testing, don't need open it
if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {
opn(uri)
}
_resolve()
})
var server = app.listen(port)
module.exports = {
ready: readyPromise,
close: () => {
server.close()
}
}

View File

@ -11,38 +11,48 @@ exports.assetsPath = function (_path) {
exports.cssLoaders = function (options) {
options = options || {}
// generate loader string to be used with extract text plugin
function generateLoaders (loaders) {
var sourceLoader = loaders.map(function (loader) {
var extraParamChar
if (/\?/.test(loader)) {
loader = loader.replace(/\?/, '-loader?')
extraParamChar = '&'
} else {
loader = loader + '-loader'
extraParamChar = '?'
var cssLoader = {
loader: 'css-loader',
options: {
minimize: process.env.NODE_ENV === 'production',
sourceMap: options.sourceMap
}
}
// generate loader string to be used with extract text plugin
function generateLoaders (loader, loaderOptions) {
var loaders = [cssLoader]
if (loader) {
loaders.push({
loader: loader + '-loader',
options: Object.assign({}, loaderOptions, {
sourceMap: options.sourceMap
})
})
}
return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '')
}).join('!')
// Extract CSS when that option is specified
// (which is the case during production build)
if (options.extract) {
return ExtractTextPlugin.extract('vue-style-loader', sourceLoader)
return ExtractTextPlugin.extract({
use: loaders,
fallback: 'vue-style-loader'
})
} else {
return ['vue-style-loader', sourceLoader].join('!')
return ['vue-style-loader'].concat(loaders)
}
}
// http://vuejs.github.io/vue-loader/configurations/extract-css.html
// https://vue-loader.vuejs.org/en/configurations/extract-css.html
return {
css: generateLoaders(['css']),
postcss: generateLoaders(['css']),
less: generateLoaders(['css', 'less']),
sass: generateLoaders(['css', 'sass?indentedSyntax']),
scss: generateLoaders(['css', 'sass']),
stylus: generateLoaders(['css', 'stylus']),
styl: generateLoaders(['css', 'stylus'])
css: generateLoaders(),
postcss: generateLoaders(),
less: generateLoaders('less'),
sass: generateLoaders('sass', { indentedSyntax: true }),
scss: generateLoaders('sass'),
stylus: generateLoaders('stylus'),
styl: generateLoaders('stylus')
}
}
@ -54,7 +64,7 @@ exports.styleLoaders = function (options) {
var loader = loaders[extension]
output.push({
test: new RegExp('\\.' + extension + '$'),
loader: loader
use: loader
})
}
return output

View File

@ -0,0 +1,12 @@
var utils = require('./utils')
var config = require('../config')
var isProduction = process.env.NODE_ENV === 'production'
module.exports = {
loaders: utils.cssLoaders({
sourceMap: isProduction
? config.build.productionSourceMap
: config.dev.cssSourceMap,
extract: isProduction
})
}

View File

@ -1,15 +1,11 @@
var path = require('path')
var config = require('../config')
var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../')
var config = require('../config')
var vueLoaderConfig = require('./vue-loader.conf')
var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
// various preprocessor loaders added to vue-loader at the end of this file
var cssSourceMapDev = (env === 'development' && config.dev.cssSourceMap)
var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap)
var useCssSourceMap = cssSourceMapDev || cssSourceMapProd
var webpack = require('webpack')
function resolve (dir) {
return path.join(__dirname, '..', dir)
}
module.exports = {
entry: {
@ -17,84 +13,55 @@ module.exports = {
},
output: {
path: config.build.assetsRoot,
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
filename: '[name].js'
filename: '[name].js',
publicPath: process.env.NODE_ENV === 'production'
? config.build.assetsPublicPath
: config.dev.assetsPublicPath
},
resolve: {
extensions: ['', '.js', '.vue'],
fallback: [path.join(__dirname, '../node_modules')],
extensions: ['.js', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue',
'src': path.resolve(__dirname, '../src'),
'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components')
'vue$': 'vue/dist/vue.esm.js',
'@': resolve('src')
}
},
resolveLoader: {
fallback: [path.join(__dirname, '../node_modules')]
},
module: {
preLoaders: [
rules: [
{
test: /\.vue$/,
loader: 'eslint',
include: projectRoot,
exclude: /node_modules/
},
{
test: /\.js$/,
loader: 'eslint',
include: projectRoot,
exclude: /node_modules/
test: /\.(js|vue)$/,
loader: 'eslint-loader',
enforce: 'pre',
include: [resolve('src'), resolve('test')],
options: {
formatter: require('eslint-friendly-formatter')
}
],
loaders: [
},
{
test: /\.vue$/,
loader: 'vue'
loader: 'vue-loader',
options: vueLoaderConfig
},
{
test: /\.js$/,
loader: 'babel',
include: projectRoot,
exclude: /node_modules/
},
{
test: /\.json$/,
loader: 'json'
loader: 'babel-loader',
include: [resolve('src'), resolve('test')]
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url',
query: {
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url',
query: {
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
}
]
},
eslint: {
formatter: require('eslint-friendly-formatter')
},
vue: {
loaders: utils.cssLoaders({ sourceMap: useCssSourceMap }),
postcss: [
require('autoprefixer')({
browsers: ['last 2 versions']
})
]
},
plugins: [
// https://github.com/webpack/webpack/issues/87
new webpack.ContextReplacementPlugin(/moment\/locale$/, /en-gb/),
new webpack.ContextReplacementPlugin(/codemirror\/mode$/, /clike\/clike|meta|python\/python|javascript\/javascript/)
]
}
}

View File

@ -1,9 +1,10 @@
var config = require('../config')
var webpack = require('webpack')
var merge = require('webpack-merge')
var utils = require('./utils')
var webpack = require('webpack')
var config = require('../config')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
// add hot-reload related code to entry chunks
Object.keys(baseWebpackConfig.entry).forEach(function (name) {
@ -12,23 +13,23 @@ Object.keys(baseWebpackConfig.entry).forEach(function (name) {
module.exports = merge(baseWebpackConfig, {
module: {
loaders: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
},
// eval-source-map is faster for development
devtool: '#eval-source-map',
// cheap-module-eval-source-map is faster for development
devtool: '#cheap-module-eval-source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
// https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
inject: true
})
}),
new FriendlyErrorsPlugin()
]
})

View File

@ -1,18 +1,22 @@
var path = require('path')
var config = require('../config')
var utils = require('./utils')
var webpack = require('webpack')
var config = require('../config')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var CopyWebpackPlugin = require('copy-webpack-plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
: config.build.env
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
var env = config.build.env
var webpackConfig = merge(baseWebpackConfig, {
module: {
loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true })
rules: utils.styleLoaders({
sourceMap: config.build.productionSourceMap,
extract: true
})
},
devtool: config.build.productionSourceMap ? '#source-map' : false,
output: {
@ -20,32 +24,33 @@ var webpackConfig = merge(baseWebpackConfig, {
filename: utils.assetsPath('js/[name].[chunkhash].js'),
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
},
vue: {
loaders: utils.cssLoaders({
sourceMap: config.build.productionSourceMap,
extract: true
})
},
plugins: [
// http://vuejs.github.io/vue-loader/workflow/production.html
// http://vuejs.github.io/vue-loader/en/workflow/production.html
new webpack.DefinePlugin({
'process.env': env
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
},
sourceMap: true
}),
// extract css into its own file
new ExtractTextPlugin({
filename: utils.assetsPath('css/[name].[contenthash].css')
}),
// Compress extracted CSS. We are using this plugin so that possible
// duplicated CSS from different components can be deduped.
new OptimizeCSSPlugin({
cssProcessorOptions: {
safe: true
}
}),
new webpack.optimize.OccurenceOrderPlugin(),
// extract css into its own file
new ExtractTextPlugin(utils.assetsPath('css/[name].[contenthash].css')),
// generate dist index.html with correct asset hash for caching.
// you can customize output by editing /index.html
// see https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: process.env.NODE_ENV === 'testing'
? 'index.html'
: config.build.index,
filename: config.build.index,
template: 'index.html',
inject: true,
minify: {
@ -77,7 +82,15 @@ var webpackConfig = merge(baseWebpackConfig, {
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor']
})
}),
// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.build.assetsSubDirectory,
ignore: ['.*']
}
])
]
})
@ -99,4 +112,9 @@ if (config.build.productionGzip) {
)
}
if (config.build.bundleAnalyzerReport) {
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
webpackConfig.plugins.push(new BundleAnalyzerPlugin())
}
module.exports = webpackConfig

View File

@ -14,11 +14,17 @@ module.exports = {
// Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin
productionGzip: false,
productionGzipExtensions: ['js', 'css']
productionGzipExtensions: ['js', 'css'],
// Run the build command with an extra argument to
// View the bundle analyzer report after build finishes:
// `npm run build --report`
// Set to `true` or `false` to always turn it on or off
bundleAnalyzerReport: process.env.npm_config_report
},
dev: {
env: require('./dev.env'),
port: 8080,
autoOpenBrowser: true,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {

View File

@ -1,10 +1,3 @@
let date = require('moment')().format('YYYYMMDD')
let commit = require('child_process').execSync('git rev-parse HEAD').toString().slice(0, 5)
let version = `"${date}-${commit}"`
console.log(`current version is ${version}`)
module.exports = {
NODE_ENV: '"production"',
VERSION: version
NODE_ENV: '"production"'
}

View File

@ -1,6 +0,0 @@
var merge = require('webpack-merge')
var devEnv = require('./dev.env')
module.exports = merge(devEnv, {
NODE_ENV: '"testing"'
})

View File

@ -1,10 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<meta charset="utf-8">
<title>OnlineJudge Admin</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<title>oj</title>
</head>
<body>
<div id="app"></div>

5378
oj/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,89 +1,76 @@
{
"name": "onlinejudge_webapp",
"name": "oj",
"version": "1.0.0",
"description": "A Vue.js project",
"author": "tw1997 <t888y@qq.com>",
"author": "zemal <rawidn@163.com>",
"private": true,
"scripts": {
"dev": "node build/dev-server.js",
"start": "node build/dev-server.js",
"build": "node build/build.js",
"unit": "karma start test/unit/karma.conf.js --single-run",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
"lint": "eslint --ext .js,.vue src"
},
"dependencies": {
"font-awesome": "^4.7.0",
"moment": "^2.17.1",
"promise": "^7.1.1",
"simditor": "^2.3.6",
"simditor-markdown": "^1.1.2",
"vue": "^2.0.1",
"vue-codemirror": "^3.0.6",
"vue-i18n": "^4.7.1",
"vue-resource": "^1.0.3",
"vue-router": "^2.0.1"
"iview": "^2.0.0-rc.17",
"less": "^2.7.2",
"less-loader": "^4.0.4",
"moment": "^2.18.1",
"vue": "^2.3.3",
"vue-codemirror": "^3.0.8",
"vue-resource": "^1.3.4",
"vue-router": "^2.6.0"
},
"devDependencies": {
"autoprefixer": "^6.4.0",
"babel-core": "^6.0.0",
"babel-eslint": "^7.0.0",
"babel-loader": "^6.0.0",
"babel-plugin-transform-runtime": "^6.0.0",
"babel-preset-es2015": "^6.0.0",
"babel-preset-stage-2": "^6.0.0",
"babel-register": "^6.0.0",
"chai": "^3.5.0",
"autoprefixer": "^6.7.2",
"babel-core": "^6.22.1",
"babel-eslint": "^7.1.1",
"babel-loader": "^6.2.10",
"babel-plugin-transform-runtime": "^6.22.0",
"babel-preset-env": "^1.3.2",
"babel-preset-stage-2": "^6.22.0",
"babel-register": "^6.22.0",
"chalk": "^1.1.3",
"connect-history-api-fallback": "^1.1.0",
"cross-spawn": "^4.0.2",
"css-loader": "^0.25.0",
"element-ui": "^1.0.0",
"eslint": "^3.7.1",
"eslint-config-standard": "^6.1.0",
"eslint-friendly-formatter": "^2.0.5",
"eslint-loader": "^1.5.0",
"eslint-plugin-html": "^1.3.0",
"eslint-plugin-promise": "^2.0.1",
"connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^4.0.1",
"css-loader": "^0.28.0",
"eslint": "^3.19.0",
"eslint-friendly-formatter": "^2.0.7",
"eslint-loader": "^1.7.1",
"eslint-plugin-html": "^2.0.0",
"eslint-config-standard": "^6.2.1",
"eslint-plugin-promise": "^3.4.0",
"eslint-plugin-standard": "^2.0.1",
"eventsource-polyfill": "^0.9.6",
"express": "^4.13.3",
"extract-text-webpack-plugin": "^1.0.1",
"file-loader": "^0.9.0",
"function-bind": "^1.0.2",
"html-webpack-plugin": "^2.8.1",
"http-proxy-middleware": "^0.17.2",
"inject-loader": "^2.0.1",
"isparta-loader": "^2.0.0",
"json-loader": "^0.5.4",
"karma": "^1.3.0",
"karma-coverage": "^1.1.1",
"karma-mocha": "^1.2.0",
"karma-sinon-chai": "^1.2.0",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
"karma-webpack": "^1.7.0",
"less": "^2.7.1",
"less-loader": "^2.2.3",
"lolex": "^1.4.0",
"mocha": "^3.1.0",
"nightwatch": "^0.9.8",
"opn": "^4.0.2",
"ora": "^0.3.0",
"express": "^4.14.1",
"extract-text-webpack-plugin": "^2.0.0",
"file-loader": "^0.11.1",
"friendly-errors-webpack-plugin": "^1.1.3",
"html-webpack-plugin": "^2.28.0",
"http-proxy-middleware": "^0.17.3",
"webpack-bundle-analyzer": "^2.2.1",
"semver": "^5.3.0",
"shelljs": "^0.7.4",
"sinon": "^1.17.3",
"sinon-chai": "^2.8.0",
"url-loader": "^0.5.7",
"vue-loader": "^9.4.0",
"vue-style-loader": "^1.0.0",
"webpack": "^1.13.2",
"webpack-dev-middleware": "^1.8.3",
"webpack-hot-middleware": "^2.12.2",
"webpack-merge": "^0.14.1"
"shelljs": "^0.7.6",
"opn": "^4.0.2",
"optimize-css-assets-webpack-plugin": "^1.3.0",
"ora": "^1.2.0",
"rimraf": "^2.6.0",
"url-loader": "^0.5.8",
"vue-loader": "^12.1.0",
"vue-style-loader": "^3.0.1",
"vue-template-compiler": "^2.3.3",
"webpack": "^2.6.1",
"webpack-dev-middleware": "^1.10.0",
"webpack-hot-middleware": "^2.18.0",
"webpack-merge": "^4.1.0"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}

View File

@ -11,8 +11,7 @@
</template>
<script>
import 'font-awesome/css/font-awesome.min.css'
import Navbar from './components/Navbar.vue'
import Navbar from './NavBar.vue'
// import api from './api.js'
export default {
@ -32,31 +31,24 @@
<style lang="less">
* {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
body {
margin: 0;
background-color: rgb(236, 242, 247) !important;
}
a {
text-decoration: none;
background-color: transparent;
-webkit-text-decoration-skip: objects;
&:active, &:hover{
outline-width:0 ;
}
a:active,
a:hover {
outline-width: 0;
}
img {
border-style: none;
}
body {
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, SimSun, sans-serif;
margin: 0;
padding: 0;
font-family: "Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif;
overflow: auto;
-webkit-font-smoothing: antialiased;
background-color: #edf0f2;;

View File

@ -0,0 +1,94 @@
<template>
<div id="header">
<Row type="flex" justify="space-around">
<Col span="3">
<div class="logo"><span>QduOJ</span></div>
</Col>
<Col span="14">
<Menu theme="light" mode="horizontal" @on-select="handleRoute">
<Menu-item name="/problems">Problems</Menu-item>
<Menu-item name="/1">Contests</Menu-item>
<Menu-item name="/2">Status</Menu-item>
<Menu-item name="/3">Rank</Menu-item>
<Menu-item name="/4">About</Menu-item>
<Menu-item name="/test">Test</Menu-item>
</Menu>
</Col>
<Col span="4">
<template v-if="!isAuthed">
<div class="btn-menu">
<Button type="ghost" shape="circle" @click="handleRoute('/login')">Login</Button>
<Button type="ghost" shape="circle" @click="handleRoute('/register')" style="margin-left: 5px;">Register
</Button>
</div>
</template>
<template v-else>
<Dropdown class="right" @on-click="handleRoute">
<Button class="btn-menu" type="text">zemal
<Icon type="arrow-down-b"></Icon>
</Button>
<Dropdown-menu slot="list">
<Dropdown-item>Home</Dropdown-item>
<Dropdown-item>Submissions</Dropdown-item>
<Dropdown-item name="/settings">Settings</Dropdown-item>
<Dropdown-item divided name="/logout">Logout</Dropdown-item>
</Dropdown-menu>
</Dropdown>
</template>
</Col>
</Row>
</div>
</template>
<script>
import bus from '../utils/eventBus'
// import auth from '../utils/authHelper'
export default {
data() {
return {
isAuthed: false
}
},
methods: {
handleRoute(route) {
this.$router.push(route)
}
},
mounted() {
bus.$on('loginSuccess', (res) => {
this.isAuthed = true
})
bus.$on('logout', () => {
})
}
}
</script>
<style lang="less" scoped>
.right {
float: right;
}
#header {
background-color: #fff;
border-bottom: 1px solid #dddee1;
.logo {
margin-left: 20px;
font-size: 20px;
float: left;
line-height: 60px;
}
}
.ivu-menu-horizontal.ivu-menu-light:after {
height: 0;
}
.btn-menu {
font-size: 16px;
margin-top: 12px;
}
</style>

View File

@ -1,103 +0,0 @@
<template>
<div class="nav">
<el-row :gutter="10" type="flex" justify="space-around">
<el-col :xs="1" :sm="1" :lg="1">&nbsp;</el-col>
<el-col :xs="2" :sm="2" :lg="2">
<div id="brand">QDUOJ</div>
</el-col>
<el-col :xs="16" :sm="16" :lg="16">
<el-menu theme="dark" mode="horizontal" router>
<el-menu-item index="/problems">Problems</el-menu-item>
<el-menu-item index="3">Contests</el-menu-item>
<el-menu-item index="2">Status</el-menu-item>
<el-menu-item index="4">Rank</el-menu-item>
<el-menu-item index="5">About</el-menu-item>
</el-menu>
</el-col>
<el-col :xs="3" :sm="3" :lg="3">
<template v-if="isLogin">
<el-menu theme="dark" mode="horizontal" router>
<el-submenu index="2">
<template slot="title">{{ info.user.username }}
</template>
<el-menu-item index="2-1" :route="{path:'/user/'+info.user.username}">Home</el-menu-item>
<el-menu-item index="2-2">Submissions</el-menu-item>
<el-menu-item index="/setting">Settings</el-menu-item>
<el-menu-item index="/logout">Logout</el-menu-item>
</el-submenu>
</el-menu>
</template>
<template v-else>
<div class="">
<el-menu theme="dark" mode="horizontal" router>
<el-menu-item class="login-btn" index="/login">Login</el-menu-item>
<el-menu-item class="login-separator" index="/">/</el-menu-item>
<el-menu-item class="login-btn" index="/register">Register</el-menu-item>
</el-menu>
</div>
</template>
</el-col>
</el-row>
</div>
</template>
<script>
import api from '../api.js'
export default {
data() {
return {
isLogin: false
}
},
mounted() {
this.getMyInfo()
},
watch: {
//
'$route': 'getMyInfo'
},
methods: {
getMyInfo() {
api.getMyInfo().then(res => {
this.info = res.data.data
this.isLogin = true
}, res => {
this.isLogin = false
})
}
}
}
</script>
<style lang="less" scoped>
.nav {
background-color: #324157;
}
.el-menu-item{
font-size: 15px;
}
#brand {
font-size: 23px;
display: inline-block;
line-height: 60px;
color: #E5E9F2;
}
.login-btn {
padding: 0 !important;
}
.login-separator {
padding: 0 10px !important;
cursor: default !important;
}
.login-separator.is-active {
color: #bfcbd9 !important;
}
</style>

View File

@ -1,135 +1,39 @@
import Vue from 'vue'
import App from './App'
import Element from 'element-ui'
import 'element-ui/lib/theme-default/index.css'
import VueRouter from 'vue-router'
import locale from 'element-ui/lib/locale/lang/en'
import App from './components/App.vue'
import iView from 'iview'
import router from './routers/index'
import 'iview/dist/styles/iview.css'
import 'font-awesome/css/font-awesome.min.css'
import * as filters from './filters.js'
import * as filters from './utils/filters.js'
import Panel from 'components/Panel.vue'
import IconBtn from 'components/btn/IconBtn.vue'
import Save from 'components/btn/Save.vue'
import Cancel from 'components/btn/Cancel.vue'
// import Panel from 'components/Panel.vue'
// import IconBtn from 'components/btn/IconBtn.vue'
// import Save from 'components/btn/Save.vue'
// import Cancel from 'components/btn/Cancel.vue'
// register global utility filters.
Object.keys(filters).forEach(key => {
Vue.filter(key, filters[key])
})
Vue.use(Element, { locale })
Vue.use(VueRouter)
Vue.use(iView)
// Vue.use(VueI18n)
Vue.component(IconBtn.name, IconBtn)
Vue.component(Panel.name, Panel)
Vue.component(Save.name, Save)
Vue.component(Cancel.name, Cancel)
// 引入 view 组件
import { Problem, Contest, ContestList,
ContestAnnouncement, ProblemList, Login, Register, Logout, Profile,
ProfileSetting, AvatarSetting } from './views'
const router = new VueRouter({
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: [
{
path: '/login',
name: 'login',
component: Login
},
{
path: '/register',
name: 'register',
component: Register
},
{
path: '/logout',
name: 'logout',
component: Logout
},
{
path: '/user/:userId',
name: 'user-profile',
component: Profile
},
{
path: '/setting',
name: 'profile-setting',
component: ProfileSetting
},
{
path: '/setting/avatar',
name: 'avatar-setting',
component: AvatarSetting
},
// 隔开
{
path: '/problems',
name: 'problem-list',
component: ProblemList
},
{
path: '/problem/:id',
name: 'problem-details',
component: Problem
},
{
path: '/contest/create',
name: 'create-contest',
component: Contest
},
{
path: '/contest',
name: 'contest-list',
component: ContestList
},
{
path: '/contest/:contestId/edit',
name: 'edit-contest',
component: Contest
},
{
path: '/contest/:contestId/announcement',
name: 'contest-announcement',
component: ContestAnnouncement
},
{
path: '/contest/:contestId/problems',
name: 'contest-problem-list',
component: ProblemList
},
{
path: '/contest/:contestId/problem/create',
name: 'create-contest-problem',
component: Problem
},
{
path: '/contest/:contestId/problem/:problemId/edit',
name: 'edit-contest-problem',
component: Problem
},
{
path: '*', redirect: '/problems'
}
]
})
Vue.prototype.$error = (msg) => {
Vue.prototype.$message({ 'message': msg, 'type': 'error' })
}
Vue.prototype.$alert = (msg) => {
Vue.prototype.$message({ 'message': msg, 'type': 'info' })
}
// Vue.component(IconBtn.name, IconBtn)
// Vue.component(Panel.name, Panel)
// Vue.component(Save.name, Save)
// Vue.component(Cancel.name, Cancel)
// 注册全局消息提示
Vue.prototype.$error = Vue.prototype.$Message.error
Vue.prototype.$info = Vue.prototype.$Message.info
Vue.prototype.$success = (msg) => {
if (!msg) {
Vue.prototype.$message({ 'message': 'Succeeded', 'type': 'success' })
Vue.prototype.$Message.success('Success')
} else {
Vue.prototype.$message({ 'message': msg, 'type': 'success' })
Vue.prototype.$Message.success(msg)
}
}
Vue.prototype.$Message.success('启动成功')
new Vue(Vue.util.extend({router}, App)).$mount('#app')
new Vue(Vue.util.extend({ router }, App)).$mount('#app')

32
oj/src/routers/index.js Normal file
View File

@ -0,0 +1,32 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Test from '../views/test'
import Login from './routes/login'
Vue.use(VueRouter)
const router = new VueRouter({
mode: 'history',
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
},
routes: [
Login,
{
path: '/test',
name: 'Test',
component: Test
},
{
path: '*', redirect: '/login'
}
]
})
export default router

View File

@ -0,0 +1,14 @@
// import auth from '@/utils/authHelper'
export default {
name: 'login',
path: '/login',
component: () => import('@/views/user/Login.vue')
// beforeEnter(to, from, next) {
// if (auth.isAuthicated()) {
// next('/test')
// } else {
// next()
// }
// }
}

View File

@ -0,0 +1,30 @@
const storage = window.localStorage
export default {
name: 'authHelper',
/**
* 将用户信息存入localstorage
* @param userInfo
*/
set(userInfo) {
storage.setItem('userInfo', JSON.stringify(userInfo))
},
/**
* 获取用户信息
* @returns {object}
*/
get() {
return JSON.parse(storage.getItem('userInfo')) || null
},
/**
* 清除用户信息
*/
remove() {
storage.removeItem('userInfo')
},
isAuthicated() {
return !!storage.getItem('userInfo')
}
}

6
oj/src/utils/eventBus.js Normal file
View File

@ -0,0 +1,6 @@
// 这个文件用来做同级组件间的数据传递,见Login.vue
import Vue from 'vue'
export default new Vue()

View File

@ -1,144 +0,0 @@
<template>
<div class="announcement view">
<Panel title="Contest Announcement">
<div class="list">
<el-table v-loading="loading" element-loading-text="loading" ref="table" :data="announcementList" style="width: 100%">
<el-table-column prop="title" label="Title">
</el-table-column>
<el-table-column prop="created_by.username" label="Author">
</el-table-column>
<el-table-column prop="create_time" label="CreateTime">
</el-table-column>
<el-table-column inline-template fixed="right" label="Option">
<span>
<el-button type="text" size="small" @click="deleteAnnouncement(row.id)">Delete</el-button>
</span>
</el-table-column>
</el-table>
<div class="option">
<el-button type="primary" size="small" @click="openAnnouncementDialog()" icon="plus">Create</el-button>
</div>
</div>
</Panel>
<!--对话框-->
<el-dialog title="Create Contest Announcement" @open="openAnnouncementDialog" v-model="showEditAnnouncementDialog">
<el-form label-position="top">
<el-form-item label="Title" required>
<el-input v-model="announcement.title" placeholder="Title" class="title-input">
</el-input>
</el-form-item>
<el-form-item label="Content" required>
<Simditor v-model="announcement.content"></Simditor>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<cancel @click.native="showEditAnnouncementDialog = false"></cancel>
<save type="primary" @click.native="saveAnnouncement()">Submit</save>
</span>
</el-dialog>
</div>
</template>
<script>
import Simditor from '../../components/Simditor.vue'
import api from '../../api.js'
export default {
name: 'Announcement',
components: {
Simditor
},
data() {
return {
showEditAnnouncementDialog: false,
//
announcementList: [
],
announcement: {
title: '',
content: '',
contest_id: ''
},
// loading
loading: true
}
},
methods: {
getContestAnnouncementList() {
this.loading = true
api.getContestAnnouncementList(this.$route.params.contestId).then(res => {
this.announcementList = res.data.data
this.loading = false
}).catch(() => {
this.loading = false
})
},
openAnnouncementDialog() {
// todo
// bug
setTimeout(() => {
if (document.createEvent) {
let event = document.createEvent('HTMLEvents')
event.initEvent('resize', true, true)
window.dispatchEvent(event)
} else if (document.createEventObject) {
window.fireEvent('onresize')
}
}, 0)
this.showEditAnnouncementDialog = true
},
//
saveAnnouncement() {
this.announcement.contest_id = this.$route.params.contestId
api.createContestAnnouncement(this.announcement).then(() => {
this.showEditAnnouncementDialog = false
this.announcement.title = this.announcement.content = ''
this.getContestAnnouncementList()
}).catch(() => {
this.showEditAnnouncementDialog = false
})
},
//
deleteAnnouncement(announcementId) {
this.$confirm('Are you sure you want to delete this announcement?', 'Warning', {
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
this.loading = true
api.deleteContestAnnouncement(announcementId).then(res => {
this.getContestAnnouncementList()
})
}).catch(() => { })
}
},
mounted() {
this.getContestAnnouncementList()
}
}
</script>
<style lang="less" scoped>
.announcement {
.option {
border: 1px solid #e0e6ed;
border-top: none;
padding: 10px;
background-color: #fff;
position: relative;
button {
margin-right: 10px;
}
}
}
.title-input {
margin-bottom: 20px;
}
.visible-box {
margin-top: 10px;
width: 205px;
float: left;
}
</style>

View File

@ -1,106 +0,0 @@
<template>
<div class="view">
<Panel :title="title">
<el-form label-position="top">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="Title" required>
<el-input v-model="contest.title" placeholder="Tittle"></el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="Description" required>
<Simditor v-model="contest.description"></Simditor>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Start Time" required>
<el-date-picker v-model="contest.start_time" type="datetime" placeholder="Start Time">
</el-date-picker>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="End Time" required>
<el-date-picker v-model="contest.end_time" type="datetime" placeholder="End Time">
</el-date-picker>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Password">
<el-input v-model="contest.password" placeholder="Contest Password"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Contest Rule Type">
<el-radio class="radio" v-model="contest.rule_type" label="ACM" :disabled="disableRuleType">ACM</el-radio>
<el-radio class="radio" v-model="contest.rule_type" label="OI" :disabled="disableRuleType">OI</el-radio>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Real Time Rank">
<el-switch v-model="contest.real_time_rank" on-color="#13ce66" off-color="#ff4949">
</el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Status">
<el-switch v-model="contest.visible" on-text="" off-text="">
</el-switch>
</el-form-item>
</el-col>
</el-row>
</el-form>
<save @click.native="saveContest"></save>
</Panel>
</div>
</template>
<script>
import api from '../../api.js'
import Simditor from '../../components/Simditor.vue'
import { backendDatetimeToISOFormat } from '../../utils'
export default {
name: 'CreateContest',
components: {
Simditor
},
data() {
return {
title: 'Create Contest',
disableRuleType: false,
contest: {
title: '',
description: '',
start_time: '',
end_time: '',
rule_type: 'ACM',
password: '',
real_time_rank: true,
visible: true
}
}
},
methods: {
saveContest() {
let funcName = this.$route.name === 'edit-contest' ? 'editContest' : 'createContest'
api[funcName](this.contest).then(res => {
this.$router.push({ name: 'contest-list', query: { refresh: 'true' } })
}).catch(() => { })
}
},
mounted() {
if (this.$route.name === 'edit-contest') {
this.title = 'Edit Contest'
this.disableRuleType = true
api.getContest(this.$route.params.contestId).then(res => {
let data = res.data.data
data.start_time = backendDatetimeToISOFormat(data.start_time)
data.end_time = backendDatetimeToISOFormat(data.end_time)
this.contest = data
}).catch(() => {
})
}
}
}
</script>

View File

@ -1,116 +0,0 @@
<template>
<div class="view">
<Panel title="Contest List">
<div slot="header">
<el-input v-model="keyword" icon="search" placeholder="Keywords">
</el-input>
</div>
<el-table v-loading="loading" element-loading-text="loading" ref="table" :data="contestList" style="width: 100%">
<el-table-column type="expand">
<template scope="props">
<div>
<el-tag :type="props.row.visible ? 'success' : 'danger'">{{props.row.visible ? 'Visible' : 'Invisible'}}</el-tag>
<el-tag :type="props.row.contest_status === 'Public' ? 'success' : 'primary'">{{ props.row.contest_type}}</el-tag>
<el-tag :type="props.row.status === 'Ended' ? 'gray' : props.row.status === 'Underway' ? 'success' : 'primary'">{{ props.row.status }}</el-tag>
</div>
<p>
Create Time: {{ props.row.create_time }}
</p>
</template>
</el-table-column>
<el-table-column prop="id" label="ID">
</el-table-column>
<el-table-column prop="title" label="Title">
</el-table-column>
<el-table-column prop="start_time" label="Start Time">
</el-table-column>
<el-table-column prop="end_time" label="End Time">
</el-table-column>
<el-table-column prop="created_by.username" label="Author">
</el-table-column>
<el-table-column inline-template :context="_self" fixed="right" label="Operation">
<div>
<icon-btn name="Edit" icon="edit" @click.native="goEdit(row.id)"></icon-btn>
<icon-btn name="Problem" icon="list-ol" @click.native="goContestProblemList(row.id)"></icon-btn>
<icon-btn name="Announcement" icon="info-circle" @click.native="goContestAnnouncement(row.id)"></icon-btn>
</div>
</el-table-column>
</el-table>
<div class="option">
<el-pagination class="page" layout="prev, pager, next" @current-change="currentChange" :page-size="pageSize" :total="total">
</el-pagination>
</div>
</Panel>
</div>
</template>
<script>
import api from '../../api.js'
export default {
name: 'ContestList',
data() {
return {
pageSize: 5,
total: 0,
contestList: [],
keyword: '',
loading: false,
currentPage: 1
}
},
mounted() {
this.getContestList(this.currentPage)
},
methods: {
//
currentChange(page) {
this.currentPage = page
this.getContestList(page)
},
getContestList(page) {
this.loading = true
api.getContestList((page - 1) * this.pageSize, this.pageSize, this.keyword).then(res => {
this.loading = false
this.total = res.data.data.total
this.contestList = res.data.data.results
}, res => {
this.loading = false
})
},
goEdit(contestId) {
this.$router.push({ name: 'edit-contest', params: { contestId } })
},
goContestAnnouncement(contestId) {
this.$router.push({ name: 'contest-announcement', params: { contestId } })
},
goContestProblemList(contestId) {
this.$router.push({ name: 'contest-problem-list', params: { contestId } })
}
},
watch: {
'keyword'() {
this.currentChange(1)
}
}
}
</script>
<style scoped lang="less">
.option {
border: 1px solid #e0e6ed;
border-top: none;
padding: 8px;
background-color: #fff;
position: relative;
height: 50px;
button {
margin-right: 10px;
}
>.page {
position: absolute;
right: 20px;
top: 10px;
}
}
</style>

View File

@ -1,17 +1,20 @@
import Problem from './problem/Problem.vue'
import ProblemList from './problem/ProblemList.vue'
import ContestList from './contest/ContestList.vue'
import Contest from './contest/Contest.vue'
import ContestAnnouncement from './contest/Announcement.vue'
import Login from './user/Login.vue'
import Register from './user/Register.vue'
import Logout from './user/Logout.vue'
import Profile from './user/Profile.vue'
import ProfileSetting from './setting/ProfileSetting.vue'
import AvatarSetting from './setting/AvatarSetting.vue'
/* , Login, Logout,
在对应的route内加载
见https://router.vuejs.org/en/advanced/lazy-loading.html
*/
export {
Problem, ProblemList, Contest,
ContestList, ContestAnnouncement, Login, Register, Logout, Profile,
ProfileSetting, AvatarSetting
}
// import Problem from './problem/Problem.vue'
// import ProblemList from './problem/ProblemList.vue'
// import Register from './user/Register.vue'
// import Logout from './user/Logout.vue'
// import Profile from './user/Profile.vue'
// import ProfileSetting from './setting/ProfileSetting.vue'
// import AvatarSetting from './setting/AvatarSetting.vue'
//
// export {
// Problem, ProblemList,
// Login, Register, Logout, Profile,
// ProfileSetting, AvatarSetting
// }
// export {}

View File

@ -78,7 +78,7 @@
import CodeMirror from '../../components/CodeMirror'
import Card from '../../components/Card'
import api from '../../api'
import {STATUS} from '../../utils'
import {STATUS} from '../../utils/utils'
import Panel from '../../components/Panel'
export default {

20
oj/src/views/test.vue Normal file
View File

@ -0,0 +1,20 @@
<template>
<div>
Hello world
</div>
</template>
<script>
import api from '@/api'
export default {
mounted() {
api.getProblem('1').then((res) => {
console.log(res.data)
})
}
}
</script>
<style>
</style>

View File

@ -1,39 +1,35 @@
<template>
<div class="center">
<el-card class="box-card" id="login-card">
<div slot="header" class="clearfix" id="login-title">
<span style="line-height: 36px;">用户登录</span>
<div class="center">
<Card id="login-card">
<div slot="title" class="clearfix" id="login-title">
<span style="line-height: 36px;">OnlineJudge</span>
</div>
<Form :model="loginForm" :label-width="80">
<Form-item label="Username">
<Input v-model="loginForm.username"/>
</Form-item>
<Form-item label="Password">
<Input v-model="loginForm.password"/>
</Form-item>
<Form-item>
<Button type="primary" id="btn-login" @click="onSubmit">Login</Button>
</Form-item>
</Form>
<el-form :label-position="labelPosition" label-width="80px" :model="formLabelAlign">
<el-form-item label="账号">
<el-input v-model="formLabelAlign.username"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="formLabelAlign.password" type="password"></el-input>
</el-form-item>
<!-- <el-form-item label="二次验证">
<el-input v-model="formLabelAlign.totp"></el-input>
</el-form-item> -->
<el-form-item>
<el-button type="primary" @click="onSubmit">立即登录</el-button>
<el-button>用户注册</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</Card>
</div>
</template>
<script>
import api from '../../api.js'
import api from '../../api.js'
import bus from '../../utils/eventBus'
// import auth from '@/utils/authHelper'
export default {
export default {
data() {
return {
labelPosition: 'top',
formLabelAlign: {
loginForm: {
username: '',
password: ''
}
@ -41,27 +37,33 @@ export default {
},
methods: {
onSubmit() {
console.log('submit!')
api.login(this.formLabelAlign.username, this.formLabelAlign.password).then(function(res) {
console.log(res)
api.devLogin(this.loginForm.username, this.loginForm.password).then((res) => {
bus.$emit('loginSuccess', res)
// auth.set(res.data)
}, (res) => {
})
// header
this.$router.replace({ path: '/' })
}
}
}
}
</script>
<style lang="css">
.center{
.center {
margin-top: 50px;
}
#login-card{
}
#login-card {
max-width: 400px;
margin: auto;
}
#login-title{
}
#login-title {
text-align: center;
font-size: 20px;
}
}
#btn-login {
float: right;
margin-right: 10px;
}
</style>

View File