├── .babelrc ├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── client ├── build │ ├── koa2 │ │ ├── dev.js │ │ └── hot.js │ ├── setup-dev-server.js │ ├── webpack.base.config.js │ ├── webpack.client.config.js │ └── webpack.server.config.js ├── src │ ├── api │ │ ├── article.js │ │ ├── login.js │ │ └── tag.js │ ├── components │ │ ├── Loading.vue │ │ └── Pagination.vue │ ├── lib │ │ ├── debounce.js │ │ ├── marked.js │ │ └── throttle.js │ └── modules │ │ ├── admin │ │ ├── App.vue │ │ ├── app.js │ │ ├── assets │ │ │ ├── logo.png │ │ │ └── stylus │ │ │ │ ├── _settings.styl │ │ │ │ ├── _syntax.styl │ │ │ │ ├── main.styl │ │ │ │ ├── preview.styl │ │ │ │ └── reset.styl │ │ ├── components │ │ │ ├── Admin.vue │ │ │ ├── Editor.vue │ │ │ ├── List.vue │ │ │ └── Login.vue │ │ ├── index.html │ │ └── store │ │ │ ├── index.js │ │ │ ├── modules │ │ │ ├── auth.js │ │ │ └── editor.js │ │ │ └── mutation-types.js │ │ └── front │ │ ├── App.vue │ │ ├── app.js │ │ ├── assets │ │ ├── iconfont │ │ │ ├── iconfont.css │ │ │ ├── iconfont.eot │ │ │ ├── iconfont.svg │ │ │ ├── iconfont.ttf │ │ │ └── iconfont.woff │ │ ├── logo.png │ │ └── stylus │ │ │ ├── _settings.styl │ │ │ ├── _syntax.styl │ │ │ ├── main.styl │ │ │ ├── markdown.styl │ │ │ └── reset.styl │ │ ├── components │ │ ├── Article.vue │ │ ├── List.vue │ │ └── common │ │ │ ├── Comment.vue │ │ │ ├── Side.vue │ │ │ └── Top.vue │ │ ├── entry-client.js │ │ ├── entry-server.js │ │ ├── index.html │ │ ├── router │ │ └── index.js │ │ └── store │ │ └── index.js └── static │ └── .gitkeep ├── docker-compose.prod.yml ├── docker-compose.yml ├── node.dockerfile ├── nodemon.json ├── package-lock.json ├── package.json ├── pm2.json ├── server ├── api │ ├── index.js │ └── routes │ │ ├── articles.js │ │ ├── tags.js │ │ └── token.js ├── configs │ └── index.js ├── controllers │ ├── articles_controller.js │ ├── tags_controller.js │ └── token_controller.js ├── index.js ├── logs │ └── .gitkeep ├── middleware │ ├── historyApiFallback.js │ ├── index.js │ └── verify.js ├── models │ ├── article.js │ ├── tag.js │ └── user.js └── start.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": ["transform-runtime", 4 | ["component", [ 5 | { 6 | "libraryName": "element-ui", 7 | "styleLibraryName": "theme-default" 8 | } 9 | ]]] 10 | } 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | **/dist/ 4 | *.log 5 | *.pid 6 | stats.json 7 | test-code/ 8 | server/logs/*.log 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/* 2 | **/bower_components/* 3 | static/* 4 | output/* 5 | test/* 6 | **/dist/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | **/dist/ 4 | *.log 5 | *.pid 6 | stats.json 7 | server/configs/private.js 8 | test-code/ 9 | server/logs/*.log 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present, Junming Huang (BUPT-HJM) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-blog 2 | 3 | > A single-user blog built with vue2, koa2 and mongodb which supports Server-Side Rendering 4 | 5 | 一个使用vue2、koa2、mongodb搭建的单用户博客,支持markdown编辑,文章标签分类,发布文章/撤回发布文章,支持服务端渲染(Server-Side Rendering) 6 | 7 |

8 | 9 | 10 | 11 |
12 | 访问链接:https://imhjm.com/ 13 |

14 | 15 | ## 整体架构 16 | 17 | 18 | - client端分为`front`和`admin`,`webpack2`打包实现多页配置,开发模式下`hot reload` 19 | - admin端使用vue2、vuex、vue-router 20 | - front端直接使用 ~~vue event bus~~ vuex(考虑到今后博客应用可能变复杂)、vue-router, Fastclick解决移动端300ms延迟问题 21 | - 编辑器使用[simplemde](https://github.com/NextStepWebs/simplemde-markdown-editor) 22 | - markdown解析和高亮使用marked.js和highlight.js 23 | - server 24 | - 使用koa2+koa-router实现RESTful API 25 | - mongoose连接mongodb 26 | - 前后端鉴权使用[jwt](https://github.com/auth0/node-jsonwebtoken) 27 | - 实现Server-Side Rendering服务端渲染 28 | 29 | ## 更多细节 30 | - 博客线上地址:http://imhjm.com/ 31 | - [基于vue2、koa2、mongodb的个人博客](http://imhjm.com/article/58f76ed0c9eb43547d08ec6c) 32 | - [Vue2服务端渲染实践以及相关解读](http://imhjm.com/article/590710fbe3176b248999f88c) 33 | 34 | > 访问博客线上地址可以获得最新信息 35 | 36 | ## 快速开始 37 | - 需要Node.js 6+版本 38 | - 需要安装mongodb,并且运行mongodb服务,在`server/configs/index.js`中默认连接`mongodb://localhost:27017/vue-blog` 39 | - 配置`server/configs/index.js`,配置admin用户名、密码等,或者新建`server/configs/private.js` 40 | 41 | > 注:可使用docker快速开始,详见后文 42 | 43 | ``` bash 44 | # install dependencies 45 | # 安装依赖,可以使用yarn/npm 46 | npm install # or yarn install 47 | 48 | # serve in dev mode, with hot reload at localhost:8889 49 | # 开发环境,带有HMR,监听8889端口 50 | npm run dev 51 | 52 | # build for production 53 | # 生产环境打包 54 | npm run build 55 | 56 | # serve in production mode (with building) 57 | # 生产环境服务,不带有打包 58 | npm start 59 | 60 | # serve in production mode (without building) 61 | # 生产环境服务,带有打包 62 | npm run prod 63 | 64 | # pm2 65 | # need `npm install pm2 -g` 66 | npm run pm2 67 | ``` 68 | 69 | ## 使用docker快速开始 70 | - 首先,需要访问[docker官网](https://www.docker.com/)根据不同操作系统获取docker 71 | - docker官方文档:https://docs.docker.com/ 72 | - mongo dockerhub文档:https://hub.docker.com/_/mongo/ (关于auth/volumes一些问题) 73 | 74 | ``` bash 75 | # development mode(use volumes for test-edit-reload cycle) 76 | # 开发模式(使用挂载同步容器和用户主机上的文件以便于开发) 77 | # Build or rebuild services 78 | docker-compose build 79 | # Create and start containers 80 | docker-compose up 81 | 82 | # production mode 83 | # 生产模式 84 | # Build or rebuild services 85 | docker-compose -f docker-compose.prod.yml build 86 | # Create and start containers 87 | docker-compose -f docker-compose.prod.yml up 88 | 89 | # 进入容器开启交互式终端 90 | # (xxx指代容器名或者容器ID,可由`docker ps`查看) 91 | docker exec -it xxx bash 92 | ``` 93 | 94 | > 注:为了防止生产环境数据库被改写,生产模式数据库与开发环境数据库的链接不同,开发环境使用vue-blog,生产环境使用vue-blog-prod,具体可以看docker-compose配置文件 95 | 96 | 97 | ## 自定义配置 98 | server端配置文件位于`server/configs`目录下 99 | 100 | ``` javascript 101 | // 可新建private.js定义自己私有的配置 102 | module.exports = { 103 | mongodbSecret: { // 如果mongodb设置用户名/密码可在此配置 104 | user: '', 105 | pass: '' 106 | }, 107 | jwt: { // 配置jwt secret 108 | secret: '' 109 | }, 110 | admin: { // 配置用户名/密码 111 | user: '', 112 | pwd: '' 113 | }, 114 | disqus: { // disqus评论 115 | url: '', 116 | }, 117 | baidu: { // 百度统计 118 | url: '', 119 | }, 120 | } 121 | ``` 122 | 123 | ## LICENSE 124 | [MIT](https://github.com/BUPT-HJM/vue-blog/blob/master/LICENSE) 125 | -------------------------------------------------------------------------------- /client/build/koa2/dev.js: -------------------------------------------------------------------------------- 1 | const devMiddleware = require('webpack-dev-middleware'); 2 | 3 | module.exports = (compiler, opts) => { 4 | const expressMiddleware = devMiddleware(compiler, opts); 5 | let nextFlag = false; 6 | function nextFn() { 7 | nextFlag = true; 8 | } 9 | function devFn(ctx, next) { 10 | expressMiddleware(ctx.req, { 11 | end: (content) => { 12 | ctx.body = content; 13 | }, 14 | setHeader: (name, value) => { 15 | ctx.headers[name] = value; 16 | }, 17 | }, nextFn); 18 | if (nextFlag) { 19 | nextFlag = false; 20 | return next(); 21 | } 22 | } 23 | devFn.fileSystem = expressMiddleware.fileSystem; 24 | return devFn; 25 | }; 26 | -------------------------------------------------------------------------------- /client/build/koa2/hot.js: -------------------------------------------------------------------------------- 1 | const hotMiddleware = require('webpack-hot-middleware'); 2 | const PassThrough = require('stream').PassThrough; 3 | 4 | module.exports = (compiler, opts) => { 5 | const expressMiddleware = hotMiddleware(compiler, opts); 6 | return (ctx, next) => { 7 | let stream = new PassThrough(); 8 | ctx.body = stream; 9 | return expressMiddleware(ctx.req, { 10 | write: stream.write.bind(stream), 11 | writeHead: (state, headers) => { 12 | ctx.state = state; 13 | ctx.set(headers); 14 | }, 15 | }, next); 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /client/build/setup-dev-server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const MFS = require('memory-fs'); 4 | const clientConfig = require('./webpack.client.config'); 5 | const serverConfig = require('./webpack.server.config'); 6 | 7 | module.exports = function setupDevServer (app, cb) { 8 | let bundle; 9 | let template; 10 | 11 | const clientCompiler = webpack(clientConfig); 12 | const devMiddleware = require('./koa2/dev.js')(clientCompiler, { 13 | publicPath: clientConfig.output.publicPath, 14 | stats: { 15 | colors: true, 16 | }, 17 | // noInfo: true 18 | }); 19 | app.use(devMiddleware); 20 | 21 | 22 | clientCompiler.plugin('done', () => { 23 | const fs = devMiddleware.fileSystem; 24 | const filePath = path.join(clientConfig.output.path, 'front.html'); 25 | console.log('clientPath', serverConfig.output.path); 26 | if (fs.existsSync(filePath)) { 27 | template = fs.readFileSync(filePath, 'utf-8'); 28 | if (bundle) { 29 | console.log('ok'); 30 | cb(bundle, template); 31 | } 32 | } 33 | }); 34 | 35 | // hot middleware 36 | app.use(require('./koa2/hot.js')(clientCompiler)); 37 | 38 | // console.log(serverConfig) 39 | // watch and update server renderer 40 | const serverCompiler = webpack(serverConfig); 41 | const mfs = new MFS(); 42 | serverCompiler.outputFileSystem = mfs; 43 | serverCompiler.watch({}, (err, stats) => { 44 | if (err) { 45 | throw err; 46 | } 47 | stats = stats.toJson(); 48 | stats.errors.forEach(err => console.error(err)); 49 | stats.warnings.forEach(err => console.warn(err)); 50 | 51 | console.log('serverPath', serverConfig.output.path); 52 | // read bundle generated by vue-ssr-webpack-plugin 53 | const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-server-bundle.json'); 54 | bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')); 55 | if (template) { 56 | console.log('ok'); 57 | cb(bundle, template); 58 | } 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /client/build/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const { resolve, join } = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const nodeModulesPath = resolve(__dirname, '../../node_modules'); 5 | const CLIENT_FOLDER = resolve(__dirname, '../'); 6 | const SERVER_FOLDER = resolve(__dirname, '../../server'); 7 | const productionEnv = process.env.NODE_ENV === 'production' ? true : false; 8 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 9 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 10 | 11 | let config = { 12 | devtool: '#cheap-module-eval-source-map', 13 | entry: { 14 | 'modules/admin': [ 15 | 'babel-polyfill', 16 | CLIENT_FOLDER + '/src/modules/admin/app', 17 | ], 18 | 'modules/front': [ 19 | 'babel-polyfill', 20 | CLIENT_FOLDER + '/src/modules/front/entry-client', 21 | ], 22 | }, 23 | output: { 24 | path: CLIENT_FOLDER + '/dist', 25 | filename: '[name].js', 26 | publicPath: '/', 27 | }, 28 | externals: { 29 | simplemde: 'SimpleMDE', 30 | }, 31 | plugins: [], 32 | module: { 33 | rules: [{ 34 | test: /\.vue$/, 35 | loader: 'vue-loader', 36 | options: { 37 | loaders: { 38 | styl: ['vue-style-loader', 'css-loader?minimize', 'stylus-loader'], 39 | stylus: ['vue-style-loader', 'css-loader?minimize', 'stylus-loader'], 40 | css: ['vue-style-loader', 'css-loader?minimize'], 41 | }, 42 | preserveWhitespace: false, 43 | postcss: [require('autoprefixer')({ browsers: ['last 7 versions'] })], 44 | }, 45 | // include: [ 46 | // resolve(__dirname, '../src/'), 47 | // resolve(__dirname, '../../node_modules/vue-slider-component/'), 48 | // ] 49 | }, { 50 | test: /\.js$/, 51 | loader: 'babel-loader', 52 | exclude: nodeModulesPath, 53 | // include: [ 54 | // resolve(__dirname, '../src/'), 55 | // resolve(__dirname, '../../node_modules/vue-slider-component/'), 56 | // ] 57 | }, { 58 | test: /\.styl$/, 59 | use: ['style-loader', 'css-loader?minimize', 'stylus-loader'], 60 | include: CLIENT_FOLDER, 61 | }, { 62 | test: /\.css$/, 63 | use: ['style-loader', 'css-loader?minimize'], 64 | }, { 65 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 66 | loader: 'url-loader', 67 | options: { 68 | limit: 10000, 69 | name: 'img/[name].[hash:7].[ext]', 70 | }, 71 | }, { 72 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 73 | loader: 'url-loader', 74 | options: { 75 | limit: 10000, 76 | name: 'fonts/[name].[hash:7].[ext]', 77 | }, 78 | }], 79 | }, 80 | resolve: { 81 | extensions: ['.js', '.vue', '.json'], 82 | modules: [nodeModulesPath], 83 | alias: { 84 | vue$: 'vue/dist/vue.esm.js', 85 | vuex$: 'vuex/dist/vuex.esm.js', 86 | 'vue-router$': 'vue-router/dist/vue-router.esm.js', 87 | simplemde$: 'simplemde/dist/simplemde.min.js', 88 | 'highlight.js$': 'highlight.js/lib/highlight.js', 89 | fastclick: 'fastclick/lib/fastclick.js', 90 | lib: resolve(__dirname, '../src/lib'), 91 | api: resolve(__dirname, '../src/api'), 92 | publicComponents: resolve(__dirname, '../src/components'), 93 | serverConfig: resolve(__dirname, '../../server/configs/'), 94 | }, 95 | }, 96 | // cache: true 97 | }; 98 | 99 | module.exports = config; 100 | -------------------------------------------------------------------------------- /client/build/webpack.client.config.js: -------------------------------------------------------------------------------- 1 | const { resolve, join } = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const nodeModulesPath = resolve(__dirname, '../../node_modules'); 5 | const CLIENT_FOLDER = resolve(__dirname, '../'); 6 | const SERVER_FOLDER = resolve(__dirname, '../../server'); 7 | const productionEnv = process.env.NODE_ENV === 'production' ? true : false; 8 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 9 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 10 | const merge = require('webpack-merge'); 11 | const base = require('./webpack.base.config'); 12 | 13 | let config = merge(base, { 14 | plugins: [ 15 | new webpack.HotModuleReplacementPlugin(), 16 | // 开启全局的模块热替换(HMR) 17 | 18 | new webpack.NamedModulesPlugin(), 19 | // 当模块热替换(HMR)时在浏览器控制台输出对用户更友好的模块名字信息, 20 | 21 | new HtmlWebpackPlugin({ 22 | filename: 'admin.html', 23 | template: CLIENT_FOLDER + '/src/modules/admin/index.html', 24 | inject: 'body', 25 | chunks: productionEnv ? ['modules/manifest_admin', 'modules/vendor_admin', 'modules/admin'] : ['modules/admin'], 26 | minify: { // 压缩的方式 27 | removeComments: true, 28 | collapseWhitespace: true, 29 | removeAttributeQuotes: true, 30 | }, 31 | // chunksSortMode: 'dependency' 32 | }), 33 | 34 | new HtmlWebpackPlugin({ 35 | filename: 'front.html', 36 | template: CLIENT_FOLDER + '/src/modules/front/index.html', 37 | // inject: 'body', 38 | // inject: false, 39 | chunks: productionEnv ? ['modules/manifest_front', 'modules/vendor_front', 'modules/front'] : ['modules/front'], 40 | minify: { // 压缩的方式 41 | // removeComments: true, 42 | collapseWhitespace: true, 43 | removeAttributeQuotes: true, 44 | }, 45 | // chunksSortMode: 'dependency' 46 | }), 47 | // 配置提取出的样式文件 48 | new ExtractTextPlugin('css/[name].[contenthash].css'), 49 | 50 | new webpack.DefinePlugin({ 51 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 52 | 'process.env.VUE_ENV': '"client"', 53 | }), 54 | ], 55 | }); 56 | config.entry['modules/admin'].unshift('event-source-polyfill', 'webpack-hot-middleware/client?reload=true'); 57 | config.entry['modules/front'].unshift('event-source-polyfill', 'webpack-hot-middleware/client?reload=true'); 58 | 59 | if (process.env.NODE_ENV === 'production') { 60 | // 删除devtool 61 | delete config.devtool; 62 | // 删除webpack-hot-middleware 63 | config.entry['modules/admin'].splice(0, 2); 64 | config.entry['modules/front'].splice(0, 2); 65 | config.output.filename = '[name].[chunkhash:8].min.js'; 66 | // 提取css 67 | config.module.rules[0].options.loaders = { 68 | styl: ExtractTextPlugin.extract({ 69 | use: [{ 70 | loader: 'css-loader', 71 | options: { 72 | minimize: true, 73 | sourceMap: true, 74 | }, 75 | }, { 76 | loader: 'stylus-loader', 77 | options: { 78 | sourceMap: true, 79 | }, 80 | }], 81 | fallback: 'vue-style-loader', 82 | }), 83 | stylus: ExtractTextPlugin.extract({ 84 | use: [{ 85 | loader: 'css-loader', 86 | options: { 87 | minimize: true, 88 | sourceMap: true, 89 | }, 90 | }, { 91 | loader: 'stylus-loader', 92 | options: { 93 | sourceMap: true, 94 | }, 95 | }], 96 | fallback: 'vue-style-loader', 97 | }), 98 | css: ExtractTextPlugin.extract({ 99 | use: [{ 100 | loader: 'css-loader', 101 | options: { 102 | minimize: true, 103 | sourceMap: true, 104 | }, 105 | }], 106 | fallback: 'vue-style-loader', 107 | }), 108 | }; 109 | // 删除HotModuleReplacementPlugin和NamedModulesPlugin 110 | config.plugins.shift(); 111 | config.plugins.shift(); 112 | config.plugins = config.plugins.concat([ 113 | new webpack.optimize.UglifyJsPlugin({ 114 | // 最紧凑的输出 115 | beautify: false, 116 | // 删除所有的注释 117 | comments: false, 118 | compress: { 119 | // 在UglifyJs删除没有用到的代码时不输出警告 120 | warnings: false, 121 | // 删除所有的 `console` 语句 122 | // 还可以兼容ie浏览器 123 | drop_console: true, 124 | // 内嵌定义了但是只用到一次的变量 125 | collapse_vars: true, 126 | // 提取出出现多次但是没有定义成变量去引用的静态值 127 | reduce_vars: true, 128 | }, 129 | }), 130 | // 分别提取vendor、manifest 131 | new webpack.optimize.CommonsChunkPlugin({ 132 | name: 'modules/vendor_admin', 133 | chunks: ['modules/admin'], 134 | minChunks: function (module, count) { 135 | return ( 136 | module.resource 137 | && /\.js$/.test(module.resource) 138 | && module.resource.indexOf( 139 | nodeModulesPath 140 | ) === 0 141 | ); 142 | }, 143 | }), 144 | new webpack.optimize.CommonsChunkPlugin({ 145 | name: 'modules/manifest_admin', 146 | chunks: ['modules/vendor_admin'], 147 | }), 148 | new webpack.optimize.CommonsChunkPlugin({ 149 | name: 'modules/vendor_front', 150 | chunks: ['modules/front'], 151 | minChunks: function (module, count) { 152 | return ( 153 | module.resource 154 | && /\.js$/.test(module.resource) 155 | && module.resource.indexOf( 156 | nodeModulesPath 157 | ) === 0 158 | ); 159 | }, 160 | }), 161 | new webpack.optimize.CommonsChunkPlugin({ 162 | name: 'modules/manifest_front', 163 | chunks: ['modules/vendor_front'], 164 | }), 165 | // copy static 166 | new CopyWebpackPlugin([{ 167 | from: CLIENT_FOLDER + '/static', 168 | to: CLIENT_FOLDER + '/dist/static', 169 | ignore: ['.*'], 170 | }]), 171 | ]); 172 | } 173 | console.log(config); 174 | module.exports = config; 175 | -------------------------------------------------------------------------------- /client/build/webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const merge = require('webpack-merge'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | const base = require('./webpack.base.config'); 5 | const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); 6 | const { resolve, join } = require('path'); 7 | const CLIENT_FOLDER = resolve(__dirname, '../'); 8 | let config = merge(base, { 9 | target: 'node', 10 | devtool: '#source-map', 11 | entry: CLIENT_FOLDER + '/src/modules/front/entry-server.js', 12 | output: { 13 | filename: 'server-bundle.js', 14 | libraryTarget: 'commonjs2', 15 | }, 16 | resolve: { 17 | }, 18 | externals: nodeExternals({ 19 | whitelist: [/\.vue$/, /\.css$/], 20 | }), 21 | plugins: [ 22 | new webpack.DefinePlugin({ 23 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 24 | 'process.env.VUE_ENV': '"server"', 25 | }), 26 | new VueSSRServerPlugin(), 27 | ], 28 | }); 29 | module.exports = config; 30 | -------------------------------------------------------------------------------- /client/src/api/article.js: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | // 为了让服务端渲染正确请求数据 3 | if (typeof window === 'undefined') { 4 | Axios.defaults.baseURL = 'http://127.0.0.1:8889'; 5 | } 6 | export default { 7 | createArticle(title, content, publish, tags) { 8 | let abstract; 9 | if (content.indexOf('') !== -1) { 10 | abstract = content.split('')[0]; 11 | } else { 12 | abstract = ''; 13 | } 14 | return Axios.post('/api/articles', { title, content, publish, abstract, tags }); 15 | }, 16 | getAllArticles(tag = '', page = 1, limit = 0) { 17 | return Axios.get(`/api/articles?tag=${tag}&page=${page}&limit=${limit}`); 18 | }, 19 | getAllPublishArticles(tag = '', page = 1, limit = 0) { 20 | return Axios.get(`/api/publishArticles?tag=${tag}&page=${page}&limit=${limit}`); 21 | }, 22 | saveArticle(id, article) { 23 | console.log(article); 24 | return Axios.patch('/api/articles/' + id, article); 25 | }, 26 | publishArticle(id) { 27 | return Axios.patch('/api/articles/' + id, { publish: true }); 28 | }, 29 | notPublishArticle(id) { 30 | return Axios.patch('/api/articles/' + id, { publish: false }); 31 | }, 32 | deleteArticle(id) { 33 | return Axios.delete('/api/articles/' + id); 34 | }, 35 | getArticle(id) { 36 | return Axios.get('/api/articles/' + id); 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /client/src/api/login.js: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | export default { 3 | createToken(username, password) { 4 | return Axios.post('/api/token', { username, password }); 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/api/tag.js: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | // 为了让服务端渲染正确请求数据 3 | if (typeof window === 'undefined') { 4 | Axios.defaults.baseURL = 'http://127.0.0.1:8889'; 5 | } 6 | export default { 7 | createTag(name) { 8 | return Axios.post('/api/tags', {name: name}); 9 | }, 10 | getAllTags() { 11 | return Axios.get('/api/tags'); 12 | }, 13 | modifyTag(id, name) { 14 | return Axios.patch('/api/tags/' + id, name); 15 | }, 16 | deleteTag(id) { 17 | return Axios.delete('/api/tags/' + id); 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/components/Loading.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 31 | 32 | 39 | 40 | 145 | -------------------------------------------------------------------------------- /client/src/components/Pagination.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 89 | 90 | 111 | -------------------------------------------------------------------------------- /client/src/lib/debounce.js: -------------------------------------------------------------------------------- 1 | export default function (fn, time) { 2 | let timer; 3 | return function () { 4 | var args = arguments; 5 | clearTimeout(timer); 6 | timer = setTimeout(() => { 7 | fn.apply(this, args); 8 | }, time || 300); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /client/src/lib/marked.js: -------------------------------------------------------------------------------- 1 | import highlight from 'highlight.js'; 2 | import marked from 'marked'; 3 | const languages = ['cpp', 'xml', 'bash', 'coffeescript', 'css', 'markdown', 'http', 'java', 'javascript', 'json', 'less', 'makefile', 'nginx', 'php', 'python', 'scss', 'sql', 'stylus']; 4 | highlight.registerLanguage('cpp', require('highlight.js/lib/languages/cpp')); 5 | highlight.registerLanguage('xml', require('highlight.js/lib/languages/xml')); 6 | highlight.registerLanguage('bash', require('highlight.js/lib/languages/bash')); 7 | highlight.registerLanguage('coffeescript', require('highlight.js/lib/languages/coffeescript')); 8 | highlight.registerLanguage('css', require('highlight.js/lib/languages/css')); 9 | highlight.registerLanguage('markdown', require('highlight.js/lib/languages/markdown')); 10 | highlight.registerLanguage('http', require('highlight.js/lib/languages/http')); 11 | highlight.registerLanguage('java', require('highlight.js/lib/languages/java')); 12 | highlight.registerLanguage('javascript', require('highlight.js/lib/languages/javascript')); 13 | highlight.registerLanguage('json', require('highlight.js/lib/languages/json')); 14 | highlight.registerLanguage('less', require('highlight.js/lib/languages/less')); 15 | highlight.registerLanguage('makefile', require('highlight.js/lib/languages/makefile')); 16 | highlight.registerLanguage('nginx', require('highlight.js/lib/languages/nginx')); 17 | highlight.registerLanguage('php', require('highlight.js/lib/languages/php')); 18 | highlight.registerLanguage('python', require('highlight.js/lib/languages/python')); 19 | highlight.registerLanguage('scss', require('highlight.js/lib/languages/scss')); 20 | highlight.registerLanguage('sql', require('highlight.js/lib/languages/sql')); 21 | highlight.registerLanguage('stylus', require('highlight.js/lib/languages/stylus')); 22 | highlight.configure({ 23 | classPrefix: '', // don't append class prefix 24 | }); 25 | // https://github.com/chjj/marked 26 | marked.setOptions({ 27 | renderer: new marked.Renderer(), 28 | gfm: true, 29 | pedantic: false, 30 | sanitize: false, 31 | tables: true, 32 | breaks: true, 33 | smartLists: true, 34 | smartypants: true, 35 | highlight: function (code, lang) { 36 | if (!lang) { 37 | return; 38 | } 39 | if (!~languages.indexOf(lang)) { 40 | return highlight.highlightAuto(code).value; 41 | } 42 | return highlight.highlight(lang, code).value; 43 | }, 44 | }); 45 | export default marked; 46 | -------------------------------------------------------------------------------- /client/src/lib/throttle.js: -------------------------------------------------------------------------------- 1 | export default function (fn, time) { 2 | var timer; 3 | var firstTime = true; 4 | return function () { 5 | var args = arguments; 6 | if (firstTime) { 7 | fn.apply(this, arguments); 8 | firstTime = false; 9 | return firstTime; 10 | } 11 | if (timer) { 12 | return false; 13 | } 14 | timer = setTimeout(() => { 15 | clearTimeout(timer); 16 | timer = null; 17 | fn.apply(this, args); 18 | }, time || 500); 19 | }; 20 | } -------------------------------------------------------------------------------- /client/src/modules/admin/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 12 | 13 | 26 | -------------------------------------------------------------------------------- /client/src/modules/admin/app.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import Axios from 'axios'; 4 | 5 | import { Message } from 'element-ui'; 6 | import { MessageBox } from 'element-ui'; 7 | 8 | import './assets/stylus/main.styl'; 9 | 10 | import App from './App.vue'; 11 | import store from './store'; 12 | 13 | // 按需引入element-ui相关弹出 14 | Vue.prototype.$msgbox = MessageBox; 15 | Vue.prototype.$alert = MessageBox.alert; 16 | Vue.prototype.$confirm = MessageBox.confirm; 17 | Vue.prototype.$prompt = MessageBox.prompt; 18 | Vue.prototype.$message = (options) => { // 重新定义默认参数 19 | options = Object.assign(options, { duration: 500 }); 20 | return Message(options); 21 | }; 22 | Vue.prototype.$message.error = (err) => { // 重新定义默认参数 23 | var options = { 24 | message: err, 25 | duration: 500, 26 | type: 'error', 27 | }; 28 | return Message(options); 29 | }; 30 | 31 | Vue.use(VueRouter); 32 | 33 | const Login = resolve => require(['./components/Login.vue'], resolve); 34 | const Admin = resolve => require(['./components/Admin.vue'], resolve); 35 | const routes = [ 36 | { path: '/admin/login', component: Login, meta: { authPage: true } }, 37 | { path: '/admin', component: Admin }, { 38 | path: '*', 39 | redirect: '/admin', // 输入其他不存在的地址自动跳回首页 40 | }, 41 | ]; 42 | 43 | const router = new VueRouter({ 44 | mode: 'history', 45 | routes, 46 | }); 47 | 48 | router.beforeEach((to, from, next) => { 49 | // console.log(store.state); 50 | console.log(to); 51 | if (to.meta.authPage) { // login 52 | console.log('login'); 53 | if (store.state.auth.token) { 54 | next('/admin'); 55 | } 56 | next(); 57 | } else { 58 | console.log('admin'); 59 | if (store.state.auth.token) { 60 | Axios.defaults.headers.common['Authorization'] = 'Bearer ' + store.state.auth.token; // 全局设定header的token验证,注意Bearer后有个空格 61 | next(); 62 | } else { 63 | console.log('没有token'); 64 | next('/admin/login'); 65 | } 66 | } 67 | }); 68 | 69 | // axios拦截返回,拦截token过期 70 | Axios.interceptors.response.use(function (response) { 71 | return response; 72 | }, function (error) { 73 | if (error.response.data.error.indexOf('token') !== -1) { 74 | store.commit('DELETE_TOKEN'); 75 | } 76 | return Promise.reject(error); 77 | }); 78 | 79 | new Vue({ 80 | el: '#app', 81 | render: h => h(App), 82 | router, 83 | store, 84 | }); 85 | -------------------------------------------------------------------------------- /client/src/modules/admin/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BUPT-HJM/vue-blog/747306b702fe84d215478403d61f49332e15f829/client/src/modules/admin/assets/logo.png -------------------------------------------------------------------------------- /client/src/modules/admin/assets/stylus/_settings.styl: -------------------------------------------------------------------------------- 1 | // font faces 2 | $body-font = "Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif; 3 | 4 | 5 | // font sizes 6 | $body-font-size = 15px 7 | 8 | // colors 9 | $grey-bg = #efefef 10 | $blue = #0288D1 11 | $orange = #FF8400 12 | $grey-publish = #aab2b3 13 | $login-text = #757575 14 | 15 | // border 16 | $border-radius = 3px 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /client/src/modules/admin/assets/stylus/_syntax.styl: -------------------------------------------------------------------------------- 1 | .gutter pre 2 | color #999 3 | pre 4 | color: #525252 5 | .function .keyword, 6 | .constant 7 | color: #0092db 8 | .keyword, 9 | .attribute 10 | color: #e96900 11 | .number, 12 | .literal 13 | color: #AE81FF 14 | .tag, 15 | .tag .title, 16 | .change, 17 | .winutils, 18 | .flow, 19 | .lisp .title, 20 | .clojure .built_in, 21 | .nginx .title, 22 | .tex .special 23 | color: #2973b7 24 | .class .title 25 | color: #42b983 26 | .symbol, 27 | .symbol .string, 28 | .value, 29 | .regexp 30 | color: $blue 31 | .title 32 | color: #A6E22E 33 | .tag .value, 34 | .string, 35 | .subst, 36 | .haskell .type, 37 | .preprocessor, 38 | .ruby .class .parent, 39 | .built_in, 40 | .sql .aggregate, 41 | .django .template_tag, 42 | .django .variable, 43 | .smalltalk .class, 44 | .javadoc, 45 | .django .filter .argument, 46 | .smalltalk .localvars, 47 | .smalltalk .array, 48 | .attr_selector, 49 | .pseudo, 50 | .addition, 51 | .stream, 52 | .envvar, 53 | .apache .tag, 54 | .apache .cbracket, 55 | .tex .command, 56 | .prompt 57 | color: $blue 58 | .comment, 59 | .java .annotation, 60 | .python .decorator, 61 | .template_comment, 62 | .pi, 63 | .doctype, 64 | .deletion, 65 | .shebang, 66 | .apache .sqbracket, 67 | .tex .formula 68 | color: #b3b3b3 69 | .coffeescript .javascript, 70 | .javascript .xml, 71 | .tex .formula, 72 | .xml .javascript, 73 | .xml .vbscript, 74 | .xml .css, 75 | .xml .cdata 76 | opacity: 0.5 -------------------------------------------------------------------------------- /client/src/modules/admin/assets/stylus/main.styl: -------------------------------------------------------------------------------- 1 | @import "reset" 2 | @import "_settings" 3 | @import "_syntax" 4 | 5 | 6 | 7 | [v-cloak] 8 | display none 9 | 10 | html, body 11 | font-size $body-font-size 12 | input, button 13 | border-radius $border-radius 14 | -------------------------------------------------------------------------------- /client/src/modules/admin/assets/stylus/preview.styl: -------------------------------------------------------------------------------- 1 | @import './_settings.styl' 2 | // modify github markdown css 3 | // @font-face { 4 | // font-family: octicons-link; 5 | // src: url(data:font/woff;charset=utf-8;base64,d09GRgABAAAAAAZwABAAAAAACFQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEU0lHAAAGaAAAAAgAAAAIAAAAAUdTVUIAAAZcAAAACgAAAAoAAQAAT1MvMgAAAyQAAABJAAAAYFYEU3RjbWFwAAADcAAAAEUAAACAAJThvmN2dCAAAATkAAAABAAAAAQAAAAAZnBnbQAAA7gAAACyAAABCUM+8IhnYXNwAAAGTAAAABAAAAAQABoAI2dseWYAAAFsAAABPAAAAZwcEq9taGVhZAAAAsgAAAA0AAAANgh4a91oaGVhAAADCAAAABoAAAAkCA8DRGhtdHgAAAL8AAAADAAAAAwGAACfbG9jYQAAAsAAAAAIAAAACABiATBtYXhwAAACqAAAABgAAAAgAA8ASm5hbWUAAAToAAABQgAAAlXu73sOcG9zdAAABiwAAAAeAAAAME3QpOBwcmVwAAAEbAAAAHYAAAB/aFGpk3jaTY6xa8JAGMW/O62BDi0tJLYQincXEypYIiGJjSgHniQ6umTsUEyLm5BV6NDBP8Tpts6F0v+k/0an2i+itHDw3v2+9+DBKTzsJNnWJNTgHEy4BgG3EMI9DCEDOGEXzDADU5hBKMIgNPZqoD3SilVaXZCER3/I7AtxEJLtzzuZfI+VVkprxTlXShWKb3TBecG11rwoNlmmn1P2WYcJczl32etSpKnziC7lQyWe1smVPy/Lt7Kc+0vWY/gAgIIEqAN9we0pwKXreiMasxvabDQMM4riO+qxM2ogwDGOZTXxwxDiycQIcoYFBLj5K3EIaSctAq2kTYiw+ymhce7vwM9jSqO8JyVd5RH9gyTt2+J/yUmYlIR0s04n6+7Vm1ozezUeLEaUjhaDSuXHwVRgvLJn1tQ7xiuVv/ocTRF42mNgZGBgYGbwZOBiAAFGJBIMAAizAFoAAABiAGIAznjaY2BkYGAA4in8zwXi+W2+MjCzMIDApSwvXzC97Z4Ig8N/BxYGZgcgl52BCSQKAA3jCV8CAABfAAAAAAQAAEB42mNgZGBg4f3vACQZQABIMjKgAmYAKEgBXgAAeNpjYGY6wTiBgZWBg2kmUxoDA4MPhGZMYzBi1AHygVLYQUCaawqDA4PChxhmh/8ODDEsvAwHgMKMIDnGL0x7gJQCAwMAJd4MFwAAAHjaY2BgYGaA4DAGRgYQkAHyGMF8NgYrIM3JIAGVYYDT+AEjAwuDFpBmA9KMDEwMCh9i/v8H8sH0/4dQc1iAmAkALaUKLgAAAHjaTY9LDsIgEIbtgqHUPpDi3gPoBVyRTmTddOmqTXThEXqrob2gQ1FjwpDvfwCBdmdXC5AVKFu3e5MfNFJ29KTQT48Ob9/lqYwOGZxeUelN2U2R6+cArgtCJpauW7UQBqnFkUsjAY/kOU1cP+DAgvxwn1chZDwUbd6CFimGXwzwF6tPbFIcjEl+vvmM/byA48e6tWrKArm4ZJlCbdsrxksL1AwWn/yBSJKpYbq8AXaaTb8AAHja28jAwOC00ZrBeQNDQOWO//sdBBgYGRiYWYAEELEwMTE4uzo5Zzo5b2BxdnFOcALxNjA6b2ByTswC8jYwg0VlNuoCTWAMqNzMzsoK1rEhNqByEyerg5PMJlYuVueETKcd/89uBpnpvIEVomeHLoMsAAe1Id4AAAAAAAB42oWQT07CQBTGv0JBhagk7HQzKxca2sJCE1hDt4QF+9JOS0nbaaYDCQfwCJ7Au3AHj+LO13FMmm6cl7785vven0kBjHCBhfpYuNa5Ph1c0e2Xu3jEvWG7UdPDLZ4N92nOm+EBXuAbHmIMSRMs+4aUEd4Nd3CHD8NdvOLTsA2GL8M9PODbcL+hD7C1xoaHeLJSEao0FEW14ckxC+TU8TxvsY6X0eLPmRhry2WVioLpkrbp84LLQPGI7c6sOiUzpWIWS5GzlSgUzzLBSikOPFTOXqly7rqx0Z1Q5BAIoZBSFihQYQOOBEdkCOgXTOHA07HAGjGWiIjaPZNW13/+lm6S9FT7rLHFJ6fQbkATOG1j2OFMucKJJsxIVfQORl+9Jyda6Sl1dUYhSCm1dyClfoeDve4qMYdLEbfqHf3O/AdDumsjAAB42mNgYoAAZQYjBmyAGYQZmdhL8zLdDEydARfoAqIAAAABAAMABwAKABMAB///AA8AAQAAAAAAAAAAAAAAAAABAAAAAA==) format('woff'); 6 | // } 7 | 8 | .editor-box 9 | .CodeMirror 10 | height: 400px 11 | .editor-preview, 12 | .editor-preview-side 13 | -ms-text-size-adjust: 100%; 14 | -webkit-text-size-adjust: 100%; 15 | line-height: 1.5; 16 | color: #24292e; 17 | font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 18 | font-size: 16px; 19 | line-height: 1.5; 20 | word-wrap: break-word; 21 | background: white; 22 | .pl-c { 23 | color: #969896; 24 | } 25 | 26 | .pl-c1, 27 | .pl-s .pl-v { 28 | color: #0086b3; 29 | } 30 | 31 | .pl-e, 32 | .pl-en { 33 | color: #795da3; 34 | } 35 | 36 | .pl-smi, 37 | .pl-s .pl-s1 { 38 | color: #333; 39 | } 40 | 41 | .pl-ent { 42 | color: #63a35c; 43 | } 44 | 45 | .pl-k { 46 | color: #a71d5d; 47 | } 48 | 49 | .pl-s, 50 | .pl-pds, 51 | .pl-s .pl-pse .pl-s1, 52 | .pl-sr, 53 | .pl-sr .pl-cce, 54 | .pl-sr .pl-sre, 55 | .pl-sr .pl-sra { 56 | color: #183691; 57 | } 58 | 59 | .pl-v, 60 | .pl-smw { 61 | color: #ed6a43; 62 | } 63 | 64 | .pl-bu { 65 | color: #b52a1d; 66 | } 67 | 68 | .pl-ii { 69 | color: #f8f8f8; 70 | background-color: #b52a1d; 71 | } 72 | 73 | .pl-c2 { 74 | color: #f8f8f8; 75 | background-color: #b52a1d; 76 | } 77 | 78 | .pl-c2::before { 79 | content: "^M"; 80 | } 81 | 82 | .pl-sr .pl-cce { 83 | font-weight: bold; 84 | color: #63a35c; 85 | } 86 | 87 | .pl-ml { 88 | color: #693a17; 89 | } 90 | 91 | .pl-mh, 92 | .pl-mh .pl-en, 93 | .pl-ms { 94 | font-weight: bold; 95 | color: #1d3e81; 96 | } 97 | 98 | .pl-mq { 99 | color: #008080; 100 | } 101 | 102 | .pl-mi { 103 | font-style: italic; 104 | color: #333; 105 | } 106 | 107 | .pl-mb { 108 | font-weight: bold; 109 | color: #333; 110 | } 111 | 112 | .pl-md { 113 | color: #bd2c00; 114 | background-color: #ffecec; 115 | } 116 | 117 | .pl-mi1 { 118 | color: #55a532; 119 | background-color: #eaffea; 120 | } 121 | 122 | .pl-mc { 123 | color: #ef9700; 124 | background-color: #ffe3b4; 125 | } 126 | 127 | .pl-mi2 { 128 | color: #d8d8d8; 129 | background-color: #808080; 130 | } 131 | 132 | .pl-mdr { 133 | font-weight: bold; 134 | color: #795da3; 135 | } 136 | 137 | .pl-mo { 138 | color: #1d3e81; 139 | } 140 | 141 | .pl-ba { 142 | color: #595e62; 143 | } 144 | 145 | .pl-sg { 146 | color: #c0c0c0; 147 | } 148 | 149 | .pl-corl { 150 | text-decoration: underline; 151 | color: #183691; 152 | } 153 | 154 | .octicon { 155 | display: inline-block; 156 | vertical-align: text-top; 157 | fill: currentColor; 158 | } 159 | 160 | a { 161 | background-color: transparent; 162 | -webkit-text-decoration-skip: objects; 163 | } 164 | 165 | a:active, 166 | a:hover { 167 | outline-width: 0; 168 | } 169 | 170 | strong { 171 | font-weight: inherit; 172 | } 173 | 174 | strong { 175 | font-weight: bolder; 176 | } 177 | 178 | h1 { 179 | font-size: 2em; 180 | margin: 0.67em 0; 181 | } 182 | 183 | img { 184 | border-style: none; 185 | } 186 | 187 | svg:not(:root) { 188 | overflow: hidden; 189 | } 190 | 191 | code, 192 | kbd, 193 | pre { 194 | font-family: monospace, monospace; 195 | font-size: 1em; 196 | } 197 | 198 | hr { 199 | box-sizing: content-box; 200 | height: 0; 201 | overflow: visible; 202 | } 203 | 204 | input { 205 | font: inherit; 206 | margin: 0; 207 | } 208 | 209 | input { 210 | overflow: visible; 211 | } 212 | 213 | [type="checkbox"] { 214 | box-sizing: border-box; 215 | padding: 0; 216 | } 217 | 218 | * { 219 | box-sizing: border-box; 220 | } 221 | 222 | input { 223 | font-family: inherit; 224 | font-size: inherit; 225 | line-height: inherit; 226 | } 227 | 228 | a { 229 | color: #0366d6; 230 | text-decoration: none; 231 | } 232 | 233 | a:hover { 234 | text-decoration: underline; 235 | } 236 | 237 | strong { 238 | font-weight: 600; 239 | } 240 | 241 | hr { 242 | height: 0; 243 | margin: 15px 0; 244 | overflow: hidden; 245 | background: transparent; 246 | border: 0; 247 | border-bottom: 1px solid #dfe2e5; 248 | } 249 | 250 | hr::before { 251 | display: table; 252 | content: ""; 253 | } 254 | 255 | hr::after { 256 | display: table; 257 | clear: both; 258 | content: ""; 259 | } 260 | 261 | table { 262 | border-spacing: 0; 263 | border-collapse: collapse; 264 | } 265 | 266 | td, 267 | th { 268 | padding: 0; 269 | } 270 | 271 | h1, 272 | h2, 273 | h3, 274 | h4, 275 | h5, 276 | h6 { 277 | margin-top: 0; 278 | margin-bottom: 0; 279 | } 280 | 281 | h1 { 282 | font-size: 32px; 283 | font-weight: 600; 284 | } 285 | 286 | h2 { 287 | font-size: 24px; 288 | font-weight: 600; 289 | } 290 | 291 | h3 { 292 | font-size: 20px; 293 | font-weight: 600; 294 | } 295 | 296 | h4 { 297 | font-size: 16px; 298 | font-weight: 600; 299 | } 300 | 301 | h5 { 302 | font-size: 14px; 303 | font-weight: 600; 304 | } 305 | 306 | h6 { 307 | font-size: 12px; 308 | font-weight: 600; 309 | } 310 | 311 | p { 312 | margin-top: 0; 313 | margin-bottom: 10px; 314 | } 315 | 316 | blockquote { 317 | margin: 0; 318 | } 319 | 320 | ul, 321 | ol { 322 | padding-left: 0; 323 | margin-top: 0; 324 | margin-bottom: 0; 325 | } 326 | 327 | ol ol, 328 | ul ol { 329 | list-style-type: lower-roman; 330 | } 331 | 332 | ul ul ol, 333 | ul ol ol, 334 | ol ul ol, 335 | ol ol ol { 336 | list-style-type: lower-alpha; 337 | } 338 | 339 | dd { 340 | margin-left: 0; 341 | } 342 | 343 | code { 344 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 345 | font-size: 12px; 346 | } 347 | 348 | pre { 349 | margin-top: 0; 350 | margin-bottom: 0; 351 | font: 12px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 352 | } 353 | 354 | .octicon { 355 | vertical-align: text-bottom; 356 | } 357 | 358 | .pl-0 { 359 | padding-left: 0 !important; 360 | } 361 | 362 | .pl-1 { 363 | padding-left: 4px !important; 364 | } 365 | 366 | .pl-2 { 367 | padding-left: 8px !important; 368 | } 369 | 370 | .pl-3 { 371 | padding-left: 16px !important; 372 | } 373 | 374 | .pl-4 { 375 | padding-left: 24px !important; 376 | } 377 | 378 | .pl-5 { 379 | padding-left: 32px !important; 380 | } 381 | 382 | .pl-6 { 383 | padding-left: 40px !important; 384 | } 385 | 386 | .markdown-body::before { 387 | display: table; 388 | content: ""; 389 | } 390 | 391 | .markdown-body::after { 392 | display: table; 393 | clear: both; 394 | content: ""; 395 | } 396 | 397 | .markdown-body>*:first-child { 398 | margin-top: 0 !important; 399 | } 400 | 401 | .markdown-body>*:last-child { 402 | margin-bottom: 0 !important; 403 | } 404 | 405 | a:not([href]) { 406 | color: inherit; 407 | text-decoration: none; 408 | } 409 | 410 | .anchor { 411 | float: left; 412 | padding-right: 4px; 413 | margin-left: -20px; 414 | line-height: 1; 415 | } 416 | 417 | .anchor:focus { 418 | outline: none; 419 | } 420 | 421 | p, 422 | blockquote, 423 | ul, 424 | ol, 425 | dl, 426 | table, 427 | pre { 428 | margin-top: 0; 429 | margin-bottom: 16px; 430 | } 431 | 432 | hr { 433 | height: 0.25em; 434 | padding: 0; 435 | margin: 24px 0; 436 | background-color: #e1e4e8; 437 | border: 0; 438 | } 439 | 440 | blockquote { 441 | padding: 0 1em; 442 | color: #6a737d; 443 | border-left: 0.25em solid #dfe2e5; 444 | } 445 | 446 | blockquote>:first-child { 447 | margin-top: 0; 448 | } 449 | 450 | blockquote>:last-child { 451 | margin-bottom: 0; 452 | } 453 | 454 | kbd { 455 | display: inline-block; 456 | padding: 3px 5px; 457 | font-size: 11px; 458 | line-height: 10px; 459 | color: #444d56; 460 | vertical-align: middle; 461 | background-color: #fafbfc; 462 | border: solid 1px #c6cbd1; 463 | border-bottom-color: #959da5; 464 | border-radius: 3px; 465 | box-shadow: inset 0 -1px 0 #959da5; 466 | } 467 | 468 | h1, 469 | h2, 470 | h3, 471 | h4, 472 | h5, 473 | h6 { 474 | margin-top: 24px; 475 | margin-bottom: 16px; 476 | font-weight: 600; 477 | line-height: 1.25; 478 | } 479 | 480 | h1 .octicon-link, 481 | h2 .octicon-link, 482 | h3 .octicon-link, 483 | h4 .octicon-link, 484 | h5 .octicon-link, 485 | h6 .octicon-link { 486 | color: #1b1f23; 487 | vertical-align: middle; 488 | visibility: hidden; 489 | } 490 | 491 | h1:hover .anchor, 492 | h2:hover .anchor, 493 | h3:hover .anchor, 494 | h4:hover .anchor, 495 | h5:hover .anchor, 496 | h6:hover .anchor { 497 | text-decoration: none; 498 | } 499 | 500 | h1:hover .anchor .octicon-link, 501 | h2:hover .anchor .octicon-link, 502 | h3:hover .anchor .octicon-link, 503 | h4:hover .anchor .octicon-link, 504 | h5:hover .anchor .octicon-link, 505 | h6:hover .anchor .octicon-link { 506 | visibility: visible; 507 | } 508 | 509 | h1 { 510 | padding-bottom: 0.3em; 511 | font-size: 2em; 512 | border-bottom: 1px solid #eaecef; 513 | } 514 | 515 | h2 { 516 | padding-bottom: 0.3em; 517 | font-size: 1.5em; 518 | border-bottom: 1px solid #eaecef; 519 | } 520 | 521 | h3 { 522 | font-size: 1.25em; 523 | } 524 | 525 | h4 { 526 | font-size: 1em; 527 | } 528 | 529 | h5 { 530 | font-size: 0.875em; 531 | } 532 | 533 | h6 { 534 | font-size: 0.85em; 535 | color: #6a737d; 536 | } 537 | 538 | ul, 539 | ol { 540 | padding-left: 2em; 541 | } 542 | 543 | ul ul, 544 | ul ol, 545 | ol ol, 546 | ol ul { 547 | margin-top: 0; 548 | margin-bottom: 0; 549 | } 550 | 551 | li>p { 552 | margin-top: 16px; 553 | } 554 | 555 | li+li { 556 | margin-top: 0.25em; 557 | } 558 | 559 | dl { 560 | padding: 0; 561 | } 562 | 563 | dl dt { 564 | padding: 0; 565 | margin-top: 16px; 566 | font-size: 1em; 567 | font-style: italic; 568 | font-weight: 600; 569 | } 570 | 571 | dl dd { 572 | padding: 0 16px; 573 | margin-bottom: 16px; 574 | } 575 | 576 | table { 577 | display: block; 578 | width: 100%; 579 | overflow: auto; 580 | } 581 | 582 | table th { 583 | font-weight: 600; 584 | } 585 | 586 | table th, 587 | table td { 588 | padding: 6px 13px; 589 | border: 1px solid #dfe2e5; 590 | } 591 | 592 | table tr { 593 | background-color: #fff; 594 | border-top: 1px solid #c6cbd1; 595 | } 596 | 597 | table tr:nth-child(2n) { 598 | background-color: #f6f8fa; 599 | } 600 | 601 | img { 602 | max-width: 100%; 603 | box-sizing: content-box; 604 | background-color: #fff; 605 | } 606 | 607 | code { 608 | padding: 0; 609 | padding-top: 0.2em; 610 | padding-bottom: 0.2em; 611 | margin: 0; 612 | font-size: 85%; 613 | background-color: rgba(27,31,35,0.05); 614 | border-radius: 3px; 615 | } 616 | 617 | code::before, 618 | code::after { 619 | letter-spacing: -0.2em; 620 | content: "\00a0"; 621 | } 622 | 623 | pre { 624 | word-wrap: normal; 625 | } 626 | 627 | pre>code { 628 | padding: 0; 629 | margin: 0; 630 | font-size: 100%; 631 | word-break: normal; 632 | white-space: pre; 633 | background: transparent; 634 | border: 0; 635 | } 636 | 637 | .highlight { 638 | margin-bottom: 16px; 639 | } 640 | 641 | .highlight pre { 642 | margin-bottom: 0; 643 | word-break: normal; 644 | } 645 | 646 | .highlight pre, 647 | pre { 648 | padding: 16px; 649 | overflow: auto; 650 | font-size: 85%; 651 | line-height: 1.45; 652 | background-color: #f6f8fa; 653 | border-radius: 3px; 654 | } 655 | 656 | pre code { 657 | display: inline; 658 | max-width: auto; 659 | padding: 0; 660 | margin: 0; 661 | overflow: visible; 662 | line-height: inherit; 663 | word-wrap: normal; 664 | background-color: transparent; 665 | border: 0; 666 | } 667 | 668 | pre code::before, 669 | pre code::after { 670 | content: normal; 671 | } 672 | 673 | .full-commit .btn-outline:not(:disabled):hover { 674 | color: #005cc5; 675 | border-color: #005cc5; 676 | } 677 | 678 | kbd { 679 | display: inline-block; 680 | padding: 3px 5px; 681 | font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 682 | line-height: 10px; 683 | color: #444d56; 684 | vertical-align: middle; 685 | background-color: #fcfcfc; 686 | border: solid 1px #c6cbd1; 687 | border-bottom-color: #959da5; 688 | border-radius: 3px; 689 | box-shadow: inset 0 -1px 0 #959da5; 690 | } 691 | 692 | :checked+.radio-label { 693 | position: relative; 694 | z-index: 1; 695 | border-color: #0366d6; 696 | } 697 | 698 | .task-list-item { 699 | list-style-type: none; 700 | } 701 | 702 | .task-list-item+.task-list-item { 703 | margin-top: 3px; 704 | } 705 | 706 | .task-list-item input { 707 | margin: 0 0.2em 0.25em -1.6em; 708 | vertical-align: middle; 709 | } 710 | 711 | hr { 712 | border-bottom-color: #eee; 713 | } -------------------------------------------------------------------------------- /client/src/modules/admin/assets/stylus/reset.styl: -------------------------------------------------------------------------------- 1 | /*! 2 | * ress.css • v1.1.2 3 | * MIT License 4 | * github.com/filipelinhares/ress 5 | */ 6 | 7 | /* # ================================================================= 8 | # Global selectors 9 | # ================================================================= */ 10 | 11 | html { 12 | box-sizing: border-box; 13 | //overflow-y: scroll; /* All browsers without overlaying scrollbars */ 14 | -webkit-text-size-adjust: 100%; /* iOS 8+ */ 15 | } 16 | 17 | *, 18 | ::before, 19 | ::after { 20 | box-sizing: inherit; 21 | } 22 | 23 | ::before, 24 | ::after { 25 | text-decoration: inherit; /* Inherit text-decoration and vertical align to ::before and ::after pseudo elements */ 26 | vertical-align: inherit; 27 | } 28 | 29 | /* Remove margin, padding of all elements and set background-no-repeat as default */ 30 | * { 31 | background-repeat: no-repeat; /* Set `background-repeat: no-repeat` to all elements */ 32 | padding: 0; /* Reset `padding` and `margin` of all elements */ 33 | margin: 0; 34 | } 35 | 36 | /* # ================================================================= 37 | # General elements 38 | # ================================================================= */ 39 | 40 | /* Add the correct display in iOS 4-7.*/ 41 | audio:not([controls]) { 42 | display: none; 43 | height: 0; 44 | } 45 | 46 | hr { 47 | overflow: visible; /* Show the overflow in Edge and IE */ 48 | } 49 | 50 | /* 51 | * Correct `block` display not defined for any HTML5 element in IE 8/9 52 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 53 | * and Firefox 54 | * Correct `block` display not defined for `main` in IE 11 55 | */ 56 | article, 57 | aside, 58 | details, 59 | figcaption, 60 | figure, 61 | footer, 62 | header, 63 | main, 64 | menu, 65 | nav, 66 | section, 67 | summary { 68 | display: block; 69 | } 70 | 71 | summary { 72 | display: list-item; /* Add the correct display in all browsers */ 73 | } 74 | 75 | small { 76 | font-size: 80%; /* Set font-size to 80% in `small` elements */ 77 | } 78 | 79 | [hidden], 80 | template { 81 | display: none; /* Add the correct display in IE */ 82 | } 83 | 84 | abbr[title] { 85 | border-bottom: 1px dotted; /* Add a bordered underline effect in all browsers */ 86 | text-decoration: none; /* Remove text decoration in Firefox 40+ */ 87 | } 88 | 89 | a { 90 | background-color: transparent; /* Remove the gray background on active links in IE 10 */ 91 | -webkit-text-decoration-skip: objects; /* Remove gaps in links underline in iOS 8+ and Safari 8+ */ 92 | } 93 | 94 | a:active, 95 | a:hover { 96 | outline-width: 0; /* Remove the outline when hovering in all browsers */ 97 | } 98 | 99 | code, 100 | kbd, 101 | pre, 102 | samp { 103 | font-family: monospace, monospace; /* Specify the font family of code elements */ 104 | } 105 | 106 | b, 107 | strong { 108 | font-weight: bolder; /* Correct style set to `bold` in Edge 12+, Safari 6.2+, and Chrome 18+ */ 109 | } 110 | 111 | dfn { 112 | font-style: italic; /* Address styling not present in Safari and Chrome */ 113 | } 114 | 115 | /* Address styling not present in IE 8/9 */ 116 | mark { 117 | background-color: #ff0; 118 | color: #000; 119 | } 120 | 121 | /* https://gist.github.com/unruthless/413930 */ 122 | sub, 123 | sup { 124 | font-size: 75%; 125 | line-height: 0; 126 | position: relative; 127 | vertical-align: baseline; 128 | } 129 | 130 | sub { 131 | bottom: -0.25em; 132 | } 133 | 134 | sup { 135 | top: -0.5em; 136 | } 137 | 138 | /* # ================================================================= 139 | # Forms 140 | # ================================================================= */ 141 | 142 | input { 143 | border-radius: 0; 144 | } 145 | 146 | /* Apply cursor pointer to button elements */ 147 | button, 148 | [type="button"], 149 | [type="reset"], 150 | [type="submit"], 151 | [role="button"] { 152 | cursor: pointer; 153 | } 154 | 155 | /* Replace pointer cursor in disabled elements */ 156 | [disabled] { 157 | cursor: default; 158 | } 159 | 160 | [type="number"] { 161 | width: auto; /* Firefox 36+ */ 162 | } 163 | 164 | [type="search"] { 165 | -webkit-appearance: textfield; /* Safari 8+ */ 166 | } 167 | 168 | [type="search"]::-webkit-search-cancel-button, 169 | [type="search"]::-webkit-search-decoration { 170 | -webkit-appearance: none; /* Safari 8 */ 171 | } 172 | 173 | textarea { 174 | overflow: auto; /* Internet Explorer 11+ */ 175 | resize: vertical; /* Specify textarea resizability */ 176 | } 177 | 178 | button, 179 | input, 180 | optgroup, 181 | select, 182 | textarea { 183 | font: inherit; /* Specify font inheritance of form elements */ 184 | } 185 | 186 | optgroup { 187 | font-weight: bold; /* Restore the font weight unset by the previous rule. */ 188 | } 189 | 190 | button { 191 | overflow: visible; /* Address `overflow` set to `hidden` in IE 8/9/10/11 */ 192 | } 193 | 194 | /* Remove inner padding and border in Firefox 4+ */ 195 | button::-moz-focus-inner, 196 | [type="button"]::-moz-focus-inner, 197 | [type="reset"]::-moz-focus-inner, 198 | [type="submit"]::-moz-focus-inner { 199 | border-style: 0; 200 | padding: 0; 201 | } 202 | 203 | /* Replace focus style removed in the border reset above */ 204 | button:-moz-focusring, 205 | [type="button"]::-moz-focus-inner, 206 | [type="reset"]::-moz-focus-inner, 207 | [type="submit"]::-moz-focus-inner { 208 | outline: 1px dotted ButtonText; 209 | } 210 | 211 | button, 212 | html [type="button"], /* Prevent a WebKit bug where (2) destroys native `audio` and `video`controls in Android 4 */ 213 | [type="reset"], 214 | [type="submit"] { 215 | -webkit-appearance: button; /* Correct the inability to style clickable types in iOS */ 216 | } 217 | 218 | button, 219 | select { 220 | text-transform: none; /* Firefox 40+, Internet Explorer 11- */ 221 | } 222 | 223 | /* Remove the default button styling in all browsers */ 224 | button, 225 | input, 226 | select, 227 | textarea { 228 | background-color: transparent; 229 | border-style: none; 230 | color: inherit; 231 | } 232 | 233 | /* Style select like a standard input */ 234 | select { 235 | -moz-appearance: none; /* Firefox 36+ */ 236 | -webkit-appearance: none; /* Chrome 41+ */ 237 | } 238 | 239 | select::-ms-expand { 240 | display: none; /* Internet Explorer 11+ */ 241 | } 242 | 243 | select::-ms-value { 244 | color: currentColor; /* Internet Explorer 11+ */ 245 | } 246 | 247 | legend { 248 | border: 0; /* Correct `color` not being inherited in IE 8/9/10/11 */ 249 | color: inherit; /* Correct the color inheritance from `fieldset` elements in IE */ 250 | display: table; /* Correct the text wrapping in Edge and IE */ 251 | max-width: 100%; /* Correct the text wrapping in Edge and IE */ 252 | white-space: normal; /* Correct the text wrapping in Edge and IE */ 253 | } 254 | 255 | ::-webkit-file-upload-button { 256 | -webkit-appearance: button; /* Correct the inability to style clickable types in iOS and Safari */ 257 | font: inherit; /* Change font properties to `inherit` in Chrome and Safari */ 258 | } 259 | 260 | [type="search"] { 261 | -webkit-appearance: textfield; /* Correct the odd appearance in Chrome and Safari */ 262 | outline-offset: -2px; /* Correct the outline style in Safari */ 263 | } 264 | 265 | /* # ================================================================= 266 | # Specify media element style 267 | # ================================================================= */ 268 | 269 | img { 270 | border-style: none; /* Remove border when inside `a` element in IE 8/9/10 */ 271 | } 272 | 273 | /* Add the correct vertical alignment in Chrome, Firefox, and Opera */ 274 | progress { 275 | vertical-align: baseline; 276 | } 277 | 278 | svg:not(:root) { 279 | overflow: hidden; /* Internet Explorer 11- */ 280 | } 281 | 282 | audio, 283 | canvas, 284 | progress, 285 | video { 286 | display: inline-block; /* Internet Explorer 11+, Windows Phone 8.1+ */ 287 | } 288 | 289 | /* # ================================================================= 290 | # Accessibility 291 | # ================================================================= */ 292 | 293 | /* Hide content from screens but not screenreaders */ 294 | @media screen { 295 | [hidden~="screen"] { 296 | display: inherit; 297 | } 298 | [hidden~="screen"]:not(:active):not(:focus):not(:target) { 299 | position: absolute !important; 300 | clip: rect(0 0 0 0) !important; 301 | } 302 | } 303 | 304 | /* Specify the progress cursor of updating elements */ 305 | [aria-busy="true"] { 306 | cursor: progress; 307 | } 308 | 309 | /* Specify the pointer cursor of trigger elements */ 310 | [aria-controls] { 311 | cursor: pointer; 312 | } 313 | 314 | /* Specify the unstyled cursor of disabled, not-editable, or otherwise inoperable elements */ 315 | [aria-disabled] { 316 | cursor: default; 317 | } 318 | 319 | /* # ================================================================= 320 | # Selection 321 | # ================================================================= */ 322 | 323 | /* Specify text selection background color and omit drop shadow */ 324 | 325 | ::-moz-selection { 326 | background-color: #b3d4fc; /* Required when declaring ::selection */ 327 | color: #000; 328 | text-shadow: none; 329 | } 330 | 331 | ::selection { 332 | background-color: #b3d4fc; /* Required when declaring ::selection */ 333 | color: #000; 334 | text-shadow: none; 335 | } 336 | -------------------------------------------------------------------------------- /client/src/modules/admin/components/Admin.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 54 | 55 | 76 | -------------------------------------------------------------------------------- /client/src/modules/admin/components/Editor.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 298 | 299 | 302 | 344 | -------------------------------------------------------------------------------- /client/src/modules/admin/components/List.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | 177 | 178 | 179 | 249 | -------------------------------------------------------------------------------- /client/src/modules/admin/components/Login.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 51 | 52 | 53 | 79 | -------------------------------------------------------------------------------- /client/src/modules/admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-blog-admin 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/src/modules/admin/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import auth from './modules/auth'; 4 | import editor from './modules/editor'; 5 | 6 | Vue.use(Vuex); 7 | 8 | const debug = process.env.NODE_ENV !== 'production'; 9 | 10 | export default new Vuex.Store({ 11 | modules: { 12 | auth, 13 | editor, 14 | }, 15 | strict: debug, 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/modules/admin/store/modules/auth.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types'; 2 | import api from '../../../../api/login.js'; 3 | 4 | const state = { 5 | token: sessionStorage.getItem('vue-blog-token'), 6 | }; 7 | // actions 8 | const actions = { 9 | createToken({ commit, state }, { username, password }) { 10 | return api.createToken(username, password).then(res => { 11 | if (res.data.success) { 12 | console.log(res.data.token); 13 | commit(types.CREATE_TOKEN, res.data.token); 14 | } else { 15 | commit(types.DELETE_TOKEN); 16 | } 17 | return new Promise((resolve, reject) => { 18 | resolve(res); 19 | }); 20 | }); 21 | }, 22 | }; 23 | 24 | const mutations = { 25 | [types.CREATE_TOKEN](state, token) { 26 | state.token = token; 27 | sessionStorage.setItem('vue-blog-token', token); 28 | }, 29 | [types.DELETE_TOKEN](state) { 30 | state.token = null; 31 | sessionStorage.setItem('vue-blog-token', ''); 32 | }, 33 | }; 34 | export default { 35 | state, 36 | actions, 37 | mutations, 38 | }; 39 | -------------------------------------------------------------------------------- /client/src/modules/admin/store/modules/editor.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types'; 2 | import api from '../../../../api/article.js'; 3 | import tagApi from '../../../../api/tag.js'; 4 | 5 | const state = { 6 | articleList: [], 7 | tagList: [], 8 | currentArticle: { 9 | id: -1, 10 | index: -1, 11 | content: '', 12 | title: '', 13 | tags: [], 14 | save: true, 15 | publish: false, 16 | }, 17 | allPage: 1, 18 | curPage: 1, 19 | selectTagArr: [], 20 | }; 21 | // getters 22 | const getters = { 23 | articleList: state => state.articleList, 24 | tagList: state => state.tagList, 25 | currentArticle: state => state.currentArticle, 26 | allPage: state => state.allPage, 27 | curPage: state => state.curPage, 28 | selectTagArr: state => state.selectTagArr, 29 | }; 30 | // actions 31 | const actions = { 32 | createArticle({ commit, state }, { title, content, publish, tags }) { 33 | return api.createArticle(title, content, publish, tags).then(res => { 34 | console.log(res.data); 35 | if (res.data.success) { 36 | const article = { 37 | save: true, 38 | }; 39 | } 40 | return new Promise((resolve, reject) => { 41 | resolve(res); 42 | }); 43 | }).catch(err => { 44 | console.log(err); 45 | return new Promise((resolve, reject) => { 46 | reject(err); 47 | }); 48 | }); 49 | }, 50 | getAllArticles({ commit, state, dispatch }, { tag = '', page = 1, limit = 0 } = {}) { 51 | return api.getAllArticles(tag, page, limit).then(res => { 52 | if (res.data.success) { 53 | commit(types.GET_ALL_ARTICLES, { articleList: res.data.articleArr, allPage: res.data.allPage, curPage: page }); 54 | dispatch('getCurrentArticle', 0); 55 | } 56 | return new Promise((resolve, reject) => { 57 | resolve(res); 58 | }); 59 | }).catch(err => { 60 | console.log(err); 61 | return new Promise((resolve, reject) => { 62 | reject(err); 63 | }); 64 | }); 65 | }, 66 | getCurrentArticle({ commit, state }, index) { 67 | let article; 68 | console.log('currentIndex:', index); 69 | if (state.articleList.length === 0 || index === -1) { 70 | article = { 71 | id: -1, 72 | index: -1, 73 | title: '', 74 | content: '', 75 | save: true, 76 | publish: false, 77 | tags: [], 78 | }; 79 | } else { 80 | article = { 81 | id: state.articleList[index].id, 82 | index: index, 83 | title: state.articleList[index].title, 84 | content: state.articleList[index].content, 85 | save: true, 86 | publish: state.articleList[index].publish, 87 | tags: state.articleList[index].tags, 88 | }; 89 | } 90 | commit(types.GET_CURRENT_ARTICLE, article); 91 | }, 92 | changeArticle({ commit, state }) { 93 | commit(types.CHANGE_ARTICLE); 94 | }, 95 | saveArticle({ commit, state }, { id, article }) { 96 | return api.saveArticle(id, article).then(res => { 97 | if (res.data.success) { 98 | commit(types.SAVE_ARTICLE, { id, article }); 99 | } 100 | return new Promise((resolve, reject) => { 101 | resolve(res); 102 | }); 103 | }); 104 | }, 105 | publishArticle({ commit, state }, { id }) { 106 | return api.publishArticle(id).then(res => { 107 | if (res.data.success) { 108 | commit(types.PUBLISH_ARTICLE, id); 109 | } 110 | return new Promise((resolve, reject) => { 111 | resolve(res); 112 | }); 113 | }); 114 | }, 115 | notPublishArticle({ commit, state }, { id }) { 116 | return api.notPublishArticle(id).then(res => { 117 | if (res.data.success) { 118 | commit(types.NOT_PUBLISH_ARTICLE, id); 119 | } 120 | return new Promise((resolve, reject) => { 121 | resolve(res); 122 | }); 123 | }); 124 | }, 125 | deleteArticle({ commit, state }, { id, index }) { 126 | return api.deleteArticle(id).then(res => { 127 | if (res.data.success) { 128 | if (state.articleList.length <= 1) { 129 | let article = { 130 | id: -1, 131 | index: 0, 132 | title: '', 133 | content: '', 134 | save: false, 135 | publish: false, 136 | }; 137 | commit(types.GET_CURRENT_ARTICLE, article); 138 | } 139 | } 140 | return new Promise((resolve, reject) => { 141 | resolve(res); 142 | }); 143 | }); 144 | }, 145 | createTag({ commit, state }, { name }) { 146 | return tagApi.createTag(name).then(res => { 147 | if (res.data.success) { 148 | commit(types.CREATE_TAG, res.data.tag); 149 | } 150 | return new Promise((resolve, reject) => { 151 | resolve(res); 152 | }); 153 | }); 154 | }, 155 | getAllTags({ commit, state }) { 156 | return tagApi.getAllTags().then(res => { 157 | if (res.data.success) { 158 | commit(types.GET_ALL_TAGS, res.data.tagArr); 159 | } 160 | return new Promise((resolve, reject) => { 161 | resolve(res); 162 | }); 163 | }); 164 | }, 165 | modifyTag({ commit, state }, { id, name }) { 166 | return tagApi.modifyTag(id, name).then(res => { 167 | if (res.data.success) { 168 | commit(types.MODIFY_TAG, { id, name }); 169 | } 170 | return new Promise((resolve, reject) => { 171 | resolve(res); 172 | }); 173 | }); 174 | }, 175 | deleteTag({ commit, state }, { id }) { 176 | return tagApi.deleteTag(id).then(res => { 177 | if (res.data.success) { 178 | commit(types.DELETE_TAG, id); 179 | } 180 | return new Promise((resolve, reject) => { 181 | resolve(res); 182 | }); 183 | }); 184 | }, 185 | deleteCurrentTag({ commit, state }, { index }) { 186 | commit(types.DELETE_CURRENT_TAG, index); 187 | return new Promise((resolve, reject) => { 188 | resolve(); 189 | }); 190 | }, 191 | }; 192 | 193 | const mutations = { 194 | [types.CREATE_ARTICLE](state, article) { 195 | state.articleList.unshift(article); 196 | state.currentArticle = article; 197 | }, 198 | [types.SAVE_ARTICLE](state, { id, article }) { 199 | state.currentArticle.save = true; 200 | let now = state.articleList.find(p => p.id === id); 201 | if (now) { 202 | now.title = article.title; 203 | now.content = article.content; 204 | now.abstract = article.abstract; 205 | now.tags = article.tags; 206 | now.lastEditTime = article.lastEditTime; 207 | } 208 | }, 209 | [types.PUBLISH_ARTICLE](state) { 210 | state.currentArticle.publish = true; 211 | }, 212 | [types.GET_ALL_ARTICLES](state, { articleList, allPage, curPage }) { 213 | state.articleList = articleList; 214 | state.allPage = allPage; 215 | state.curPage = curPage; 216 | }, 217 | [types.GET_CURRENT_ARTICLE](state, article) { 218 | state.currentArticle = article; 219 | }, 220 | [types.CHANGE_ARTICLE](state) { 221 | state.currentArticle.save = false; 222 | }, 223 | [types.PUBLISH_ARTICLE](state, id) { 224 | state.currentArticle.publish = true; 225 | state.articleList.find(p => p.id === id).publish = true; 226 | }, 227 | [types.NOT_PUBLISH_ARTICLE](state, id) { 228 | state.currentArticle.publish = false; 229 | state.articleList.find(p => p.id === id).publish = false; 230 | }, 231 | [types.DELETE_ARTICLE](state, index) { 232 | state.articleList.splice(index, 1); 233 | if (state.articleList.length === 0) { 234 | return; 235 | } 236 | if (index > state.articleList.length - 1) { 237 | index = state.articleList.length - 1; 238 | } 239 | state.currentArticle = state.articleList[index]; 240 | state.currentArticle.index = index; 241 | state.currentArticle.save = true; 242 | }, 243 | [types.CREATE_TAG](state, tag) { 244 | state.currentArticle.tags.push(tag); 245 | }, 246 | [types.MODIFY_TAG](state, name) { 247 | state.currentArticle.tags.push(name); 248 | 249 | }, 250 | [types.DELETE_TAG](state, id) { 251 | state.tagList = state.tagList.filter((e) => { 252 | return e.id !== id; 253 | }); 254 | state.currentArticle.tags = state.currentArticle.tags.filter((e) => { 255 | return e.id !== id; 256 | }); 257 | state.selectTagArr = state.selectTagArr.filter((e) => { 258 | return e !== id; 259 | }); 260 | 261 | }, 262 | [types.DELETE_CURRENT_TAG](state, index) { 263 | state.currentArticle.tags.splice(index, 1); 264 | }, 265 | [types.GET_ALL_TAGS](state, tagList) { 266 | state.tagList = tagList; 267 | }, 268 | [types.SET_ALL_PAGE](state, allPage) { 269 | state.allPage = allPage; 270 | }, 271 | [types.SET_CUR_PAGE](state, curPage) { 272 | state.curPage = curPage; 273 | }, 274 | [types.TOGGLE_SELECT_TAG](state, id) { 275 | if (!state.selectTagArr.includes(id)) { 276 | state.selectTagArr.push(id); 277 | } else { 278 | state.selectTagArr = state.selectTagArr.filter((e) => { 279 | return e !== id; 280 | }); 281 | } 282 | }, 283 | [types.CLEAR_SELECT_TAG](state) { 284 | state.selectTagArr = []; 285 | }, 286 | }; 287 | export default { 288 | state, 289 | getters, 290 | actions, 291 | mutations, 292 | }; 293 | -------------------------------------------------------------------------------- /client/src/modules/admin/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | // auth 2 | export const CREATE_TOKEN = 'CREATE_TOKEN'; 3 | export const DELETE_TOKEN = 'DELETE_TOKEN'; 4 | // edit 5 | export const CREATE_ARTICLE = 'CREATE_ARITCLE'; 6 | export const SAVE_ARTICLE = 'SAVE_ARITCLE'; 7 | export const CHANGE_ARTICLE = 'CHANGE_ARITCLE'; 8 | export const PUBLISH_ARTICLE = 'PUBLISH_ARTICLE'; 9 | export const NOT_PUBLISH_ARTICLE = 'NOT_PUBLISH_ARTICLE'; 10 | export const GET_ALL_ARTICLES = 'GET_ALL_ARTICLES'; 11 | export const GET_CURRENT_ARTICLE = 'GET_CURRENT_ARTICLE'; 12 | export const DELETE_ARTICLE = 'DELETE_ARTICLE'; 13 | // tag 14 | export const CREATE_TAG = 'CREATE_TAG'; 15 | export const MODIFY_TAG = 'MODIFY_TAG'; 16 | export const DELETE_TAG = 'DELETE_TAG'; 17 | export const DELETE_CURRENT_TAG = 'DELETE_CURRENT_TAG'; 18 | export const GET_ALL_TAGS = 'GET_ALL_TAGS'; 19 | export const TOGGLE_SELECT_TAG = 'TOGGLE_SELECT_TAG'; 20 | export const CLEAR_SELECT_TAG = 'CLEAR_SELECT_TAG'; 21 | // page 22 | export const GET_ALL_PAGE = 'GET_ALL_PAGE'; 23 | export const GET_CUR_PAGE = 'GET_CUR_PAGE'; 24 | -------------------------------------------------------------------------------- /client/src/modules/front/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 32 | 33 | 72 | 77 | -------------------------------------------------------------------------------- /client/src/modules/front/app.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Axios from 'axios'; 3 | import { createStore } from './store'; 4 | import { createRouter } from './router'; 5 | import { sync } from 'vuex-router-sync'; 6 | 7 | import App from './App.vue'; 8 | 9 | // 解决移动端300ms延迟问题 10 | if (typeof window !== 'undefined') { 11 | const Fastclick = require('fastclick'); 12 | Fastclick.attach(document.body); 13 | } 14 | 15 | // 每次服务端请求渲染时会重新createApp,初始化这些store、router 16 | // 不然会出现数据还是原来的数据没有变化的问题 17 | export function createApp(ssrContext) { 18 | const store = createStore(); 19 | const router = createRouter(); 20 | 21 | sync(store, router); 22 | 23 | if (typeof window !== 'undefined') { 24 | router.beforeEach((to, from, next) => { 25 | if (to.path === '/' && store.state.sideBoxOpen) { 26 | store.commit('CLOSE_SIDEBOX'); 27 | setTimeout(function () { 28 | next(); 29 | }, 100); 30 | } else { 31 | next(); 32 | } 33 | }); 34 | } 35 | 36 | const app = new Vue({ 37 | router, 38 | store, 39 | ssrContext, 40 | render: h => h(App), 41 | }); 42 | 43 | return { app, router, store }; 44 | } 45 | -------------------------------------------------------------------------------- /client/src/modules/front/assets/iconfont/iconfont.css: -------------------------------------------------------------------------------- 1 | 2 | @font-face {font-family: "iconfont"; 3 | src: url('iconfont.eot?t=1492408788915'); /* IE9*/ 4 | src: url('iconfont.eot?t=1492408788915#iefix') format('embedded-opentype'), /* IE6-IE8 */ 5 | url('iconfont.woff?t=1492408788915') format('woff'), /* chrome, firefox */ 6 | url('iconfont.ttf?t=1492408788915') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/ 7 | url('iconfont.svg?t=1492408788915#iconfont') format('svg'); /* iOS 4.1- */ 8 | } 9 | 10 | .iconfont { 11 | font-family:"iconfont" !important; 12 | font-size:16px; 13 | font-style:normal; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | .icon-github:before { content: "\e6ed"; } 19 | 20 | -------------------------------------------------------------------------------- /client/src/modules/front/assets/iconfont/iconfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BUPT-HJM/vue-blog/747306b702fe84d215478403d61f49332e15f829/client/src/modules/front/assets/iconfont/iconfont.eot -------------------------------------------------------------------------------- /client/src/modules/front/assets/iconfont/iconfont.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Created by FontForge 20120731 at Mon Apr 17 13:59:48 2017 6 | By admin 7 | 8 | 9 | 10 | 24 | 26 | 28 | 30 | 32 | 34 | 38 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/src/modules/front/assets/iconfont/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BUPT-HJM/vue-blog/747306b702fe84d215478403d61f49332e15f829/client/src/modules/front/assets/iconfont/iconfont.ttf -------------------------------------------------------------------------------- /client/src/modules/front/assets/iconfont/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BUPT-HJM/vue-blog/747306b702fe84d215478403d61f49332e15f829/client/src/modules/front/assets/iconfont/iconfont.woff -------------------------------------------------------------------------------- /client/src/modules/front/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BUPT-HJM/vue-blog/747306b702fe84d215478403d61f49332e15f829/client/src/modules/front/assets/logo.png -------------------------------------------------------------------------------- /client/src/modules/front/assets/stylus/_settings.styl: -------------------------------------------------------------------------------- 1 | // font faces 2 | $body-font = "Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif; 3 | //$body-font = Source Sans Pro,Helvetica Neue,Arial,sans-serif; 4 | 5 | // font sizes 6 | $body-font-size = 15px 7 | 8 | // colors 9 | $grey-bg = #efefef 10 | $blue = #0288D1 11 | $blue-link = #868686 12 | $orange = #FF8400 13 | $grey-publish = #aab2b3 14 | $login-text = #757575 15 | $grey = #bfbfbf 16 | $grey-dark = #808080 17 | 18 | // border 19 | $border-radius = 3px 20 | 21 | -------------------------------------------------------------------------------- /client/src/modules/front/assets/stylus/_syntax.styl: -------------------------------------------------------------------------------- 1 | .gutter pre 2 | color #999 3 | 4 | pre 5 | color: #525252 6 | .function .keyword, 7 | .constant 8 | color: #0092db 9 | .keyword, 10 | .attribute 11 | color: #e96900 12 | .number, 13 | .literal 14 | color: #AE81FF 15 | .tag, 16 | .tag .title, 17 | .change, 18 | .winutils, 19 | .flow, 20 | .lisp .title, 21 | .clojure .built_in, 22 | .nginx .title, 23 | .tex .special 24 | color: #2973b7 25 | .class .title 26 | color: #42b983 27 | .symbol, 28 | .symbol .string, 29 | .value, 30 | .regexp 31 | color: $dark-blue 32 | .title 33 | color: #A6E22E 34 | .tag .value, 35 | .string, 36 | .subst, 37 | .haskell .type, 38 | .preprocessor, 39 | .ruby .class .parent, 40 | .built_in, 41 | .sql .aggregate, 42 | .django .template_tag, 43 | .django .variable, 44 | .smalltalk .class, 45 | .javadoc, 46 | .django .filter .argument, 47 | .smalltalk .localvars, 48 | .smalltalk .array, 49 | .attr_selector, 50 | .pseudo, 51 | .addition, 52 | .stream, 53 | .envvar, 54 | .apache .tag, 55 | .apache .cbracket, 56 | .tex .command, 57 | .prompt 58 | color: $dark-blue 59 | .comment, 60 | .java .annotation, 61 | .python .decorator, 62 | .template_comment, 63 | .pi, 64 | .doctype, 65 | .deletion, 66 | .shebang, 67 | .apache .sqbracket, 68 | .tex .formula 69 | color: #b3b3b3 70 | .coffeescript .javascript, 71 | .javascript .xml, 72 | .tex .formula, 73 | .xml .javascript, 74 | .xml .vbscript, 75 | .xml .css, 76 | .xml .cdata 77 | opacity: 0.5 -------------------------------------------------------------------------------- /client/src/modules/front/assets/stylus/main.styl: -------------------------------------------------------------------------------- 1 | @import "reset" 2 | @import "_settings" 3 | @import "_syntax" 4 | 5 | 6 | [v-cloak] 7 | display none 8 | 9 | html, body 10 | height 100% 11 | font-size $body-font-size 12 | font-family $body-font 13 | -webkit-font-smoothing antialiased 14 | -moz-osx-font-smoothing grayscale 15 | background-color white 16 | input, button 17 | border-radius $border-radius 18 | 19 | -------------------------------------------------------------------------------- /client/src/modules/front/assets/stylus/markdown.styl: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | -ms-text-size-adjust: 100%; 3 | -webkit-text-size-adjust: 100%; 4 | line-height: 1.5; 5 | color: #24292e; 6 | font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 7 | font-size: 16px; 8 | line-height: 1.5; 9 | word-wrap: break-word; 10 | } 11 | 12 | .markdown-body .pl-c { 13 | color: #969896; 14 | } 15 | 16 | .markdown-body .pl-c1, 17 | .markdown-body .pl-s .pl-v { 18 | color: #0086b3; 19 | } 20 | 21 | .markdown-body .pl-e, 22 | .markdown-body .pl-en { 23 | color: #795da3; 24 | } 25 | 26 | .markdown-body .pl-smi, 27 | .markdown-body .pl-s .pl-s1 { 28 | color: #333; 29 | } 30 | 31 | .markdown-body .pl-ent { 32 | color: #63a35c; 33 | } 34 | 35 | .markdown-body .pl-k { 36 | color: #a71d5d; 37 | } 38 | 39 | .markdown-body .pl-s, 40 | .markdown-body .pl-pds, 41 | .markdown-body .pl-s .pl-pse .pl-s1, 42 | .markdown-body .pl-sr, 43 | .markdown-body .pl-sr .pl-cce, 44 | .markdown-body .pl-sr .pl-sre, 45 | .markdown-body .pl-sr .pl-sra { 46 | color: #183691; 47 | } 48 | 49 | .markdown-body .pl-v, 50 | .markdown-body .pl-smw { 51 | color: #ed6a43; 52 | } 53 | 54 | .markdown-body .pl-bu { 55 | color: #b52a1d; 56 | } 57 | 58 | .markdown-body .pl-ii { 59 | color: #f8f8f8; 60 | background-color: #b52a1d; 61 | } 62 | 63 | .markdown-body .pl-c2 { 64 | color: #f8f8f8; 65 | background-color: #b52a1d; 66 | } 67 | 68 | .markdown-body .pl-c2::before { 69 | content: "^M"; 70 | } 71 | 72 | .markdown-body .pl-sr .pl-cce { 73 | font-weight: bold; 74 | color: #63a35c; 75 | } 76 | 77 | .markdown-body .pl-ml { 78 | color: #693a17; 79 | } 80 | 81 | .markdown-body .pl-mh, 82 | .markdown-body .pl-mh .pl-en, 83 | .markdown-body .pl-ms { 84 | font-weight: bold; 85 | color: #1d3e81; 86 | } 87 | 88 | .markdown-body .pl-mq { 89 | color: #008080; 90 | } 91 | 92 | .markdown-body .pl-mi { 93 | font-style: italic; 94 | color: #333; 95 | } 96 | 97 | .markdown-body .pl-mb { 98 | font-weight: bold; 99 | color: #333; 100 | } 101 | 102 | .markdown-body .pl-md { 103 | color: #bd2c00; 104 | background-color: #ffecec; 105 | } 106 | 107 | .markdown-body .pl-mi1 { 108 | color: #55a532; 109 | background-color: #eaffea; 110 | } 111 | 112 | .markdown-body .pl-mc { 113 | color: #ef9700; 114 | background-color: #ffe3b4; 115 | } 116 | 117 | .markdown-body .pl-mi2 { 118 | color: #d8d8d8; 119 | background-color: #808080; 120 | } 121 | 122 | .markdown-body .pl-mdr { 123 | font-weight: bold; 124 | color: #795da3; 125 | } 126 | 127 | .markdown-body .pl-mo { 128 | color: #1d3e81; 129 | } 130 | 131 | .markdown-body .pl-ba { 132 | color: #595e62; 133 | } 134 | 135 | .markdown-body .pl-sg { 136 | color: #c0c0c0; 137 | } 138 | 139 | .markdown-body .pl-corl { 140 | text-decoration: underline; 141 | color: #183691; 142 | } 143 | 144 | .markdown-body .octicon { 145 | display: inline-block; 146 | vertical-align: text-top; 147 | fill: currentColor; 148 | } 149 | 150 | .markdown-body a { 151 | background-color: transparent; 152 | -webkit-text-decoration-skip: objects; 153 | } 154 | 155 | .markdown-body a:active, 156 | .markdown-body a:hover { 157 | outline-width: 0; 158 | } 159 | 160 | .markdown-body strong { 161 | font-weight: inherit; 162 | } 163 | 164 | .markdown-body strong { 165 | font-weight: bolder; 166 | } 167 | 168 | .markdown-body h1 { 169 | font-size: 2em; 170 | margin: 0.67em 0; 171 | } 172 | 173 | .markdown-body img { 174 | border-style: none; 175 | } 176 | 177 | .markdown-body svg:not(:root) { 178 | overflow: hidden; 179 | } 180 | 181 | .markdown-body code, 182 | .markdown-body kbd, 183 | .markdown-body pre { 184 | font-family: monospace, monospace; 185 | font-size: 1em; 186 | } 187 | 188 | .markdown-body hr { 189 | box-sizing: content-box; 190 | height: 0; 191 | overflow: visible; 192 | } 193 | 194 | .markdown-body input { 195 | font: inherit; 196 | margin: 0; 197 | } 198 | 199 | .markdown-body input { 200 | overflow: visible; 201 | } 202 | 203 | .markdown-body [type="checkbox"] { 204 | box-sizing: border-box; 205 | padding: 0; 206 | } 207 | 208 | .markdown-body * { 209 | box-sizing: border-box; 210 | } 211 | 212 | .markdown-body input { 213 | font-family: inherit; 214 | font-size: inherit; 215 | line-height: inherit; 216 | } 217 | 218 | .markdown-body a { 219 | color: #0366d6; 220 | text-decoration: none; 221 | } 222 | 223 | .markdown-body a:hover { 224 | text-decoration: underline; 225 | } 226 | 227 | .markdown-body strong { 228 | font-weight: 600; 229 | } 230 | 231 | .markdown-body hr { 232 | height: 0; 233 | margin: 15px 0; 234 | overflow: hidden; 235 | background: transparent; 236 | border: 0; 237 | border-bottom: 1px solid #dfe2e5; 238 | } 239 | 240 | .markdown-body hr::before { 241 | display: table; 242 | content: ""; 243 | } 244 | 245 | .markdown-body hr::after { 246 | display: table; 247 | clear: both; 248 | content: ""; 249 | } 250 | 251 | .markdown-body table { 252 | border-spacing: 0; 253 | border-collapse: collapse; 254 | } 255 | 256 | .markdown-body td, 257 | .markdown-body th { 258 | padding: 0; 259 | } 260 | 261 | .markdown-body h1, 262 | .markdown-body h2, 263 | .markdown-body h3, 264 | .markdown-body h4, 265 | .markdown-body h5, 266 | .markdown-body h6 { 267 | margin-top: 0; 268 | margin-bottom: 0; 269 | } 270 | 271 | .markdown-body h1 { 272 | font-size: 32px; 273 | font-weight: 600; 274 | } 275 | 276 | .markdown-body h2 { 277 | font-size: 24px; 278 | font-weight: 600; 279 | } 280 | 281 | .markdown-body h3 { 282 | font-size: 20px; 283 | font-weight: 600; 284 | } 285 | 286 | .markdown-body h4 { 287 | font-size: 16px; 288 | font-weight: 600; 289 | } 290 | 291 | .markdown-body h5 { 292 | font-size: 14px; 293 | font-weight: 600; 294 | } 295 | 296 | .markdown-body h6 { 297 | font-size: 12px; 298 | font-weight: 600; 299 | } 300 | 301 | .markdown-body p { 302 | margin-top: 0; 303 | margin-bottom: 10px; 304 | } 305 | 306 | .markdown-body blockquote { 307 | margin: 0; 308 | } 309 | 310 | .markdown-body ul, 311 | .markdown-body ol { 312 | padding-left: 0; 313 | margin-top: 0; 314 | margin-bottom: 0; 315 | } 316 | 317 | .markdown-body ol ol, 318 | .markdown-body ul ol { 319 | list-style-type: lower-roman; 320 | } 321 | 322 | .markdown-body ul ul ol, 323 | .markdown-body ul ol ol, 324 | .markdown-body ol ul ol, 325 | .markdown-body ol ol ol { 326 | list-style-type: lower-alpha; 327 | } 328 | 329 | .markdown-body dd { 330 | margin-left: 0; 331 | } 332 | 333 | .markdown-body code { 334 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 335 | font-size: 12px; 336 | } 337 | 338 | .markdown-body pre { 339 | margin-top: 0; 340 | margin-bottom: 0; 341 | font: 12px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 342 | } 343 | 344 | .markdown-body .octicon { 345 | vertical-align: text-bottom; 346 | } 347 | 348 | .markdown-body .pl-0 { 349 | padding-left: 0 !important; 350 | } 351 | 352 | .markdown-body .pl-1 { 353 | padding-left: 4px !important; 354 | } 355 | 356 | .markdown-body .pl-2 { 357 | padding-left: 8px !important; 358 | } 359 | 360 | .markdown-body .pl-3 { 361 | padding-left: 16px !important; 362 | } 363 | 364 | .markdown-body .pl-4 { 365 | padding-left: 24px !important; 366 | } 367 | 368 | .markdown-body .pl-5 { 369 | padding-left: 32px !important; 370 | } 371 | 372 | .markdown-body .pl-6 { 373 | padding-left: 40px !important; 374 | } 375 | 376 | .markdown-body::before { 377 | display: table; 378 | content: ""; 379 | } 380 | 381 | .markdown-body::after { 382 | display: table; 383 | //clear: both; 384 | content: ""; 385 | } 386 | 387 | .markdown-body>*:first-child { 388 | margin-top: 0 !important; 389 | } 390 | 391 | .markdown-body>*:last-child { 392 | margin-bottom: 0 !important; 393 | } 394 | 395 | .markdown-body a:not([href]) { 396 | color: inherit; 397 | text-decoration: none; 398 | } 399 | 400 | .markdown-body .anchor { 401 | float: left; 402 | padding-right: 4px; 403 | margin-left: -20px; 404 | line-height: 1; 405 | } 406 | 407 | .markdown-body .anchor:focus { 408 | outline: none; 409 | } 410 | 411 | .markdown-body p, 412 | .markdown-body blockquote, 413 | .markdown-body ul, 414 | .markdown-body ol, 415 | .markdown-body dl, 416 | .markdown-body table, 417 | .markdown-body pre { 418 | margin-top: 0; 419 | margin-bottom: 16px; 420 | } 421 | 422 | .markdown-body hr { 423 | height: 0.25em; 424 | padding: 0; 425 | margin: 24px 0; 426 | background-color: #e1e4e8; 427 | border: 0; 428 | } 429 | 430 | .markdown-body blockquote { 431 | padding: 0 1em; 432 | color: #6a737d; 433 | border-left: 0.25em solid #dfe2e5; 434 | } 435 | 436 | .markdown-body blockquote>:first-child { 437 | margin-top: 0; 438 | } 439 | 440 | .markdown-body blockquote>:last-child { 441 | margin-bottom: 0; 442 | } 443 | 444 | .markdown-body kbd { 445 | display: inline-block; 446 | padding: 3px 5px; 447 | font-size: 11px; 448 | line-height: 10px; 449 | color: #444d56; 450 | vertical-align: middle; 451 | background-color: #fafbfc; 452 | border: solid 1px #c6cbd1; 453 | border-bottom-color: #959da5; 454 | border-radius: 3px; 455 | box-shadow: inset 0 -1px 0 #959da5; 456 | } 457 | 458 | .markdown-body h1, 459 | .markdown-body h2, 460 | .markdown-body h3, 461 | .markdown-body h4, 462 | .markdown-body h5, 463 | .markdown-body h6 { 464 | margin-top: 24px; 465 | margin-bottom: 16px; 466 | font-weight: 600; 467 | line-height: 1.25; 468 | } 469 | 470 | .markdown-body h1 .octicon-link, 471 | .markdown-body h2 .octicon-link, 472 | .markdown-body h3 .octicon-link, 473 | .markdown-body h4 .octicon-link, 474 | .markdown-body h5 .octicon-link, 475 | .markdown-body h6 .octicon-link { 476 | color: #1b1f23; 477 | vertical-align: middle; 478 | visibility: hidden; 479 | } 480 | 481 | .markdown-body h1:hover .anchor, 482 | .markdown-body h2:hover .anchor, 483 | .markdown-body h3:hover .anchor, 484 | .markdown-body h4:hover .anchor, 485 | .markdown-body h5:hover .anchor, 486 | .markdown-body h6:hover .anchor { 487 | text-decoration: none; 488 | } 489 | 490 | .markdown-body h1:hover .anchor .octicon-link, 491 | .markdown-body h2:hover .anchor .octicon-link, 492 | .markdown-body h3:hover .anchor .octicon-link, 493 | .markdown-body h4:hover .anchor .octicon-link, 494 | .markdown-body h5:hover .anchor .octicon-link, 495 | .markdown-body h6:hover .anchor .octicon-link { 496 | visibility: visible; 497 | } 498 | 499 | .markdown-body h1 { 500 | padding-bottom: 0.3em; 501 | font-size: 2em; 502 | border-bottom: 1px solid #eaecef; 503 | } 504 | 505 | .markdown-body h2 { 506 | padding-bottom: 0.3em; 507 | font-size: 1.5em; 508 | border-bottom: 1px solid #eaecef; 509 | } 510 | 511 | .markdown-body h3 { 512 | font-size: 1.25em; 513 | } 514 | 515 | .markdown-body h4 { 516 | font-size: 1em; 517 | } 518 | 519 | .markdown-body h5 { 520 | font-size: 0.875em; 521 | } 522 | 523 | .markdown-body h6 { 524 | font-size: 0.85em; 525 | color: #6a737d; 526 | } 527 | 528 | .markdown-body ul, 529 | .markdown-body ol { 530 | padding-left: 2em; 531 | } 532 | 533 | .markdown-body ul ul, 534 | .markdown-body ul ol, 535 | .markdown-body ol ol, 536 | .markdown-body ol ul { 537 | margin-top: 0; 538 | margin-bottom: 0; 539 | } 540 | 541 | .markdown-body li>p { 542 | margin-top: 16px; 543 | } 544 | 545 | .markdown-body li+li { 546 | margin-top: 0.25em; 547 | } 548 | 549 | .markdown-body dl { 550 | padding: 0; 551 | } 552 | 553 | .markdown-body dl dt { 554 | padding: 0; 555 | margin-top: 16px; 556 | font-size: 1em; 557 | font-style: italic; 558 | font-weight: 600; 559 | } 560 | 561 | .markdown-body dl dd { 562 | padding: 0 16px; 563 | margin-bottom: 16px; 564 | } 565 | 566 | .markdown-body table { 567 | display: block; 568 | width: 100%; 569 | overflow: auto; 570 | } 571 | 572 | .markdown-body table th { 573 | font-weight: 600; 574 | } 575 | 576 | .markdown-body table th, 577 | .markdown-body table td { 578 | padding: 6px 13px; 579 | border: 1px solid #dfe2e5; 580 | } 581 | 582 | .markdown-body table tr { 583 | background-color: #fff; 584 | border-top: 1px solid #c6cbd1; 585 | } 586 | 587 | .markdown-body table tr:nth-child(2n) { 588 | background-color: #f6f8fa; 589 | } 590 | 591 | .markdown-body img { 592 | max-width: 100%; 593 | box-sizing: content-box; 594 | background-color: #fff; 595 | } 596 | 597 | .markdown-body code { 598 | padding: 0; 599 | padding-top: 0.2em; 600 | padding-bottom: 0.2em; 601 | margin: 0; 602 | font-size: 85%; 603 | background-color: rgba(27,31,35,0.05); 604 | border-radius: 3px; 605 | } 606 | 607 | .markdown-body code::before, 608 | .markdown-body code::after { 609 | letter-spacing: -0.2em; 610 | content: "\00a0"; 611 | } 612 | 613 | .markdown-body pre { 614 | word-wrap: normal; 615 | } 616 | 617 | .markdown-body pre>code { 618 | padding: 0; 619 | margin: 0; 620 | font-size: 100%; 621 | word-break: normal; 622 | white-space: pre; 623 | background: transparent; 624 | border: 0; 625 | } 626 | 627 | .markdown-body .highlight { 628 | margin-bottom: 16px; 629 | } 630 | 631 | .markdown-body .highlight pre { 632 | margin-bottom: 0; 633 | word-break: normal; 634 | } 635 | 636 | .markdown-body .highlight pre, 637 | .markdown-body pre { 638 | padding: 16px; 639 | overflow: auto; 640 | font-size: 85%; 641 | line-height: 1.45; 642 | background-color: #f6f8fa; 643 | border-radius: 3px; 644 | } 645 | 646 | .markdown-body pre code { 647 | display: inline; 648 | max-width: auto; 649 | padding: 0; 650 | margin: 0; 651 | overflow: visible; 652 | line-height: inherit; 653 | word-wrap: normal; 654 | background-color: transparent; 655 | border: 0; 656 | } 657 | 658 | .markdown-body pre code::before, 659 | .markdown-body pre code::after { 660 | content: normal; 661 | } 662 | 663 | .markdown-body .full-commit .btn-outline:not(:disabled):hover { 664 | color: #005cc5; 665 | border-color: #005cc5; 666 | } 667 | 668 | .markdown-body kbd { 669 | display: inline-block; 670 | padding: 3px 5px; 671 | font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 672 | line-height: 10px; 673 | color: #444d56; 674 | vertical-align: middle; 675 | background-color: #fcfcfc; 676 | border: solid 1px #c6cbd1; 677 | border-bottom-color: #959da5; 678 | border-radius: 3px; 679 | box-shadow: inset 0 -1px 0 #959da5; 680 | } 681 | 682 | .markdown-body :checked+.radio-label { 683 | position: relative; 684 | z-index: 1; 685 | border-color: #0366d6; 686 | } 687 | 688 | .markdown-body .task-list-item { 689 | list-style-type: none; 690 | } 691 | 692 | .markdown-body .task-list-item+.task-list-item { 693 | margin-top: 3px; 694 | } 695 | 696 | .markdown-body .task-list-item input { 697 | margin: 0 0.2em 0.25em -1.6em; 698 | vertical-align: middle; 699 | } 700 | 701 | .markdown-body hr { 702 | border-bottom-color: #eee; 703 | } -------------------------------------------------------------------------------- /client/src/modules/front/assets/stylus/reset.styl: -------------------------------------------------------------------------------- 1 | /*! 2 | * ress.css • v1.1.2 3 | * MIT License 4 | * github.com/filipelinhares/ress 5 | */ 6 | 7 | /* # ================================================================= 8 | # Global selectors 9 | # ================================================================= */ 10 | 11 | html { 12 | box-sizing: border-box; 13 | //overflow-y: scroll; /* All browsers without overlaying scrollbars */ 14 | -webkit-text-size-adjust: 100%; /* iOS 8+ */ 15 | } 16 | 17 | *, 18 | ::before, 19 | ::after { 20 | box-sizing: inherit; 21 | } 22 | 23 | ::before, 24 | ::after { 25 | text-decoration: inherit; /* Inherit text-decoration and vertical align to ::before and ::after pseudo elements */ 26 | vertical-align: inherit; 27 | } 28 | 29 | /* Remove margin, padding of all elements and set background-no-repeat as default */ 30 | * { 31 | background-repeat: no-repeat; /* Set `background-repeat: no-repeat` to all elements */ 32 | padding: 0; /* Reset `padding` and `margin` of all elements */ 33 | margin: 0; 34 | } 35 | 36 | /* # ================================================================= 37 | # General elements 38 | # ================================================================= */ 39 | 40 | /* Add the correct display in iOS 4-7.*/ 41 | audio:not([controls]) { 42 | display: none; 43 | height: 0; 44 | } 45 | 46 | hr { 47 | overflow: visible; /* Show the overflow in Edge and IE */ 48 | } 49 | 50 | /* 51 | * Correct `block` display not defined for any HTML5 element in IE 8/9 52 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 53 | * and Firefox 54 | * Correct `block` display not defined for `main` in IE 11 55 | */ 56 | article, 57 | aside, 58 | details, 59 | figcaption, 60 | figure, 61 | footer, 62 | header, 63 | main, 64 | menu, 65 | nav, 66 | section, 67 | summary { 68 | display: block; 69 | } 70 | 71 | summary { 72 | display: list-item; /* Add the correct display in all browsers */ 73 | } 74 | 75 | small { 76 | font-size: 80%; /* Set font-size to 80% in `small` elements */ 77 | } 78 | 79 | [hidden], 80 | template { 81 | display: none; /* Add the correct display in IE */ 82 | } 83 | 84 | abbr[title] { 85 | border-bottom: 1px dotted; /* Add a bordered underline effect in all browsers */ 86 | text-decoration: none; /* Remove text decoration in Firefox 40+ */ 87 | } 88 | 89 | a { 90 | background-color: transparent; /* Remove the gray background on active links in IE 10 */ 91 | -webkit-text-decoration-skip: objects; /* Remove gaps in links underline in iOS 8+ and Safari 8+ */ 92 | } 93 | 94 | a:active, 95 | a:hover { 96 | outline-width: 0; /* Remove the outline when hovering in all browsers */ 97 | } 98 | 99 | code, 100 | kbd, 101 | pre, 102 | samp { 103 | font-family: monospace, monospace; /* Specify the font family of code elements */ 104 | } 105 | 106 | b, 107 | strong { 108 | font-weight: bolder; /* Correct style set to `bold` in Edge 12+, Safari 6.2+, and Chrome 18+ */ 109 | } 110 | 111 | dfn { 112 | font-style: italic; /* Address styling not present in Safari and Chrome */ 113 | } 114 | 115 | /* Address styling not present in IE 8/9 */ 116 | mark { 117 | background-color: #ff0; 118 | color: #000; 119 | } 120 | 121 | /* https://gist.github.com/unruthless/413930 */ 122 | sub, 123 | sup { 124 | font-size: 75%; 125 | line-height: 0; 126 | position: relative; 127 | vertical-align: baseline; 128 | } 129 | 130 | sub { 131 | bottom: -0.25em; 132 | } 133 | 134 | sup { 135 | top: -0.5em; 136 | } 137 | 138 | /* # ================================================================= 139 | # Forms 140 | # ================================================================= */ 141 | 142 | input { 143 | border-radius: 0; 144 | } 145 | 146 | /* Apply cursor pointer to button elements */ 147 | button, 148 | [type="button"], 149 | [type="reset"], 150 | [type="submit"], 151 | [role="button"] { 152 | cursor: pointer; 153 | } 154 | 155 | /* Replace pointer cursor in disabled elements */ 156 | [disabled] { 157 | cursor: default; 158 | } 159 | 160 | [type="number"] { 161 | width: auto; /* Firefox 36+ */ 162 | } 163 | 164 | [type="search"] { 165 | -webkit-appearance: textfield; /* Safari 8+ */ 166 | } 167 | 168 | [type="search"]::-webkit-search-cancel-button, 169 | [type="search"]::-webkit-search-decoration { 170 | -webkit-appearance: none; /* Safari 8 */ 171 | } 172 | 173 | textarea { 174 | overflow: auto; /* Internet Explorer 11+ */ 175 | resize: vertical; /* Specify textarea resizability */ 176 | } 177 | 178 | button, 179 | input, 180 | optgroup, 181 | select, 182 | textarea { 183 | font: inherit; /* Specify font inheritance of form elements */ 184 | } 185 | 186 | optgroup { 187 | font-weight: bold; /* Restore the font weight unset by the previous rule. */ 188 | } 189 | 190 | button { 191 | overflow: visible; /* Address `overflow` set to `hidden` in IE 8/9/10/11 */ 192 | } 193 | 194 | /* Remove inner padding and border in Firefox 4+ */ 195 | button::-moz-focus-inner, 196 | [type="button"]::-moz-focus-inner, 197 | [type="reset"]::-moz-focus-inner, 198 | [type="submit"]::-moz-focus-inner { 199 | border-style: 0; 200 | padding: 0; 201 | } 202 | 203 | /* Replace focus style removed in the border reset above */ 204 | button:-moz-focusring, 205 | [type="button"]::-moz-focus-inner, 206 | [type="reset"]::-moz-focus-inner, 207 | [type="submit"]::-moz-focus-inner { 208 | outline: 1px dotted ButtonText; 209 | } 210 | 211 | button, 212 | html [type="button"], /* Prevent a WebKit bug where (2) destroys native `audio` and `video`controls in Android 4 */ 213 | [type="reset"], 214 | [type="submit"] { 215 | -webkit-appearance: button; /* Correct the inability to style clickable types in iOS */ 216 | } 217 | 218 | button, 219 | select { 220 | text-transform: none; /* Firefox 40+, Internet Explorer 11- */ 221 | } 222 | 223 | /* Remove the default button styling in all browsers */ 224 | button, 225 | input, 226 | select, 227 | textarea { 228 | background-color: transparent; 229 | border-style: none; 230 | color: inherit; 231 | } 232 | 233 | /* Style select like a standard input */ 234 | select { 235 | -moz-appearance: none; /* Firefox 36+ */ 236 | -webkit-appearance: none; /* Chrome 41+ */ 237 | } 238 | 239 | select::-ms-expand { 240 | display: none; /* Internet Explorer 11+ */ 241 | } 242 | 243 | select::-ms-value { 244 | color: currentColor; /* Internet Explorer 11+ */ 245 | } 246 | 247 | legend { 248 | border: 0; /* Correct `color` not being inherited in IE 8/9/10/11 */ 249 | color: inherit; /* Correct the color inheritance from `fieldset` elements in IE */ 250 | display: table; /* Correct the text wrapping in Edge and IE */ 251 | max-width: 100%; /* Correct the text wrapping in Edge and IE */ 252 | white-space: normal; /* Correct the text wrapping in Edge and IE */ 253 | } 254 | 255 | ::-webkit-file-upload-button { 256 | -webkit-appearance: button; /* Correct the inability to style clickable types in iOS and Safari */ 257 | font: inherit; /* Change font properties to `inherit` in Chrome and Safari */ 258 | } 259 | 260 | [type="search"] { 261 | -webkit-appearance: textfield; /* Correct the odd appearance in Chrome and Safari */ 262 | outline-offset: -2px; /* Correct the outline style in Safari */ 263 | } 264 | 265 | /* # ================================================================= 266 | # Specify media element style 267 | # ================================================================= */ 268 | 269 | img { 270 | border-style: none; /* Remove border when inside `a` element in IE 8/9/10 */ 271 | } 272 | 273 | /* Add the correct vertical alignment in Chrome, Firefox, and Opera */ 274 | progress { 275 | vertical-align: baseline; 276 | } 277 | 278 | svg:not(:root) { 279 | overflow: hidden; /* Internet Explorer 11- */ 280 | } 281 | 282 | audio, 283 | canvas, 284 | progress, 285 | video { 286 | display: inline-block; /* Internet Explorer 11+, Windows Phone 8.1+ */ 287 | } 288 | 289 | /* # ================================================================= 290 | # Accessibility 291 | # ================================================================= */ 292 | 293 | /* Hide content from screens but not screenreaders */ 294 | @media screen { 295 | [hidden~="screen"] { 296 | display: inherit; 297 | } 298 | [hidden~="screen"]:not(:active):not(:focus):not(:target) { 299 | position: absolute !important; 300 | clip: rect(0 0 0 0) !important; 301 | } 302 | } 303 | 304 | /* Specify the progress cursor of updating elements */ 305 | [aria-busy="true"] { 306 | cursor: progress; 307 | } 308 | 309 | /* Specify the pointer cursor of trigger elements */ 310 | [aria-controls] { 311 | cursor: pointer; 312 | } 313 | 314 | /* Specify the unstyled cursor of disabled, not-editable, or otherwise inoperable elements */ 315 | [aria-disabled] { 316 | cursor: default; 317 | } 318 | 319 | /* # ================================================================= 320 | # Selection 321 | # ================================================================= */ 322 | 323 | /* Specify text selection background color and omit drop shadow */ 324 | 325 | ::-moz-selection { 326 | background-color: #b3d4fc; /* Required when declaring ::selection */ 327 | color: #000; 328 | text-shadow: none; 329 | } 330 | 331 | ::selection { 332 | background-color: #b3d4fc; /* Required when declaring ::selection */ 333 | color: #000; 334 | text-shadow: none; 335 | } 336 | -------------------------------------------------------------------------------- /client/src/modules/front/components/Article.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 102 | 103 | 106 | 143 | -------------------------------------------------------------------------------- /client/src/modules/front/components/List.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 124 | 125 | 205 | -------------------------------------------------------------------------------- /client/src/modules/front/components/common/Comment.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /client/src/modules/front/components/common/Side.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 125 | 126 | 127 | 270 | 273 | 274 | -------------------------------------------------------------------------------- /client/src/modules/front/components/common/Top.vue: -------------------------------------------------------------------------------- 1 | 7 | 36 | 37 | 69 | -------------------------------------------------------------------------------- /client/src/modules/front/entry-client.js: -------------------------------------------------------------------------------- 1 | import { createApp } from './app'; 2 | const { app, router, store } = createApp(); 3 | 4 | // store替换使client rendering和server rendering匹配 5 | if (window.__INITIAL_STATE__) { 6 | store.replaceState(window.__INITIAL_STATE__); 7 | } 8 | 9 | // 挂载#app 10 | router.onReady(() => { 11 | app.$mount('#app'); 12 | }); 13 | 14 | // service worker还没启用 15 | // // service worker 16 | // if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 17 | // navigator.serviceWorker.register('/service-worker.js') 18 | // } 19 | -------------------------------------------------------------------------------- /client/src/modules/front/entry-server.js: -------------------------------------------------------------------------------- 1 | import { createApp } from './app'; 2 | 3 | const isDev = process.env.NODE_ENV !== 'production'; 4 | 5 | export default context => { 6 | const s = isDev && Date.now(); 7 | // 注意下面这句话要写在export函数里供服务端渲染调用,重新初始化那store、router 8 | const { app, router, store } = createApp(); 9 | return new Promise((resolve, reject) => { 10 | router.push(context.url); 11 | router.onReady(() => { 12 | const matchedComponents = router.getMatchedComponents(); 13 | if (!matchedComponents.length) { 14 | reject({ code: 404 }); 15 | } 16 | Promise.all(matchedComponents.map(component => { 17 | if (component.preFetch) { 18 | // 调用组件上的preFetch(这部分只能拿到router第一级别组件,子组件的preFetch拿不到) 19 | return component.preFetch(store); 20 | } 21 | })).then(() => { 22 | isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`); 23 | // 暴露数据到HTMl,使得客户端渲染拿到数据,跟服务端渲染匹配 24 | context.state = store.state; 25 | context.state.posts.forEach((element, index) => { 26 | context.state.posts[index].content = ''; 27 | }); 28 | if (/\/article\//g.test(context.url)) { 29 | context.title = context.state.currentPost.title; 30 | } 31 | resolve(app); 32 | }).catch(reject); 33 | }); 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /client/src/modules/front/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | <% for (var chunk of webpack.chunks) { 8 | for (var file of chunk.files) { 9 | if (file.match(/\.(js|css)$/)) { 10 | if (file.indexOf('admin') == -1) {%> 11 | 14 | <% }}}} %> 15 | {{{ renderURLScript('disqus') }}} 16 | {{{ renderURLScript('baidu') }}} 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /client/src/modules/front/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | // import List from '../components/List.vue' 4 | // import Article from '../components/Article.vue' 5 | 6 | Vue.use(VueRouter); 7 | 8 | const List = resolve => require(['../components/List.vue'], resolve); 9 | const Article = resolve => require(['../components/Article.vue'], resolve); 10 | 11 | export function createRouter() { 12 | const router = new VueRouter({ 13 | mode: 'history', 14 | scrollBehavior: (to, from, savedPosition) => { 15 | if (savedPosition) { 16 | return savedPosition; 17 | } else { 18 | return { x: 0, y: 0 }; 19 | } 20 | }, 21 | routes: [ 22 | { path: '/', component: List }, 23 | { path: '/article/:id', component: Article, meta: { scrollToTop: true } }, 24 | { path: '/page/:page', component: List }, 25 | { path: '*', redirect: '/' }, 26 | ], 27 | }); 28 | if (typeof window !== 'undefined') { 29 | router.afterEach((to, from) => { 30 | if (document && !(/\/article\//g.test(to.path))) { 31 | document.querySelector('title').innerText = 'HJM\'s Blog'; 32 | } 33 | }); 34 | } 35 | return router; 36 | } 37 | -------------------------------------------------------------------------------- /client/src/modules/front/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import articleApi from 'api/article.js'; 4 | import tagApi from 'api/tag.js'; 5 | import marked from 'lib/marked.js'; 6 | 7 | Vue.use(Vuex); 8 | 9 | export function createStore() { 10 | return new Vuex.Store({ 11 | state: { 12 | currentPost: { 13 | content: '', 14 | id: '', 15 | }, 16 | currentPostCompile: '', 17 | posts: [], 18 | allPage: 0, 19 | curPage: 0, 20 | tags: [], 21 | selectTags: [], 22 | sideBoxOpen: false, 23 | }, 24 | 25 | actions: { 26 | getAllPosts({ commit, state }, { tag = '', page = 1, limit = 5 } = {}) { 27 | 28 | return articleApi.getAllPublishArticles(tag, page, limit).then(res => { 29 | commit('GET_ALL_POSTS', { posts: res.data.articleArr, allPage: res.data.allPage, curPage: page }); 30 | return new Promise((resolve, reject) => { 31 | resolve(res); 32 | }); 33 | }); 34 | }, 35 | getAllTags({ commit, state }) { 36 | return tagApi.getAllTags().then(res => { 37 | commit('GET_ALL_TAGS', res.data.tagArr); 38 | return new Promise((resolve, reject) => { 39 | resolve(res); 40 | }); 41 | }); 42 | }, 43 | getPost({ commit, state }, id) { 44 | let article = state.posts.find((post) => post.id === id); 45 | if (!article && state.currentPost.id === id) { 46 | article = state.currentPost; 47 | } 48 | if (article && article.content) { 49 | commit('GET_POST', article); 50 | return new Promise((resolve, reject) => { 51 | resolve(article); 52 | }); 53 | } else { 54 | return articleApi.getArticle(id).then(res => { 55 | commit('GET_POST', res.data.article); 56 | return new Promise((resolve, reject) => { 57 | resolve(res); 58 | }); 59 | }).catch((err) => { 60 | // console.log(err) 61 | }); 62 | } 63 | }, 64 | }, 65 | 66 | mutations: { 67 | GET_ALL_POSTS: (state, { posts, allPage, curPage }) => { 68 | if (isNaN(+allPage)) { 69 | allPage = 0; 70 | } 71 | if (isNaN(+curPage)) { 72 | curPage = 0; 73 | } 74 | state.posts = posts; 75 | state.allPage = +allPage; 76 | state.curPage = +curPage; 77 | }, 78 | GET_ALL_TAGS: (state, tags) => { 79 | state.tags = tags; 80 | }, 81 | SET_SELECT_TAGS: (state, tags) => { 82 | state.selectTags = tags; 83 | }, 84 | TOGGLE_SELECT_TAGS: (state, { id, name }) => { 85 | if (typeof state.selectTags.find(function (e) { 86 | return e.id === id; 87 | }) === 'undefined') { 88 | state.selectTags.push({ 89 | id, 90 | name, 91 | }); 92 | } else { 93 | state.selectTags = state.selectTags.filter((e) => { 94 | return e.id !== id; 95 | }); 96 | } 97 | }, 98 | TOGGLE_SIDEBOX: (state) => { 99 | state.sideBoxOpen = !state.sideBoxOpen; 100 | }, 101 | CLOSE_SIDEBOX: (state) => { 102 | state.sideBoxOpen = false; 103 | }, 104 | GET_POST: (state, article) => { 105 | state.currentPost = article; 106 | state.currentPostCompile = marked(state.currentPost.content); 107 | }, 108 | }, 109 | getters: { 110 | posts: state => state.posts, 111 | tags: state => state.tags, 112 | curPage: state => state.curPage, 113 | allPage: state => state.allPage, 114 | selectTags: state => state.selectTags, 115 | searchTags: state => { 116 | return state.selectTags.map((item) => item.id); 117 | }, 118 | sideBoxOpen: state => state.sideBoxOpen, 119 | currentPost: state => state.currentPost, 120 | currentPostCompile: state => state.currentPostCompile, 121 | }, 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /client/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BUPT-HJM/vue-blog/747306b702fe84d215478403d61f49332e15f829/client/static/.gitkeep -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | node: 4 | build: 5 | context: . 6 | dockerfile: node.dockerfile 7 | ports: 8 | - "8889:8889" 9 | environment: 10 | NODE_ENV: production 11 | MONGO_URL: mongodb://mongodb/vue-blog-prod 12 | command: ["npm", "run", "prod"] 13 | links: 14 | - mongodb 15 | depends_on: 16 | - mongodb 17 | restart: on-failure 18 | mongodb: 19 | image: mongo 20 | ports: 21 | - "27017:27017" 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | node: 4 | build: 5 | context: . 6 | dockerfile: node.dockerfile 7 | ports: 8 | - "8889:8889" 9 | environment: 10 | NODE_ENV: development 11 | MONGO_URL: mongodb://mongodb/vue-blog 12 | volumes: 13 | - .:/home/app/vue-blog 14 | - /home/app/vue-blog/node_modules 15 | command: ["npm", "run", "dev"] 16 | links: 17 | - mongodb 18 | depends_on: 19 | - mongodb 20 | mongodb: 21 | image: mongo 22 | ports: 23 | - "27017:27017" 24 | -------------------------------------------------------------------------------- /node.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.9.2 2 | 3 | MAINTAINER https://github.com/BUPT-HJM 4 | 5 | ENV HOME=/home/app 6 | 7 | COPY package.json package-lock.json $HOME/vue-blog/ 8 | 9 | WORKDIR $HOME/vue-blog 10 | RUN npm install --registry=https://registry.npm.taobao.org 11 | 12 | COPY . $HOME/vue-blog 13 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "watch": [ 4 | "server", 5 | "webpack.config.js" 6 | ], 7 | "verbose": true, 8 | "execMap": { 9 | "js": "node --harmony" 10 | }, 11 | "env": { 12 | "NODE_ENV": "development" 13 | }, 14 | "ext": "js json css html" 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-blog", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "description": "a blog platform built with vue2, koa2 and mongo", 6 | "scripts": { 7 | "start": "cross-env NODE_ENV=production node server/start.js", 8 | "dev": "nodemon server/start.js", 9 | "prod": "npm run build && npm start", 10 | "build": "npm run build:client && npm run build:server", 11 | "build:client": "rimraf ./client/dist && cross-env NODE_ENV=production webpack --config ./client/build/webpack.client.config.js --colors --profile --display-modules --progress", 12 | "build:server": "cross-env NODE_ENV=production webpack --config ./client/build/webpack.server.config.js --colors --profile --display-modules --progress", 13 | "analyze": "rimraf ./client/dist && cross-env NODE_ENV=production webpack --config ./client/build/webpack.client.config.js --json > stats.json && webpack-bundle-analyzer stats.json -p 1111", 14 | "pm2": "npm run build && pm2 start pm2.json --env production" 15 | }, 16 | "dependencies": { 17 | "axios": "^0.16.0", 18 | "element-ui": "^1.2.7", 19 | "fastclick": "^1.0.6", 20 | "font-awesome": "^4.7.0", 21 | "highlight.js": "^9.10.0", 22 | "koa": "^2.2.0", 23 | "koa-bodyparser": "1", 24 | "koa-compress": "^2.0.0", 25 | "koa-connect-history-api-fallback": "^0.3.1", 26 | "koa-convert": "^1.2.0", 27 | "koa-logger": "^2.0.1", 28 | "koa-onerror": "^3.1.0", 29 | "koa-router": "^7.1.1", 30 | "koa-static": "^3.0.0", 31 | "lru-cache": "^4.0.2", 32 | "marked": "^0.3.6", 33 | "md5": "^2.2.1", 34 | "moment": "^2.18.1", 35 | "mongoose": "^4.9.3", 36 | "require-dir": "^0.3.2", 37 | "simplemde": "^1.11.2", 38 | "ssri": "^5.2.2", 39 | "strip-ansi": "3.0.1", 40 | "vue": "^2.3.0", 41 | "vue-router": "^2.5.2", 42 | "vue-server-renderer": "^2.3.0", 43 | "vuex": "^2.3.1", 44 | "vuex-router-sync": "^4.1.2" 45 | }, 46 | "devDependencies": { 47 | "autoprefixer": "^6.7.7", 48 | "babel-core": "^6.0.0", 49 | "babel-eslint": "^8.2.3", 50 | "babel-loader": "^6.4.1", 51 | "babel-plugin-component": "^0.9.1", 52 | "babel-plugin-transform-runtime": "^6.23.0", 53 | "babel-polyfill": "^6.26.0", 54 | "babel-preset-es2015": "^6.24.0", 55 | "babel-preset-stage-0": "^6.22.0", 56 | "connect-history-api-fallback": "^1.3.0", 57 | "copy-webpack-plugin": "^4.0.1", 58 | "cross-env": "^4.0.0", 59 | "css-loader": "^0.28.0", 60 | "cz-conventional-changelog": "^2.1.0", 61 | "eslint": "^4.19.1", 62 | "eslint-plugin-vue": "^4.5.0", 63 | "event-source-polyfill": "^0.0.12", 64 | "extract-text-webpack-plugin": "^2.1.0", 65 | "file-loader": "*", 66 | "html-webpack-plugin": "^2.28.0", 67 | "jsonwebtoken": "^7.3.0", 68 | "nodemon": "^1.11.0", 69 | "postcss-loader": "^1.3.3", 70 | "rimraf": "^2.6.1", 71 | "style-loader": "^0.16.1", 72 | "stylus": "0.52.4", 73 | "stylus-loader": "^3.0.1", 74 | "url-loader": "^0.5.8", 75 | "vue-loader": "^12.0.2", 76 | "vue-ssr-webpack-plugin": "^3.0.0", 77 | "vue-template-compiler": "^2.3.0", 78 | "webpack": "^2.3.2", 79 | "webpack-bundle-analyzer": "^2.4.0", 80 | "webpack-dev-middleware": "^1.10.1", 81 | "webpack-hot-middleware": "^2.18.0", 82 | "webpack-merge": "^4.1.0", 83 | "webpack-node-externals": "^1.5.4" 84 | }, 85 | "config": { 86 | "commitizen": { 87 | "path": "./node_modules/cz-conventional-changelog" 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "name": "vue-blog", 3 | "script": "server/start.js", 4 | "error_file": "server/logs/app-err.log", 5 | "out_file": "server/logs/app-out.log", 6 | "env_dev": { 7 | "NODE_ENV": "development" 8 | }, 9 | "env_production": { 10 | "NODE_ENV": "production" 11 | } 12 | }] 13 | // pm2 install pm2-logrotate 14 | // pm2 set pm2-logrotate:retain 100 15 | // pm2 set pm2-logrotate:size 1M 16 | -------------------------------------------------------------------------------- /server/api/index.js: -------------------------------------------------------------------------------- 1 | import compose from 'koa-compose'; 2 | import Router from 'koa-router'; 3 | import convert from 'koa-convert'; 4 | import config from '../configs'; 5 | import requireDir from 'require-dir'; 6 | const routes = requireDir('./routes'); 7 | 8 | export default function api() { 9 | const router = new Router({ 10 | prefix: config.app.baseApi, 11 | }); 12 | Object.keys(routes).forEach(name => { 13 | return routes[name]['default'](router); 14 | }); 15 | return convert.compose([ 16 | router.routes(), 17 | router.allowedMethods(), 18 | ]); 19 | } 20 | -------------------------------------------------------------------------------- /server/api/routes/articles.js: -------------------------------------------------------------------------------- 1 | import * as $ from '../../controllers/articles_controller.js'; 2 | import verify from '../../middleware/verify.js'; 3 | 4 | 5 | export default async(router) => { 6 | router.get('/articles', verify, $.getAllArticles) 7 | .post('/articles', verify, $.createArticle) 8 | .patch('/articles/:id', verify, $.modifyArticle) 9 | .get('/articles/:id', $.getArticle) 10 | .delete('/articles/:id', verify, $.deleteArticle) 11 | .get('/publishArticles', $.getAllPublishArticles); 12 | }; 13 | -------------------------------------------------------------------------------- /server/api/routes/tags.js: -------------------------------------------------------------------------------- 1 | import * as $ from '../../controllers/tags_controller.js'; 2 | import verify from '../../middleware/verify.js'; 3 | 4 | export default async(router) => { 5 | router.post('/tags', verify, $.createTag) 6 | .get('/tags', $.getAllTags) 7 | .patch('/tags/:id', verify, $.modifyTag) 8 | .delete('/tags/:id', verify, $.deleteTag); 9 | }; 10 | -------------------------------------------------------------------------------- /server/api/routes/token.js: -------------------------------------------------------------------------------- 1 | import * as $ from '../../controllers/token_controller.js'; 2 | 3 | export default async(router) => { 4 | $.initUser(); 5 | router.post('/token', $.login); 6 | }; 7 | -------------------------------------------------------------------------------- /server/configs/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | let config = { 3 | app: { 4 | port: process.env.PORT || 8889, 5 | baseApi: '/api', 6 | }, 7 | mongodb: { 8 | url: process.env.MONGO_URL || 'mongodb://localhost:27017/vue-blog', 9 | }, 10 | jwt: { 11 | secret: 'me', // 默认 12 | }, 13 | mongodbSecret: { // mongodb用户和密码 14 | user: '', 15 | pass: '', 16 | }, 17 | admin: { // 后台初始化的用户名密码 18 | user: 'admin', 19 | pwd: 'password', 20 | }, 21 | disqus: { // disqus 22 | url: '', 23 | }, 24 | baidu: { // 百度统计 25 | url: '', 26 | }, 27 | }; 28 | 29 | // 可在private.js定义自己私有的配置 30 | // module.exports = { 31 | // mongodbSecret: { 32 | // user: '', 33 | // pass: '' 34 | // }, 35 | // jwt: { 36 | // secret: 'xxx' 37 | // }, 38 | // admin: { 39 | // user: '', 40 | // pwd: '' 41 | // } 42 | // } 43 | if (fs.existsSync(__dirname + '/private.js')) { 44 | config = Object.assign(config, require('./private.js')); 45 | } 46 | console.log(config); 47 | export default config; 48 | -------------------------------------------------------------------------------- /server/controllers/articles_controller.js: -------------------------------------------------------------------------------- 1 | import Article from '../models/article.js'; 2 | import md5 from 'md5'; 3 | import jwt from 'jsonwebtoken'; 4 | import config from '../configs/'; 5 | 6 | export async function createArticle(ctx) { 7 | const title = ctx.request.body.title; 8 | const content = ctx.request.body.content; 9 | const abstract = ctx.request.body.abstract; 10 | const publish = ctx.request.body.publish; 11 | const tags = ctx.request.body.tags; 12 | const createTime = new Date(); 13 | const lastEditTime = new Date(); 14 | if (title === '') { 15 | ctx.throw(400, '标题不能为空'); 16 | } 17 | if (content === '') { 18 | ctx.throw(400, '文章内容不能为空'); 19 | } 20 | if (abstract === '') { 21 | ctx.throw(400, '摘要不能为空'); 22 | } 23 | const article = new Article({ 24 | title, 25 | content, 26 | abstract, 27 | publish, 28 | tags, 29 | createTime, 30 | lastEditTime, 31 | }); 32 | let createResult = await article.save().catch(err => { 33 | ctx.throw(500, '服务器内部错误'); 34 | }); 35 | await Article.populate(createResult, { path: 'tags' }, function (err, result) { 36 | createResult = result; 37 | // console.log(result) 38 | 39 | }); 40 | console.log('文章创建成功'); 41 | ctx.body = { 42 | success: true, 43 | article: createResult, 44 | }; 45 | 46 | } 47 | 48 | export async function getAllArticles(ctx) { 49 | const tag = ctx.query.tag; 50 | const page = +ctx.query.page; 51 | const limit = +ctx.query.limit || 4; 52 | let skip = 0; 53 | let articleArr; 54 | let allPage; 55 | let allNum; 56 | 57 | if (page !== 0) { 58 | skip = limit * (page - 1); 59 | } 60 | 61 | if (tag === '') { 62 | articleArr = await Article.find() 63 | .populate('tags') 64 | .sort({ createTime: -1 }) 65 | .limit(limit) 66 | .skip(skip).catch(err => { 67 | ctx.throw(500, '服务器内部错误'); 68 | }); 69 | allNum = await Article.count().catch(err => { 70 | this.throw(500, '服务器内部错误'); 71 | }); 72 | } else { 73 | let tagArr = tag.split(','); 74 | // console.log(tagArr) 75 | articleArr = await Article.find({ 76 | tags: { $in: tagArr }, 77 | }) 78 | .populate('tags') 79 | .sort({ createTime: -1 }) 80 | .limit(limit) 81 | .skip(skip).catch(err => { 82 | ctx.throw(500, '服务器内部错误'); 83 | }); 84 | allNum = await Article.find({ 85 | tags: { $in: tagArr }, 86 | }).count().catch(err => { 87 | ctx.throw(500, '服务器内部错误'); 88 | }); 89 | } 90 | allPage = Math.ceil(allNum / limit); 91 | ctx.body = { 92 | success: true, 93 | articleArr, 94 | allPage: allPage, 95 | }; 96 | } 97 | 98 | export async function getAllPublishArticles(ctx) { 99 | const tag = ctx.query.tag; 100 | const page = +ctx.query.page; 101 | const limit = +ctx.query.limit || 4; 102 | let skip = 0; 103 | let articleArr; 104 | let allPage; 105 | let allNum; 106 | 107 | if (page !== 0) { 108 | skip = limit * (page - 1); 109 | } 110 | 111 | if (tag === '') { 112 | articleArr = await Article.find({ 113 | publish: true, 114 | }) 115 | .populate('tags') 116 | .sort({ createTime: -1 }) 117 | .limit(limit) 118 | .skip(skip).catch(err => { 119 | ctx.throw(500, '服务器内部错误'); 120 | }); 121 | allNum = await Article.find({ 122 | publish: true, 123 | }).count().catch(err => { 124 | this.throw(500, '服务器内部错误'); 125 | }); 126 | } else { 127 | let tagArr = tag.split(','); 128 | // console.log(tagArr) 129 | articleArr = await Article.find({ 130 | tags: { $in: tagArr }, 131 | publish: true, 132 | }) 133 | .populate('tags') 134 | .sort({ createTime: -1 }) 135 | .limit(limit) 136 | .skip(skip).catch(err => { 137 | ctx.throw(500, '服务器内部错误'); 138 | }); 139 | allNum = await Article.find({ 140 | tags: { $in: tagArr }, 141 | }).count().catch(err => { 142 | ctx.throw(500, '服务器内部错误'); 143 | }); 144 | } 145 | 146 | allPage = Math.ceil(allNum / limit); 147 | 148 | 149 | ctx.body = { 150 | success: true, 151 | articleArr, 152 | allPage: allPage, 153 | }; 154 | } 155 | 156 | 157 | export async function modifyArticle(ctx) { 158 | // console.log(ctx.request.body) 159 | const id = ctx.params.id; 160 | const title = ctx.request.body.title; 161 | const content = ctx.request.body.content; 162 | const abstract = ctx.request.body.abstract; 163 | const tags = ctx.request.body.tags; 164 | if (title === '') { 165 | ctx.throw(400, '标题不能为空'); 166 | } 167 | if (content === '') { 168 | ctx.throw(400, '文章内容不能为空'); 169 | } 170 | if (abstract === '') { 171 | ctx.throw(400, '摘要不能为空'); 172 | } 173 | 174 | /* if (tags.length === 0) { 175 | ctx.throw(400, '标签不能为空') 176 | } */ 177 | const article = await Article.findByIdAndUpdate(id, { $set: ctx.request.body }).catch(err => { 178 | if (err.name === 'CastError') { 179 | ctx.throw(400, 'id不存在'); 180 | } else { 181 | ctx.throw(500, '服务器内部错误'); 182 | } 183 | }); 184 | ctx.body = { 185 | success: true, 186 | }; 187 | } 188 | 189 | export async function getArticle(ctx) { 190 | const id = ctx.params.id; 191 | if (id === '') { 192 | ctx.throw(400, 'id不能为空'); 193 | } 194 | 195 | /* if (tags.length === 0) { 196 | ctx.throw(400, '标签不能为空') 197 | } */ 198 | const article = await Article.findById(id).catch(err => { 199 | if (err.name === 'CastError') { 200 | ctx.throw(400, 'id不存在'); 201 | } else { 202 | ctx.throw(500, '服务器内部错误'); 203 | } 204 | }); 205 | ctx.body = { 206 | success: true, 207 | article: article, 208 | }; 209 | } 210 | 211 | export async function deleteArticle(ctx) { 212 | const id = ctx.params.id; 213 | const article = await Article.findByIdAndRemove(id).catch(err => { 214 | if (err.name === 'CastError') { 215 | this.throw(400, 'id不存在'); 216 | } else { 217 | this.throw(500, '服务器内部错误'); 218 | } 219 | }); 220 | ctx.body = { 221 | success: true, 222 | }; 223 | } 224 | 225 | export async function publishArticle(ctx) { 226 | const id = ctx.params.id; 227 | const article = await Article.findByIdAndUpdate(id, { $set: { publish: true } }).catch(err => { 228 | if (err.name === 'CastError') { 229 | this.throw(400, 'id不存在'); 230 | } else { 231 | this.throw(500, '服务器内部错误'); 232 | } 233 | }); 234 | ctx.body = { 235 | success: true, 236 | }; 237 | } 238 | 239 | export async function notPublishArticle(ctx) { 240 | const id = ctx.params.id; 241 | const article = await Article.findByIdAndUpdate(id, { $set: { publish: false } }).catch(err => { 242 | if (err.name === 'CastError') { 243 | this.throw(400, 'id不存在'); 244 | } else { 245 | this.throw(500, '服务器内部错误'); 246 | } 247 | }); 248 | ctx.body = { 249 | success: true, 250 | }; 251 | } 252 | -------------------------------------------------------------------------------- /server/controllers/tags_controller.js: -------------------------------------------------------------------------------- 1 | import Tag from '../models/tag.js'; 2 | import Article from '../models/article'; 3 | 4 | export async function createTag(ctx) { 5 | const tagName = ctx.request.body.name; 6 | if (tagName === '') { 7 | ctx.throw(400, '标签名不能为空'); 8 | } 9 | const tag = await Tag.findOne({ name: tagName }).catch(err => { 10 | ctx.throw(500, '服务器错误'); 11 | }); 12 | console.log(tag); 13 | if (tag !== null) { 14 | ctx.body = { 15 | success: true, 16 | tag: tag, 17 | }; 18 | return; 19 | } 20 | const newTag = new Tag({ 21 | name: tagName, 22 | }); 23 | const result = await newTag.save().catch(err => { 24 | ctx.throw(500, '服务器错误'); 25 | }); 26 | ctx.body = { 27 | success: true, 28 | tag: result, 29 | }; 30 | } 31 | 32 | export async function getAllTags(ctx) { 33 | const tagArr = await Tag.find().catch(err => { 34 | ctx.throw(500, '服务器内部错误'); 35 | }); 36 | ctx.body = { 37 | success: true, 38 | tagArr, 39 | }; 40 | } 41 | 42 | export async function modifyTag(ctx) { 43 | const id = ctx.params.id; 44 | const name = ctx.request.body.name; 45 | if (name === '') { 46 | ctx.throw(400, '标签名不能为空'); 47 | } 48 | const tag = await Article.findByIdAndUpdate(id, { $set: { name: name } }).catch(err => { 49 | if (err.name === 'CastError') { 50 | ctx.throw(400, 'id不存在'); 51 | } else { 52 | ctx.throw(500, '服务器内部错误'); 53 | } 54 | }); 55 | ctx.body = { 56 | success: true, 57 | }; 58 | } 59 | 60 | export async function deleteTag(ctx) { 61 | const id = ctx.params.id; 62 | const tag = await Tag.findByIdAndRemove(id).catch(err => { 63 | if (err.name === 'CastError') { 64 | ctx.throw(400, 'id不存在'); 65 | } else { 66 | ctx.throw(500, '服务器内部错误'); 67 | } 68 | }); 69 | ctx.body = { 70 | success: true, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /server/controllers/token_controller.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user.js'; 2 | import md5 from 'md5'; 3 | import jwt from 'jsonwebtoken'; 4 | import config from '../configs/'; 5 | 6 | export async function initUser() { 7 | let user = await User.find().exec().catch(err => { 8 | console.log(err); 9 | }); 10 | if (user.length === 0) { 11 | // 目前还没做修改密码的功能,因为是单用户系统觉得需求不大 12 | // 如果想更换用户名/密码,先将数据库原有user删除(drop) 13 | // 配置中加入用户名密码,重启服务即可 14 | user = new User({ 15 | name: 'hjm', 16 | username: config.admin.user, 17 | password: md5(config.admin.pwd).toUpperCase(), 18 | avatar: '', 19 | createTime: new Date(), 20 | }); 21 | await user.save().catch(err => { 22 | console.log(err); 23 | }); 24 | } 25 | } 26 | 27 | export async function login(ctx) { 28 | const username = ctx.request.body.username; 29 | const password = ctx.request.body.password; 30 | let user = await User.findOne({ 31 | username, 32 | }).exec(); 33 | if (user !== null) { 34 | if (user.password === password) { 35 | const token = jwt.sign({ 36 | uid: user._id, 37 | name: user.name, 38 | exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60, // 1 hours 39 | }, config.jwt.secret); 40 | ctx.body = { 41 | success: true, 42 | uid: user._id, 43 | name: user.name, 44 | token: token, 45 | }; 46 | } else { 47 | ctx.throw(401, '密码错误'); 48 | } 49 | } else { 50 | ctx.throw(401, '用户名错误'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import koa from 'koa'; 2 | import convert from 'koa-convert'; 3 | import onerror from 'koa-onerror'; 4 | import serve from 'koa-static'; 5 | import mongoose from 'mongoose'; 6 | import historyApiFallback from './middleware/historyApiFallback'; 7 | import config from './configs'; 8 | import middleware from './middleware'; 9 | import api from './api'; 10 | import path from 'path'; 11 | import fs from 'fs'; 12 | import { createBundleRenderer } from 'vue-server-renderer'; 13 | const resolve = file => path.resolve(__dirname, file); 14 | 15 | mongoose.Promise = Promise; 16 | // connect mongodb 17 | mongoose.connect(config.mongodb.url, config.mongodbSecret); 18 | mongoose.connection.on('error', console.error); 19 | 20 | const isProd = process.env.NODE_ENV === 'production'; 21 | const router = require('koa-router')(); 22 | const routerInfo = require('koa-router')(); 23 | 24 | const app = new koa(); 25 | 26 | // middleware 27 | app.use(middleware()); 28 | onerror(app); 29 | 30 | // api/router 31 | app.use(api()); 32 | 33 | app.use(serve('./client/static')); 34 | 35 | // 创建渲染器,开启组件缓存 36 | let renderer; 37 | 38 | function createRenderer(bundle, template) { 39 | return createBundleRenderer(bundle, { 40 | template, 41 | cache: require('lru-cache')({ 42 | max: 1000, 43 | maxAge: 1000 * 60 * 15, 44 | }), 45 | runInNewContext: false, 46 | }); 47 | } 48 | 49 | // 提示webpack还在工作 50 | routerInfo.get('*', async(ctx, next) => { 51 | if (!renderer) { 52 | ctx.body = 'waiting for compilation... refresh in a moment.'; 53 | return ctx.body; 54 | } 55 | return next(); 56 | }); 57 | 58 | app.use(routerInfo.routes()); 59 | 60 | // 对路由admin直接走historyApiFallback,而不是用服务端渲染 61 | app.use(convert(historyApiFallback({ 62 | verbose: true, 63 | index: '/admin.html', 64 | rewrites: [ 65 | { from: /^\/admin$/, to: '/admin.html' }, 66 | { from: /^\/admin\/login/, to: '/admin.html' }, 67 | ], 68 | path: /^\/admin/, 69 | }))); 70 | 71 | if (isProd) { 72 | // 生产环境下直接读取构造渲染器 73 | const bundle = require('../client/dist/vue-ssr-server-bundle.json'); 74 | const template = fs.readFileSync(resolve('../client/dist/front.html'), 'utf-8'); 75 | renderer = createRenderer(bundle, template); 76 | app.use(serve('./client/dist')); 77 | } else { 78 | // 开发环境下使用hot/dev middleware拿到bundle与template 79 | require('../client/build/setup-dev-server')(app, (bundle, template) => { 80 | renderer = createRenderer(bundle, template); 81 | }); 82 | } 83 | 84 | // 流式渲染 85 | router.get('*', async(ctx, next) => { 86 | let req = ctx.req; 87 | // 由于koa内有处理type,此处需要额外修改content-type 88 | ctx.type = 'html'; 89 | const s = Date.now(); 90 | let context = { 91 | title: 'HJM\'s blog', 92 | url: req.url, 93 | renderURLScript: (type) => { 94 | if (config[type].url !== '') { 95 | return ``; 96 | } 97 | return ''; 98 | }, 99 | }; 100 | // let r = renderer.renderToStream(context) 101 | // .on('data', chunk => { 102 | // console.log(chunk) 103 | // console.log("__________________") 104 | // }) 105 | // .on('end', () => console.log(`whole request: ${Date.now() - s}ms`)) 106 | // ctx.body = r 107 | function renderToStringPromise() { 108 | return new Promise((resolve, reject) => { 109 | renderer.renderToString(context, (err, html) => { 110 | if (err) { 111 | console.log(err); 112 | } 113 | if (!isProd) { 114 | console.log(`whole request: ${Date.now() - s}ms`); 115 | } 116 | resolve(html); 117 | }); 118 | }); 119 | } 120 | ctx.body = await renderToStringPromise(); 121 | }); 122 | 123 | app 124 | .use(router.routes()) 125 | .use(router.allowedMethods()); 126 | 127 | // create server 128 | app.listen(config.app.port, () => { 129 | console.log('The server is running at http://localhost:' + config.app.port); 130 | }); 131 | 132 | export default app; 133 | -------------------------------------------------------------------------------- /server/logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BUPT-HJM/vue-blog/747306b702fe84d215478403d61f49332e15f829/server/logs/.gitkeep -------------------------------------------------------------------------------- /server/middleware/historyApiFallback.js: -------------------------------------------------------------------------------- 1 | function historyApiFallback (options) { 2 | const expressMiddleware = require('connect-history-api-fallback')(options); 3 | const url = require('url'); 4 | return (ctx, next) => { 5 | let parseUrl = url.parse(ctx.req.url); 6 | // 添加path match,让不匹配的路由可以直接穿过中间件 7 | if (!parseUrl.pathname.match(options.path)) { 8 | return next(); 9 | } 10 | // 修改content-type 11 | ctx.type = 'html'; 12 | return expressMiddleware(ctx.req, ctx.res, next); 13 | }; 14 | } 15 | 16 | module.exports = historyApiFallback; 17 | -------------------------------------------------------------------------------- /server/middleware/index.js: -------------------------------------------------------------------------------- 1 | import logger from 'koa-logger'; 2 | import bodyParser from 'koa-bodyparser'; 3 | import convert from 'koa-convert'; 4 | import onerror from 'koa-onerror'; 5 | import compress from 'koa-compress'; 6 | 7 | export default function middleware() { 8 | return convert.compose( 9 | logger(), 10 | bodyParser(), 11 | compress({ 12 | filter: function (content_type) { 13 | if (/event-stream/i.test(content_type)) { 14 | // 为了让hot reload生效,不对__webpack_hmr压缩 15 | return false; 16 | } else { 17 | return true; 18 | } 19 | }, 20 | }) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /server/middleware/verify.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import config from '../configs/'; 3 | export default async(ctx, next) => { 4 | // console.log(ctx.get('Authorization')); 5 | const authorization = ctx.get('Authorization'); 6 | if (authorization === '') { 7 | ctx.throw(401, 'no token detected in http header \'Authorization\''); 8 | } 9 | const token = authorization.split(' ')[1]; 10 | let tokenContent; 11 | try { 12 | tokenContent = await jwt.verify(token, config.jwt.secret); 13 | } catch (err) { 14 | if ('TokenExpiredError' === err.name) { 15 | ctx.throw(401, 'token expired,请及时本地保存数据!'); 16 | } 17 | ctx.throw(401, 'invalid token'); 18 | } 19 | console.log('鉴权成功'); 20 | await next(); 21 | }; 22 | -------------------------------------------------------------------------------- /server/models/article.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import moment from 'moment'; 3 | moment.locale('zh-cn'); 4 | const Schema = mongoose.Schema; 5 | const articleSchema = new Schema({ 6 | title: String, 7 | content: String, 8 | abstract: String, 9 | tags: [{ 10 | type: Schema.Types.ObjectId, 11 | ref: 'tag', 12 | }], 13 | publish: { 14 | type: Boolean, 15 | default: false, 16 | }, 17 | createTime: { 18 | type: Date, 19 | }, 20 | lastEditTime: { 21 | type: Date, 22 | default: Date.now, 23 | }, 24 | }, { versionKey: false }); 25 | articleSchema.set('toJSON', { getters: true, virtuals: true }); 26 | articleSchema.set('toObject', { getters: true, virtuals: true }); 27 | articleSchema.path('createTime').get(function (v) { 28 | return moment(v).format('lll'); 29 | }); 30 | articleSchema.path('lastEditTime').get(function (v) { 31 | return moment(v).format('lll'); 32 | }); 33 | module.exports = mongoose.model('article', articleSchema); 34 | -------------------------------------------------------------------------------- /server/models/tag.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | const Schema = mongoose.Schema; 3 | const tagSchema = new Schema({ 4 | name: { 5 | type: String, 6 | default: '', 7 | }, 8 | }, { versionKey: false }); 9 | tagSchema.set('toJSON', { getters: true, virtuals: true }); 10 | tagSchema.set('toObject', { getters: true, virtuals: true }); // 普通+虚拟 11 | 12 | module.exports = mongoose.model('tag', tagSchema); 13 | -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | const Schema = mongoose.Schema; 3 | const userSchema = new Schema({ 4 | name: String, 5 | username: String, 6 | password: String, 7 | avatar: String, 8 | createTime: String, 9 | }, { versionKey: false }); 10 | module.exports = mongoose.model('user', userSchema); 11 | -------------------------------------------------------------------------------- /server/start.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register')({ 2 | presets: ['es2015', 'stage-0'], 3 | }); 4 | require('babel-polyfill'); 5 | 6 | module.exports = require('./index.js'); 7 | 8 | --------------------------------------------------------------------------------