├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .postcssrc.js ├── .prettierrc ├── README.md ├── build ├── checkPort.js ├── setup-dev-server.js ├── vue-loader.config.js ├── webpack.base.config.js ├── webpack.client.config.js └── webpack.server.config.js ├── commitlint.config.js ├── config ├── env.dev.js └── env.prod.js ├── copy.js ├── ecosystem.config.js ├── package.json ├── preinstall.js ├── screenShot ├── front-archive-1.jpg ├── front-archive-2.jpg ├── front-article-1.jpg ├── front-article-2.jpg ├── front-category.jpg ├── front-home.jpg ├── front-leavewords-1.jpg ├── front-leavewords-2.jpg ├── front-movies-1.jpg ├── front-movies-2.jpg ├── front-search.jpg └── front-tag.png ├── server.js ├── server ├── api │ ├── articles.js │ ├── category.js │ ├── comments.js │ ├── count.js │ ├── index.js │ ├── login.js │ ├── mongodump.js │ ├── movies.js │ ├── msgBoard.js │ ├── news.js │ ├── qiniu.js │ ├── resetpwd.js │ ├── tags.js │ ├── viewer.js │ └── visitor.js ├── db │ ├── index.js │ └── schema.js ├── http │ ├── index.js │ └── server-api.js ├── middleware │ ├── confirmToken.js │ ├── confirmUnpublish.js │ └── ratelimit.js ├── plugin │ └── lastMod.js └── utils │ ├── getBrowser.js │ ├── getIp.js │ ├── getOS.js │ ├── highLimitApis.js │ ├── reviseTime.js │ ├── schedule.js │ └── verify.js ├── src ├── App.vue ├── api │ ├── article.js │ ├── category.js │ ├── comments.js │ ├── entertainment.js │ ├── index.js │ ├── messageBoard.js │ ├── qiniu.js │ ├── tags.js │ └── visitor.js ├── app.js ├── assets │ ├── css │ │ ├── emoji-sprite.css │ │ ├── prism.css │ │ └── quill.snow.css │ ├── font │ │ └── sf-arch │ │ │ ├── SF Arch Rival Extended Bold-embed.css │ │ │ ├── SF Arch Rival Extended Bold.css │ │ │ ├── SF Arch Rival Extended Bold.eot │ │ │ ├── SF Arch Rival Extended Bold.svg │ │ │ ├── SF Arch Rival Extended Bold.ttf │ │ │ └── SF Arch Rival Extended Bold.woff │ ├── img │ │ ├── github.png │ │ ├── icp.png │ │ ├── loading.gif │ │ ├── qq.png │ │ └── share.png │ └── js │ │ ├── emoji-data.js │ │ └── prism.js ├── components │ ├── base │ │ └── miss.vue │ ├── dot │ │ └── index.vue │ ├── emoji │ │ └── index.vue │ ├── empty │ │ └── index.vue │ ├── note │ │ └── index.vue │ ├── rating │ │ └── index.vue │ └── splitLine │ │ └── index.vue ├── entry-client.js ├── entry-server.js ├── http │ └── index.js ├── index.template.html ├── mixins │ └── mergeAsyncData.js ├── router │ └── index.js ├── store │ ├── actions.js │ ├── index.js │ └── mutations.js ├── style │ ├── function.scss │ ├── global.scss │ ├── index.scss │ ├── mixins.scss │ ├── reset.scss │ └── theme │ │ ├── dark │ │ ├── color.scss │ │ ├── font.scss │ │ └── index.scss │ │ ├── light │ │ ├── color.scss │ │ ├── font.scss │ │ └── index.scss │ │ └── map.scss ├── utils │ ├── cls.js │ ├── errorCode.js │ ├── generateTree.js │ ├── getBrowserInfo.js │ ├── getElementTop.js │ ├── getRandomCharacter.js │ ├── getRandomColor.js │ ├── getScrollTop.js │ ├── getUrlParams.js │ ├── lazyLoad.js │ ├── loadEle.js │ ├── requestAnimation.js │ ├── scrollTo.js │ ├── siblings.js │ └── storage.js └── views │ ├── archives │ └── index.vue │ ├── article-filter │ └── index.vue │ ├── article │ ├── articleDetail.vue │ └── components │ │ ├── copyright.vue │ │ ├── prevnext.vue │ │ └── share.vue │ ├── category │ └── index.vue │ ├── components │ ├── article-iterator.vue │ ├── comments-item.vue │ ├── comments.vue │ ├── search.vue │ ├── site-introduction.vue │ ├── submit.vue │ └── tags-iterator.vue │ ├── home │ └── index.vue │ ├── layout │ ├── components │ │ ├── header │ │ │ └── index.vue │ │ └── navbar │ │ │ ├── horizontal-navbar.vue │ │ │ ├── index.vue │ │ │ └── vertical-navbar.vue │ └── index.vue │ ├── messageBoard │ └── index.vue │ ├── movies │ └── index.vue │ ├── pannel │ ├── components │ │ ├── pannel-archives.vue │ │ ├── pannel-articles.vue │ │ ├── pannel-catalog.vue │ │ ├── pannel-category.vue │ │ ├── pannel-comments.vue │ │ ├── pannel-introduction.vue │ │ ├── pannel-tags.vue │ │ └── tree-folder.vue │ ├── index.vue │ └── style │ │ └── mixins.scss │ └── tags │ └── index.vue ├── static ├── .gitkeep ├── 404.html ├── gc_back.html ├── img │ ├── avatar │ │ └── avatar.jpeg │ ├── cover │ │ ├── archive.jpeg │ │ ├── articles.jpeg │ │ ├── category.jpg │ │ ├── default.jpg │ │ ├── home.jpg │ │ ├── movie.jpeg │ │ ├── movie.jpg │ │ ├── msgboard.jpeg │ │ └── tags.jpg │ └── favicon.ico ├── js │ ├── qrcode.min.js │ └── ribbon.js └── qc_back.html └── template └── secret.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env"]], 3 | "plugins": [ 4 | "transform-vue-jsx", 5 | "@babel/transform-runtime", 6 | [ 7 | "component", 8 | { 9 | "libraryName": "element-ui", 10 | "styleLibraryName": "theme-chalk" 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | 2 | config/* 3 | src/assets 4 | src/utils/lazyLoad.js 5 | node_modules 6 | dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'vue-eslint-parser', 4 | parserOptions: { 5 | parser: '@babel/eslint-parser', 6 | sourceType: 'module' 7 | }, 8 | env: { 9 | browser: true, 10 | node: true, 11 | es6: true 12 | }, 13 | extends: ['plugin:vue/essential', 'eslint:recommended'], 14 | // required to lint *.vue files 15 | plugins: ['vue', 'html'], 16 | // check if imports actually resolve 17 | 18 | globals: { 19 | BMap: true, 20 | AMap: true, 21 | AMapUI: true, 22 | processEnv: true, 23 | _: true, 24 | Prism: true, 25 | QRCode: true, 26 | QC: true 27 | }, 28 | // add your custom rules here 29 | //it is base on https://github.com/vuejs/eslint-config-vue 30 | rules: { 31 | 'vue/multi-word-component-names': 'off' 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist/* 4 | server/db/secret.js 5 | server/db/copy/* 6 | server/files/movies 7 | package-lock.json 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | .history 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged --allow-empty "$1" 5 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | "postcss-import": {}, 6 | "postcss-url": {}, 7 | // to edit target browsers: use "browserslist" field in package.json 8 | "autoprefixer": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma":"none", 5 | "htmlWhitespaceSensitivity": "ignore", 6 | "printWidth": 120, 7 | "proseWrap ": "preserver" 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # vue-ssr-blog 3 | 4 |
5 | 6 | ![vue](https://img.shields.io/badge/vue-2.x-brightgreen.svg) 7 | ![vue-router](https://img.shields.io/badge/vue--router-3.x-brightgreen.svg) 8 | ![vuex](https://img.shields.io/badge/vuex-3.x-brightgreen.svg) 9 | ![express](https://img.shields.io/badge/express-4.16.2-green.svg) 10 | ![mongodb](https://img.shields.io/badge/mongodb-5.0.2-green.svg) 11 | ![nodejs](https://img.shields.io/badge/node-14.x-green.svg) 12 | 13 |
14 | 15 | ![home](./screenShot/front-home.jpg) 16 | 17 | ## 介绍 18 | 这是一个使用 vue2.x 开发的,记录学习与生活的 [个人博客](https://mapblog.cn "Marcos's Blog")。 19 | 整站选用 ssr 技术进行服务端渲染,具备良好的 SEO 和首屏性能。 20 | 同时,我为它配备了一个使用 `vue3.x`、`typescript`、`vite` 开发的管理系统,详情请移步 [这里](https://github.com/justJokee/vue-blog-admin) 查看。 21 | ## 技术栈 22 | - vue2.x ssr 23 | - vue-router 24 | - vuex 25 | - nodejs 26 | - mongodb 27 | - mongoose 28 | - express 29 | - pm2 30 | - lazyload 31 | ## 主要功能 32 | 33 | - [x] 首页 34 | - [x] 最新文章 35 | - [x] 文章搜索(高亮关键词) 36 | - [x] 导航 37 | - [x] 文章归档 38 | - [x] 文章标签 39 | - [x] 文章分类 40 | - [x] 娱乐 41 | - [x] 电影([爬虫](https://github.com/justJokee/douban-spider)获取豆瓣的观影记录) 42 | - [x] 看过的影视 43 | - [x] 想看的影视 44 | - [x] 在看的影视 45 | - [x] 留言板 46 | - [x] 留言 47 | - [x] 相互回复 48 | - [x] 点赞、取消赞(ip统计) 49 | - [x] 支持 QQ、Github 第三方登录,以支持评论及留言 50 | - [x] 文章筛选 51 | - [x] 按标签 52 | - [x] 按分类 53 | - [x] 按归档时间 54 | - [x] 文章相关 55 | - [x] 评论 56 | - [x] 回复评论 57 | - [x] 文章/评论的赞、取消赞(ip统计) 58 | - [x] pv、评论、点赞统计 59 | - [x] 文档目录自动生成 60 | - [x] 分享 61 | - [x] 快捷看板 62 | - [x] 本站简介 63 | - [x] 最新文章 64 | - [x] 最新评论 65 | - [x] 标签统计 66 | - [x] 文章分类 67 | - [x] 文章归档 68 | - [x] 响应式 69 | - [x] 图片懒加载 70 | - [ ] 友链(留坑,待开发) 71 | - [ ] 首页名言管理(留坑,待开发) 72 | - [ ] 换肤(留坑,待开发) 73 | 74 | ## fork后必读 75 | 在fork项目后,请务必仔细阅读以下说明: 76 | ### 配置文件 77 | 在 `preinstall` hook中,系统将为你自动生成一份默认的配置文件至 `/server/db/secret.js`,它涵盖了数据库、管理系统、三方登录、七牛云等重要配置信息,以确保本项目正常启动,此文件进行了详细注释,因此你可以在项目启动前根据自己的实际情况进行修改。 78 | ### 依赖 79 | 1. 安装mongodb,推荐5.x版本 80 | 2. mongodb中创建名为`/server/db/secret.js[db.db]`(默认配置为 “blog”)的数据库 81 | 3. mongodb中创建用户为`/server/db/secret.js[db.user/pwd]`(默认配置为 “admin 12345”) 82 | 4. 运行时node版本推荐 14.x,实测16.x安装依赖会失败,初步观察为node-sass版本兼容问题,因时间原因目前不打算排查升级,建议直接安装nvm管理工具对node版本进行灵活切换。 83 | 84 | ## Build Setup 85 | 关于部署至生产环境的详细教程,请查看[这篇文章](https://mapblog.cn/app/article/7) 86 | 87 | ```bash 88 | # install dependencies 89 | 90 | npm install 91 | 92 | # serve with hot reload at localhost:6180 93 | 94 | npm run dev 95 | 96 | # build for production with minification 97 | 98 | # 注意,此命令输出生产包至 dist 目录,部署时将 dist 下 的所有目录上传至你的静态服务目录,例如 /usr/local/nginx/htmls 99 | 100 | npm run build 101 | 102 | # serve for production 103 | # 注意,如果你想在本地试运行生产包,请运行以下命令: 104 | 105 | cd dist 106 | npm run start:local 107 | 108 | # 在生产服务器的静态服务目录中 109 | 110 | npm i 111 | 112 | # 直接使用node启动 113 | 114 | npm run start 115 | 116 | # 使用pm2启动 117 | 118 | npm i pm2 -g 119 | npm run pm2:prod 120 | 121 | # 查看部署相关信息 122 | 123 | pm2 show mapblog 124 | 125 | ``` 126 | ## 预览 127 | 128 | ### 首页 129 | ![home](./screenShot/front-home.jpg) 130 | ### 搜索 131 | ![home](./screenShot/front-search.jpg) 132 | ### 文章详情 133 | ![article-1](./screenShot/front-article-1.jpg) 134 | 135 | ![article-2](./screenShot/front-article-2.jpg) 136 | ### 留言板 137 | ![leavewords](./screenShot/front-leavewords-1.jpg) 138 | 139 | ![leavewords](./screenShot/front-leavewords-2.jpg) 140 | ### 电影 141 | ![movies](./screenShot/front-movies-1.jpg) 142 | 143 | ![movies](./screenShot/front-movies-2.jpg) 144 | ### 归档 145 | ![archives](./screenShot/front-archive-1.jpg) 146 | 147 | ![archives](./screenShot/front-archive-2.jpg) 148 | ### 标签 149 | ![tag](./screenShot/front-tag.png) 150 | ### 分类 151 | ![category](./screenShot/front-category.jpg) 152 | 153 | ## 其它 154 | 155 | 1. 文章、评论、留言的点赞按照客户端ip进行统计 156 | 2. 评论、留言具备一定的频率限制 157 | 3. 爬虫固定每天凌晨一点开始同步豆瓣上的相关数据 158 | 4. 爬虫获取的数据以json格式存储至 /server/files/movies 159 | 5. Github登录有时会失败,😌 ~ 墙你太高 160 | 6. 搁置开发:友链功能 161 | 7. 搁置开发:首页名言功能的后端管控 162 | 8. 搁置开发:主题切换 163 | 164 | ## 特别致谢 165 | 166 | 本站前台展示端的UI风格参考自 [hexo](https://hexo.io/zh-cn/) 博客 的 [butterfly](https://github.com/jerryc127/hexo-theme-butterfly) 主题,特此致谢。 167 | 168 | 最后的最后,如果你喜欢这个项目,不妨star鼓励一下~ -------------------------------------------------------------------------------- /build/checkPort.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 检查端口占用并返回一个可用端口 3 | */ 4 | const detect = require('detect-port') 5 | 6 | module.exports = function checkPort(port = 6180) { 7 | return new Promise((resolve) => { 8 | detect(port, (err, _port) => { 9 | if (err) { 10 | console.log(err) 11 | } 12 | if (port == _port) { 13 | resolve(port) 14 | } else { 15 | resolve(_port) 16 | console.log(`port: ${port} was occupied, try port: ${_port}`) 17 | } 18 | }) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /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 | const checkPort = require('./checkPort') 7 | 8 | process.env.NODE_ENV === 'development' 9 | const readFile = (fs, file) => { 10 | try { 11 | return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8') 12 | } catch (e) { 13 | // 14 | } 15 | } 16 | 17 | module.exports = async function setupDevServer(app, cb) { 18 | let bundle, clientManifest 19 | let resolve 20 | 21 | const readyPromise = new Promise(_resolve => { 22 | resolve = _resolve 23 | }) 24 | const ready = (...args) => { 25 | resolve() 26 | cb(...args) 27 | } 28 | const port = await checkPort() 29 | // 注入环境变量 30 | const injectEnv = require(`../config/env.dev.js`) 31 | injectEnv.BASE_URL = `"http://localhost:${port}"` 32 | clientConfig.plugins.push( 33 | // strip dev-only code in Vue source 34 | new webpack.DefinePlugin({ 35 | 'process.env': { ...injectEnv, VUE_ENV: '"client"' } 36 | }) 37 | ) 38 | serverConfig.plugins.push( 39 | // strip dev-only code in Vue source 40 | new webpack.DefinePlugin({ 41 | 'process.env': { ...injectEnv, VUE_ENV: '"server"' } 42 | }) 43 | ) 44 | process.env.__SAFE_PORT__ = port 45 | 46 | /****** client ******/ 47 | 48 | // modify client config to work with hot middleware 49 | // see https://github.com/webpack-contrib/webpack-hot-middleware#readme 50 | clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] 51 | clientConfig.output.filename = '[name].js' 52 | clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin()) 53 | // dev middleware 54 | const clientCompiler = webpack(clientConfig) 55 | const devMiddleware = require('webpack-dev-middleware')(clientCompiler, { 56 | publicPath: clientConfig.output.publicPath, 57 | noInfo: true, 58 | clientLogLevel: 'warning' 59 | }) 60 | 61 | app.use(devMiddleware) 62 | 63 | clientCompiler.plugin('done', stats => { 64 | stats = stats.toJson() 65 | stats.errors.forEach(err => console.error(err)) 66 | stats.warnings.forEach(err => console.warn(err)) 67 | if (stats.errors.length) return 68 | 69 | clientManifest = JSON.parse(readFile(devMiddleware.fileSystem, 'vue-ssr-client-manifest.json')) 70 | if (bundle) { 71 | ready(bundle, { 72 | clientManifest 73 | }) 74 | } 75 | }) 76 | // hot middleware 77 | app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 })) 78 | 79 | /****** server ******/ 80 | 81 | // watch and update server renderer 82 | const serverCompiler = webpack(serverConfig) 83 | // 文件写入内存 84 | const mfs = new MFS() 85 | serverCompiler.outputFileSystem = mfs 86 | serverCompiler.watch({}, (err, stats) => { 87 | if (err) throw err 88 | stats = stats.toJson() 89 | if (stats.errors.length) return 90 | 91 | // read bundle generated by vue-ssr-webpack-plugin 92 | bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) 93 | 94 | if (clientManifest) { 95 | ready(bundle, { 96 | clientManifest 97 | }) 98 | } 99 | }) 100 | 101 | return readyPromise 102 | } 103 | -------------------------------------------------------------------------------- /build/vue-loader.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extractCSS: process.env.NODE_ENV === 'production', 3 | preserveWhitespace: false, 4 | postcss: [ 5 | require('autoprefixer')({ 6 | browsers: ['last 3 versions'] 7 | }) 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /build/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | const path = require('path') 3 | const webpack = require('webpack') 4 | const vueConfig = require('./vue-loader.config') 5 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 6 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 7 | // const CopyWebpackPlugin = require('copy-webpack-plugin') 8 | const isProd = process.env.NODE_ENV === 'production' 9 | 10 | module.exports = { 11 | devtool: isProd ? false : '#cheap-module-source-map', 12 | output: { 13 | path: path.resolve(__dirname, '../dist/front/'), 14 | publicPath: '/', 15 | filename: 'js/[name].[chunkhash].js', 16 | chunkFilename: 'js/[name].[chunkhash].js' 17 | }, 18 | resolve: { 19 | extensions: ['.js', '.vue', '.json'], 20 | alias: { 21 | // 'echarts': 'echarts/dist/echarts.common.min.js', 22 | // 'vue': 'vue/dist/vue.runtime.min.js', 23 | // 'vue': 'vue/dist/vue.js', 24 | '@': path.resolve('src'), 25 | R: path.resolve('src/components') 26 | } 27 | }, 28 | module: { 29 | noParse: /es6-promise\.js$/, // avoid webpack shimming process 30 | rules: [ 31 | { 32 | test: /\.vue$/, 33 | loader: 'vue-loader', 34 | options: vueConfig 35 | }, 36 | { 37 | test: /\.js$/, 38 | loader: 'babel-loader', 39 | exclude: /node_modules/ 40 | }, 41 | { 42 | test: /\.(woff|eot|ttf)\??.*$/, 43 | loader: 'url-loader', 44 | options: { 45 | limit: 10000, 46 | name: 'css/fonts/[name].[hash:8].[ext]' 47 | } 48 | }, 49 | { 50 | test: /\.(gif|jpg|png|svg|webp)\??.*$/, 51 | loader: 'url-loader', 52 | options: { 53 | limit: 10000, 54 | name: 'img/[name].[hash:8].[ext]' 55 | } 56 | }, 57 | { 58 | test: /\.(less|css|scss)$/, 59 | use: isProd 60 | ? ExtractTextPlugin.extract({ 61 | use: ['css-loader?minimize', 'less-loader', 'sass-loader'], 62 | fallback: 'vue-style-loader' 63 | }) 64 | : ['vue-style-loader', 'css-loader', 'less-loader', 'sass-loader'] 65 | } 66 | ] 67 | }, 68 | performance: { 69 | maxEntrypointSize: 300000, 70 | hints: isProd ? 'warning' : false 71 | }, 72 | plugins: isProd 73 | ? [ 74 | new webpack.optimize.UglifyJsPlugin({ 75 | compress: { 76 | warnings: false, 77 | drop_debugger: true, 78 | drop_console: true 79 | }, 80 | sourceMap: false // true 81 | }), 82 | new ExtractTextPlugin({ 83 | filename: 'css/common.[chunkhash].css', 84 | allChunks: true //所有组件的css都打包到一个css文件中 85 | }), 86 | new ExtractTextPlugin({ 87 | filename: 'css/common.[chunkhash].less' 88 | }) 89 | ] 90 | : [new FriendlyErrorsPlugin()] 91 | } 92 | -------------------------------------------------------------------------------- /build/webpack.client.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const base = require('./webpack.base.config') 4 | const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') 5 | const env = process.env.NODE_ENV === 'developemnt' ? 'dev' : 'prod' 6 | 7 | const config = merge(base, { 8 | entry: { 9 | app: './src/entry-client.js', 10 | vendors: ['axios', '@/assets/js/prism.js'], 11 | vues: ['vue', 'vuex', 'vue-router'] 12 | }, 13 | plugins: [ 14 | // extract vendor chunks for better caching 15 | // new webpack.optimize.CommonsChunkPlugin({ 16 | // name: ['vendors', 'echarts'], 17 | // minChunks: function(module) { 18 | // // a module is extracted into the vendor chunk if... 19 | // return ( 20 | // // it's inside node_modules 21 | // /node_modules/.test(module.context) && 22 | // // and not a CSS file (due to extract-text-webpack-plugin limitation) 23 | // !/\.css$/.test(module.request) 24 | // ) 25 | // } 26 | // }), 27 | new webpack.optimize.CommonsChunkPlugin({ 28 | names: ['vendors', 'vues'], 29 | minChunks: 3 30 | }), 31 | // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中, 32 | // 以便可以在之后正确注入异步 chunk。 33 | // 这也为你的 应用程序/vendor 代码提供了更好的缓存。 34 | new webpack.optimize.CommonsChunkPlugin({ 35 | name: 'manifest', 36 | // minChunks: Infinity 37 | chunks: ['vendors', 'vues'] 38 | }), 39 | // 此插件在输出目录中 40 | // 生成 `vue-ssr-client-manifest.json`。 41 | new VueSSRClientPlugin() 42 | ] 43 | }) 44 | 45 | if (process.env.NODE_ENV === 'production') { 46 | const injectEnv = require(`../config/env.prod.js`) 47 | injectEnv.VUE_ENV = '"client"' 48 | config.plugins.push( 49 | // strip dev-only code in Vue source 50 | new webpack.DefinePlugin({ 51 | 'process.env': injectEnv 52 | }) 53 | ) 54 | } 55 | 56 | module.exports = config 57 | -------------------------------------------------------------------------------- /build/webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const base = require('./webpack.base.config') 4 | const nodeExternals = require('webpack-node-externals') 5 | const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') 6 | const config = merge(base, { 7 | // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import), 8 | // 并且还会在编译 Vue 组件时, 9 | // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。 10 | target: 'node', 11 | // 对 bundle renderer 提供 source map 支持 12 | devtool: '#source-map', 13 | entry: './src/entry-server.js', 14 | // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) 15 | output: { 16 | filename: 'server-bundle.js', 17 | libraryTarget: 'commonjs2' 18 | }, 19 | // 外置化应用程序依赖模块。可以使服务器构建速度更快, 20 | // 并生成较小的 bundle 文件。 21 | externals: nodeExternals({ 22 | whitelist: /\.css$/ 23 | }), 24 | plugins: [ 25 | // 这是将服务器的整个输出 26 | // 构建为单个 JSON 文件的插件。 27 | // 默认文件名为 `vue-ssr-server-bundle.json` 28 | new VueSSRServerPlugin() 29 | ] 30 | }) 31 | if (process.env.NODE_ENV === 'production') { 32 | const injectEnv = require(`../config/env.prod.js`) 33 | injectEnv.VUE_ENV = '"server"' 34 | config.plugins.push( 35 | // strip dev-only code in Vue source 36 | new webpack.DefinePlugin({ 37 | 'process.env': injectEnv 38 | }) 39 | ) 40 | } 41 | 42 | module.exports = config 43 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * feat:新增功能 3 | * fix:bug 修复 4 | * docs:文档更新 5 | * style:不影响程序逻辑的代码修改(修改空白字符,格式缩进,补全缺失的分号等,没有改变代码逻辑) 6 | * refactor:重构代码(既没有新增功能,也没有修复 bug) 7 | * perf:性能, 体验优化 8 | * test:新增测试用例或是更新现有测试 9 | * build:主要目的是修改项目构建系统(例如 glup,webpack,rollup 的配置等)的提交 10 | * ci:主要目的是修改项目继续集成流程(例如 Travis,Jenkins,GitLab CI,Circle等)的提交 11 | * chore:不属于以上类型的其他类型,比如构建流程, 依赖管理 12 | * revert:回滚某个更早之前的提交 13 | */ 14 | module.exports = { 15 | extends: ['@commitlint/config-conventional'], 16 | rules: { 17 | 'type-enum': [2, 'always', ['feat', 'fix', 'docs', 'style', 'perf', 'refactor', 'test', 'chore', 'revert']], 18 | 'subject-full-stop': [0, 'never'], 19 | 'subject-case': [0, 'never'] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/env.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 开发环境的变量注入 3 | */ 4 | module.exports = { 5 | NODE_ENV: '"development"', 6 | STATS: '"development"', 7 | BASE_URL: '"http://localhost:6180"' 8 | } 9 | -------------------------------------------------------------------------------- /config/env.prod.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 生产环境的变量注入 3 | */ 4 | module.exports = { 5 | NODE_ENV: '"production"', 6 | STATS: '"production"', 7 | BASE_URL: '"https://mapblog.cn"' 8 | } 9 | -------------------------------------------------------------------------------- /copy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 复制需要部署的文件、目录等 3 | * @author justJokee 4 | */ 5 | 6 | const fs = require('fs-extra') 7 | console.log() 8 | console.log('开始复制部署资源 >>>>') 9 | console.log() 10 | fs.copySync('./static', './dist/front') 11 | fs.copySync('./src/index.template.html', './dist/front/index.template.html') 12 | fs.copySync('./server', './dist/server') 13 | fs.copySync('./server.js', './dist/server.js') 14 | fs.copySync('./ecosystem.config.js', './dist/ecosystem.config.js') 15 | // 处理package.json 16 | const json = fs.readFileSync('./package.json') 17 | const obj = JSON.parse(json) 18 | ;(obj.scripts['start:local'] = '../node_modules/.bin/cross-env NODE_ENV=production node server'), 19 | delete obj.scripts.preinstall 20 | delete obj.scripts.prepare 21 | fs.writeFileSync('./dist/package.json', JSON.stringify(obj, null, '\t'), 'utf8') 22 | 23 | console.log('部署资源复制完毕 >>>>') 24 | console.log() 25 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc pm2 配置文件 3 | * @author justJokee 4 | */ 5 | module.exports = { 6 | apps: [ 7 | { 8 | name: 'mapblog', 9 | cwd: './', 10 | script: 'server.js', 11 | max_memory_restart: '1024M', 12 | log_date_format: 'YYYY-MM-DD HH:mm Z', 13 | exec_mode: 'cluster', 14 | instances: 'max', 15 | watch: 'false', 16 | env: { 17 | NODE_ENV: 'development' 18 | }, 19 | env_production: { 20 | NODE_ENV: 'production' // 环境参数,当前指定为生产环境 21 | } 22 | } 23 | ] 24 | 25 | // deploy: { 26 | // production: { 27 | // user: 'SSH_USERNAME', 28 | // host: 'SSH_HOSTMACHINE', 29 | // ref: 'origin/master', 30 | // repo: 'GIT_REPOSITORY', 31 | // path: 'DESTINATION_PATH', 32 | // 'pre-deploy-local': '', 33 | // 'post-deploy': 'npm install && pm2 reload ecosystem.config.js --env production', 34 | // 'pre-setup': '' 35 | // } 36 | // } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssr", 3 | "version": "1.0.0", 4 | "description": "Marco's Blog", 5 | "author": "justJokee", 6 | "private": true, 7 | "scripts": { 8 | "preinstall": "node ./preinstall.js", 9 | "prepare": "husky install", 10 | "dev": "cross-env NODE_ENV=development node server", 11 | "start": "cross-env NODE_ENV=production node server", 12 | "build": "rimraf dist && npm run build:client && npm run build:server && npm run copy", 13 | "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules", 14 | "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules", 15 | "pm2:prod": "cross-env NODE_ENV=production pm2 start ecosystem.config.js --env production", 16 | "copy": "node copy.js" 17 | }, 18 | "lint-staged": { 19 | "src/**/*.{js,vue}": [ 20 | "prettier --write", 21 | "eslint --fix --ext .js,.jsx,.vue" 22 | ] 23 | }, 24 | "dependencies": { 25 | "@octokit/core": "^3.5.1", 26 | "archiver": "^2.1.1", 27 | "axios": "^0.17.1", 28 | "element-ui": "^2.15.6", 29 | "emojilib": "^3.0.4", 30 | "express": "^4.16.2", 31 | "lodash": "^4.17.21", 32 | "moment": "^2.29.1", 33 | "qs": "^6.5.1", 34 | "resize-observer-polyfill": "^1.5.1", 35 | "ueditor": "^1.2.3", 36 | "vue": "^2.6.10", 37 | "vue-meta": "^1.5.0", 38 | "vue-router": "^3.5.2", 39 | "vue-server-renderer": "^2.6.10", 40 | "vuex": "^3.0.1" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.17.5", 44 | "@babel/eslint-parser": "^7.17.0", 45 | "@babel/plugin-transform-runtime": "^7.17.0", 46 | "@babel/preset-env": "^7.16.0", 47 | "@commitlint/cli": "^16.2.3", 48 | "@commitlint/config-conventional": "^16.2.1", 49 | "autoprefixer": "^7.1.2", 50 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 51 | "babel-loader": "^8.2.3", 52 | "babel-plugin-component": "^1.1.1", 53 | "babel-plugin-syntax-jsx": "^6.18.0", 54 | "babel-plugin-transform-vue-jsx": "^3.5.0", 55 | "body-parser": "^1.18.2", 56 | "chalk": "^2.0.1", 57 | "compression": "^1.7.4", 58 | "cookie-parser": "^1.4.3", 59 | "copy-webpack-plugin": "^4.0.1", 60 | "cross-env": "^5.1.3", 61 | "cross-spawn": "^5.1.0", 62 | "css-loader": "^0.28.0", 63 | "detect-port": "^1.3.0", 64 | "douban-spider-v": "0.0.4", 65 | "ejs": "^2.5.7", 66 | "eslint": "^8.10.0", 67 | "eslint-plugin-html": "^6.2.0", 68 | "eslint-plugin-import": "^2.20.2", 69 | "eslint-plugin-prettier": "3.4.0", 70 | "eslint-plugin-vue": "^8.5.0", 71 | "extract-text-webpack-plugin": "^3.0.0", 72 | "file-loader": "^1.1.4", 73 | "friendly-errors-webpack-plugin": "^1.6.1", 74 | "fs-extra": "^10.0.1", 75 | "html-webpack-plugin": "^2.30.1", 76 | "husky": "^7.0.4", 77 | "js-md5": "^0.7.3", 78 | "jsonwebtoken": "^8.5.1", 79 | "less": "^2.7.3", 80 | "less-loader": "^4.0.5", 81 | "lint-staged": "^12.3.7", 82 | "lru-cache": "^7.5.1", 83 | "md5": "^2.2.1", 84 | "memory-fs": "^0.5.0", 85 | "mongoose": "^4.13.9", 86 | "morgan": "^1.10.0", 87 | "node-notifier": "^5.1.2", 88 | "node-sass": "^4.7.2", 89 | "node-schedule": "^2.1.0", 90 | "optimize-css-assets-webpack-plugin": "^3.2.0", 91 | "ora": "^1.2.0", 92 | "portfinder": "^1.0.13", 93 | "postcss-import": "^11.0.0", 94 | "postcss-loader": "^2.0.8", 95 | "postcss-url": "^7.2.1", 96 | "prettier": "^2.5.1", 97 | "qiniu": "^7.4.0", 98 | "rimraf": "^2.6.0", 99 | "sass": "^1.0.0-beta.4", 100 | "sass-loader": "^6.0.6", 101 | "semver": "^5.3.0", 102 | "serve-favicon": "^2.4.5", 103 | "sw-precache-webpack-plugin": "^0.11.4", 104 | "uglifyjs-webpack-plugin": "^1.1.1", 105 | "url-loader": "^0.5.8", 106 | "vue-eslint-parser": "^8.3.0", 107 | "vue-loader": "^13.3.0", 108 | "vue-style-loader": "^3.0.1", 109 | "vue-template-compiler": "^2.6.10", 110 | "vuex-router-sync": "^5.0.0", 111 | "webpack": "^3.6.0", 112 | "webpack-bundle-analyzer": "^2.9.0", 113 | "webpack-dev-server": "^2.9.1", 114 | "webpack-hot-middleware": "^2.21.0", 115 | "webpack-merge": "^4.1.0", 116 | "webpack-node-externals": "^1.6.0" 117 | }, 118 | "engines": { 119 | "node": ">= 6.0.0", 120 | "npm": ">= 3.0.0" 121 | }, 122 | "browserslist": [ 123 | "> 1%", 124 | "last 2 versions", 125 | "not ie <= 8" 126 | ] 127 | } 128 | -------------------------------------------------------------------------------- /preinstall.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 创建数据库/第三方登录等配置文件 3 | * @author justJokee 4 | */ 5 | 6 | const fs = require('fs') 7 | const path = './server/db/secret.js' 8 | 9 | if (!fs.existsSync(path)) { 10 | const str = fs.readFileSync('./template/secret.js') 11 | fs.writeFileSync(path, str, 'utf8') 12 | 13 | console.log() 14 | console.log('配置文件创建成功 >>>>') 15 | console.log() 16 | } 17 | -------------------------------------------------------------------------------- /screenShot/front-archive-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-archive-1.jpg -------------------------------------------------------------------------------- /screenShot/front-archive-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-archive-2.jpg -------------------------------------------------------------------------------- /screenShot/front-article-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-article-1.jpg -------------------------------------------------------------------------------- /screenShot/front-article-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-article-2.jpg -------------------------------------------------------------------------------- /screenShot/front-category.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-category.jpg -------------------------------------------------------------------------------- /screenShot/front-home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-home.jpg -------------------------------------------------------------------------------- /screenShot/front-leavewords-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-leavewords-1.jpg -------------------------------------------------------------------------------- /screenShot/front-leavewords-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-leavewords-2.jpg -------------------------------------------------------------------------------- /screenShot/front-movies-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-movies-1.jpg -------------------------------------------------------------------------------- /screenShot/front-movies-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-movies-2.jpg -------------------------------------------------------------------------------- /screenShot/front-search.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-search.jpg -------------------------------------------------------------------------------- /screenShot/front-tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/screenShot/front-tag.png -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const path = require('path') 3 | const lruCache = require('lru-cache') 4 | const fs = require('fs') 5 | const cookieParser = require('cookie-parser') 6 | const bodyParser = require('body-parser') 7 | const logger = require('morgan') 8 | const ejs = require('ejs') 9 | const detect = require('detect-port') 10 | const route = require('./server/api/') 11 | const compression = require('compression') 12 | const { createBundleRenderer } = require('vue-server-renderer') 13 | const { startSchedule } = require('./server/utils/schedule') 14 | const isProd = process.env.NODE_ENV === 'production' 15 | const tpl = isProd ? './front/index.template.html' : './src/index.template.html' 16 | const template = fs.readFileSync(tpl, 'utf-8') 17 | const ratelimit = require('./server/middleware/ratelimit') 18 | const getIp = require('./server/utils/getIp') 19 | const server = express() 20 | // nginx 反向代理后获取真实ip 21 | logger.token('remote-addr', (req) => { 22 | return req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress 23 | }) 24 | // const resolve = (file) => path.resolve(__dirname, file) 25 | // 开启流控 26 | server.use('/api', ratelimit) 27 | // 日志记录中间件 28 | server.use(logger(isProd ? 'combined' : 'tiny')) 29 | server.use(bodyParser.json()) 30 | server.use(bodyParser.urlencoded({ extended: true })) 31 | server.use(cookieParser()) 32 | // 开启定时任务 33 | startSchedule() 34 | //引入ejs模板引擎 35 | server.set('views', [path.join(__dirname, isProd ? 'front' : 'static')]) 36 | server.engine('.html', ejs.__express) 37 | server.set('view engine', 'ejs') 38 | route(server) 39 | 40 | const LRU = new lruCache({ 41 | max: 1000, 42 | ttl: 1000 * 60 * 15 43 | }) 44 | 45 | function createRenderer(bundle, options) { 46 | return createBundleRenderer( 47 | bundle, 48 | Object.assign(options, { 49 | template: template, 50 | cache: LRU, 51 | // basedir: isProd ? resolve('./front') : resolve('./dist'), 52 | runInNewContext: false 53 | }) 54 | ) 55 | } 56 | let renderer 57 | // eslint-disable-next-line no-unused-vars 58 | let readyPromise 59 | if (isProd) { 60 | const bundle = require('./front/vue-ssr-server-bundle.json') 61 | const clientManifest = require('./front/vue-ssr-client-manifest.json') 62 | renderer = createRenderer(bundle, { 63 | clientManifest 64 | }) 65 | readyPromise = new Promise((resolve) => { 66 | checkPort().then((port) => { 67 | process.env.__SAFE_PORT__ = port 68 | resolve() 69 | }) 70 | }) 71 | } else { 72 | readyPromise = require('./build/setup-dev-server')(server, (bundle, options) => { 73 | renderer = createRenderer(bundle, options) 74 | }) 75 | } 76 | readyPromise.then(() => { 77 | server.use(compression()) //开启gzip压缩 78 | // 伺服静态资源 79 | if (isProd) { 80 | server.use(express.static(path.join(__dirname, 'front'))) 81 | } else { 82 | server.use(express.static(path.join(__dirname, 'static'))) 83 | } 84 | 85 | // 前端请求 86 | server.get(['/', '/app/*'], (req, res) => { 87 | try { 88 | const context = { 89 | title: 'mapBlog', 90 | url: req.url 91 | } 92 | // 文章详情页、留言板页面请求,在server-render运行时注入客户端IP, 93 | // 否则在在查表时,获取的是生产环境的服务器IP,这将导致根据ip统计的点赞情况失效 94 | if (req.url.startsWith('/app/article/') || req.url === '/app/messageBoard') { 95 | context._ip = getIp(req) 96 | } 97 | renderer.renderToString(context, (err, html) => { 98 | if (err) { 99 | res.status(500).end('Internal Server Error') 100 | return 101 | } 102 | if (context.meta) { 103 | const { title, meta } = context.meta.inject() 104 | html = html.replace(//g, title.text()) 105 | html = html.replace(//g, meta.text()) 106 | } 107 | 108 | res.end(html) 109 | }) 110 | } catch (e) { 111 | res.status(500).end('Internal Server Error') 112 | } 113 | }) 114 | 115 | server.get('*', function (req, res) { 116 | res.render('404.html', { 117 | title: 'No Found' 118 | }) 119 | }) 120 | 121 | const port = process.env.__SAFE_PORT__ 122 | const uri = 'http://localhost:' + port 123 | console.log() 124 | console.log('启动服务路径' + uri) 125 | console.log() 126 | server.listen(port, '0.0.0.0') 127 | }) 128 | // 检查端口占用情况 129 | function checkPort(port = 6180) { 130 | return new Promise((resolve) => { 131 | detect(port, (err, _port) => { 132 | if (err) { 133 | console.log(err) 134 | } 135 | if (port == _port) { 136 | resolve(port) 137 | } else { 138 | resolve(_port) 139 | console.log(`port: ${port} was occupied, try port: ${_port}`) 140 | } 141 | }) 142 | }) 143 | } 144 | -------------------------------------------------------------------------------- /server/api/category.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 文章分类 3 | * @author justJokee 4 | */ 5 | 6 | const express = require('express') 7 | const router = express.Router() 8 | const db = require('../db/') 9 | 10 | // 获取分类列表 11 | router.get('/api/front/category/get', async (req, res) => { 12 | try { 13 | const doc = await db.category.find({}).sort({ _id: 1 }) 14 | res.json({ 15 | status: 200, 16 | data: doc, 17 | total: doc.length, 18 | info: '获取文档分类成功' 19 | }) 20 | } catch (e) { 21 | res.status(500).end() 22 | } 23 | }) 24 | 25 | module.exports = router 26 | -------------------------------------------------------------------------------- /server/api/count.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 统计类 3 | * @author justJokee 4 | */ 5 | const express = require('express') 6 | const router = express.Router() 7 | const db = require('../db/') 8 | const confirmToken = require('../middleware/confirmToken') 9 | 10 | // 管理端首页统计项 11 | router.get('/api/admin/count', confirmToken, async (req, res) => { 12 | try { 13 | const article = await db.article.find({}).count() 14 | const comment = await db.comment.find({}).count() 15 | const msgBoard = await db.msgBoard.find({}).count() 16 | const visitor = await db.visitor.find({}).count() 17 | res.json({ 18 | status: 200, 19 | data: { 20 | total: { 21 | article, 22 | visitor, 23 | comment, 24 | msgBoard 25 | } 26 | }, 27 | info: '资源统计成功' 28 | }) 29 | } catch (e) { 30 | res.status(500).end() 31 | } 32 | }) 33 | 34 | module.exports = router 35 | -------------------------------------------------------------------------------- /server/api/index.js: -------------------------------------------------------------------------------- 1 | const articles = require('./articles') 2 | const tags = require('./tags') 3 | const comments = require('./comments') 4 | const msgBoard = require('./msgBoard') 5 | const visitor = require('./visitor') 6 | const login = require('./login') 7 | const mongodump = require('./mongodump') 8 | const news = require('./news') 9 | const resetpwd = require('./resetpwd') 10 | const category = require('./category') 11 | const movies = require('./movies') 12 | const qiniu = require('./qiniu') 13 | const viewer = require('./viewer') 14 | const count = require('./count') 15 | 16 | module.exports = (app) => { 17 | app.use(viewer) 18 | app.use(articles) 19 | app.use(tags) 20 | app.use(comments) 21 | app.use(msgBoard) 22 | app.use(login) 23 | app.use(visitor) 24 | app.use(mongodump) 25 | app.use(news) 26 | app.use(resetpwd) 27 | app.use(category) 28 | app.use(movies) 29 | app.use(qiniu) 30 | app.use(count) 31 | } 32 | -------------------------------------------------------------------------------- /server/api/login.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 登录鉴权 3 | * @author justJokee 4 | */ 5 | const express = require('express') 6 | const router = express.Router() 7 | const jwt = require('jsonwebtoken') 8 | const db = require('../db/') 9 | const { userSecret } = require('../db/secret') 10 | 11 | const createToken = (id, account) => { 12 | const secret = `${userSecret.salt}` 13 | return jwt.sign( 14 | { 15 | id: id, 16 | account: account 17 | }, 18 | secret, 19 | { expiresIn: '4h' } 20 | ) 21 | } 22 | // 登录验证 23 | router.post('/api/admin/login', async (req, res) => { 24 | try { 25 | const user = await db.user.find({ account: req.body.account }) 26 | // 用户名不存在,返回401 27 | if (!user.length) { 28 | res.json({ 29 | status: 402, 30 | info: '用户名或密码不正确' 31 | }) 32 | return 33 | } 34 | const pwd = user[0].password 35 | if (req.body.password === pwd) { 36 | const token = createToken(user[0]._id, user[0].account) 37 | res.json({ 38 | status: 200, 39 | data: { 40 | token, 41 | uid: user[0]._id, 42 | account: user[0].account, 43 | avatar: user[0].avatar, 44 | lastLoginTime: user[0].lastLoginTime 45 | }, 46 | info: '登陆成功' 47 | }) 48 | // 更新登录时间 49 | await db.user.update({ user: req.body.user }, { lastLoginTime: new Date() }) 50 | } else { 51 | res.json({ 52 | status: 402, 53 | info: '用户名或密码不正确' 54 | }) 55 | } 56 | } catch (e) { 57 | res.status(500).end() 58 | } 59 | }) 60 | 61 | module.exports = router 62 | -------------------------------------------------------------------------------- /server/api/mongodump.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 数据库备份 3 | * @author justJokee 4 | */ 5 | const express = require('express') 6 | const router = express.Router() 7 | const exec = require('child_process').exec 8 | const archiver = require('archiver') 9 | const fs = require('fs-extra') 10 | const path = require('path') 11 | const confirmToken = require('../middleware/confirmToken') 12 | const { db } = require('../db/secret') 13 | const moment = require('moment') 14 | // 备份文件临时存放路径 15 | const outputPath = path.join(__dirname, '../db/copy/temp') 16 | let toolPath = '/usr/local/mongo-tools/bin/mongodump' 17 | // 生产环境 mongodump 路径 18 | if (process.env.NODE_ENV === 'production') { 19 | toolPath = 'mongodump' 20 | } 21 | router.post('/api/admin/mongodump', confirmToken, async (req, res) => { 22 | try { 23 | // 清空临时存放路径 24 | fs.emptyDirSync(outputPath) 25 | const filename = moment().format('YYYY-MM-DD-HH-mm') 26 | const zipPath = path.join(__dirname, `../db/copy/mongodump-${filename}.zip`) 27 | exec(`${toolPath} -h 127.0.0.1:27017 -u ${db.user} -p ${db.pwd} -d ${db.db} -o ${outputPath}`, (err) => { 28 | if (err) { 29 | res.status(500).end() 30 | } else { 31 | const archive = archiver('zip') 32 | const output = fs.createWriteStream(zipPath) 33 | output.on('close', () => { 34 | const row = fs.readFileSync(zipPath) 35 | res.send(row) 36 | }) 37 | archive.pipe(output) 38 | // 第二个参数是压缩包内的层级目录 39 | archive.directory(path.join(__dirname, '../db/copy/temp/mapblog'), '/mapblog') 40 | archive.finalize() 41 | } 42 | }) 43 | } catch (e) { 44 | res.status(500).end() 45 | } 46 | }) 47 | 48 | module.exports = router 49 | -------------------------------------------------------------------------------- /server/api/movies.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 豆瓣电影 3 | * @author justJokee 4 | */ 5 | const express = require('express') 6 | const router = express.Router() 7 | const path = require('path') 8 | const fs = require('fs-extra') 9 | 10 | // 获取电影列表 11 | router.get('/api/front/douban/get', async (req, res) => { 12 | try { 13 | const page = req.query.page || 1 14 | const type = req.query.type || 'collect' 15 | const tp = path.join(__dirname, '..', `files/movies/${type}/${page}.json`) 16 | const exist = fs.existsSync(tp) 17 | 18 | if (exist) { 19 | const doc = fs.readFileSync(tp, 'utf8') 20 | const pageTotal = fs.readFileSync(path.join(__dirname, '..', `files/movies/${type}/pageTotal.txt`), 'utf8') 21 | res.json({ 22 | status: 200, 23 | data: JSON.parse(doc), 24 | pageTotal: parseInt(pageTotal) || 1, 25 | info: '获取影视记录成功' 26 | }) 27 | } else { 28 | res.json({ 29 | status: 404, 30 | data: [], 31 | info: '数据不存在' 32 | }) 33 | } 34 | } catch (e) { 35 | res.status(500).end() 36 | } 37 | }) 38 | 39 | module.exports = router 40 | -------------------------------------------------------------------------------- /server/api/news.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 消息管理 3 | * @author justJokee 4 | */ 5 | 6 | const express = require('express') 7 | const router = express.Router() 8 | const db = require('../db/') 9 | const confirmToken = require('../middleware/confirmToken') 10 | 11 | // 获取消息列表 12 | router.get('/api/admin/news/gets', confirmToken, async (req, res) => { 13 | try { 14 | const limit = parseInt(req.query.limit) || 10 15 | const skip = req.query.page * limit - limit 16 | delete req.query.page 17 | delete req.query.limit 18 | const doc = await db.news 19 | .find({ ...req.query }) 20 | .sort({ _id: -1 }) 21 | .skip(skip) 22 | .limit(limit) 23 | 24 | const total = await db.news.count({ ...req.query }) 25 | 26 | res.json({ 27 | status: 200, 28 | data: doc, 29 | total, 30 | info: '获取消息成功' 31 | }) 32 | } catch (e) { 33 | res.status(500).end() 34 | } 35 | }) 36 | // 已读消息 37 | router.patch('/api/admin/news/read', confirmToken, async (req, res) => { 38 | try { 39 | const params = typeof req.body.id === 'string' ? { _id: req.body.id } : { _id: { $in: req.body.id } } 40 | await db.news.updateMany(params, { $set: { read: 1 } }) 41 | res.json({ 42 | status: 200, 43 | info: '设置已读消息成功' 44 | }) 45 | } catch (e) { 46 | res.status(500).end() 47 | } 48 | }) 49 | // 获取所有未读消息数量 50 | router.get('/api/admin/news/unReadTotal', confirmToken, async (req, res) => { 51 | try { 52 | const total = await db.news.count({ read: 0 }) 53 | res.json({ 54 | status: 200, 55 | total, 56 | info: '未读消息数量统计成功' 57 | }) 58 | } catch (e) { 59 | res.status(500).end() 60 | } 61 | }) 62 | // 将所有消息设置为已读 63 | router.patch('/api/admin/news/readAll', confirmToken, async (req, res) => { 64 | try { 65 | await db.news.updateMany({ read: 0 }, { $set: { read: 1 } }) 66 | res.json({ 67 | status: 200, 68 | info: '设置所有消息已读成功' 69 | }) 70 | } catch (e) { 71 | res.status(500).end() 72 | } 73 | }) 74 | // 删除消息 75 | router.delete('/api/admin/news/del', confirmToken, async (req, res) => { 76 | try { 77 | const params = typeof req.query.id === 'string' ? { _id: req.query.id } : { _id: { $in: req.query.id } } 78 | await db.news.remove(params) 79 | res.json({ 80 | status: 200, 81 | info: '删除消息成功' 82 | }) 83 | } catch (e) { 84 | res.status(500).end() 85 | } 86 | }) 87 | 88 | module.exports = router 89 | -------------------------------------------------------------------------------- /server/api/qiniu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 文章分类 3 | * @author justJokee 4 | */ 5 | const express = require('express') 6 | const router = express.Router() 7 | const qiniu = require('qiniu') 8 | const { qiniuConfig } = require('../db/secret') 9 | const accessKey = qiniuConfig.AccessKey 10 | const secretKey = qiniuConfig.SecretKey 11 | const mac = new qiniu.auth.digest.Mac(accessKey, secretKey) 12 | const options = { 13 | scope: qiniuConfig.Bucket 14 | } 15 | const confirmToken = require('../middleware/confirmToken') 16 | 17 | // 获取分类列表 18 | router.get('/api/front/qiniu/getToken', confirmToken, async (req, res) => { 19 | try { 20 | const putPolicy = new qiniu.rs.PutPolicy(options) 21 | const uploadToken = putPolicy.uploadToken(mac) 22 | res.json({ 23 | status: 200, 24 | data: { 25 | token: uploadToken 26 | }, 27 | info: '获取七牛云token成功' 28 | }) 29 | } catch (e) { 30 | res.status(500).end() 31 | } 32 | }) 33 | 34 | module.exports = router 35 | -------------------------------------------------------------------------------- /server/api/resetpwd.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const db = require('../db/') 4 | const confirmToken = require('../middleware/confirmToken') 5 | 6 | router.patch('/api/admin/pwd/reset', confirmToken, async (req, res) => { 7 | const user = await db.user.find({ _id: req.body.uid }) 8 | if (user.length) { 9 | // 旧密码不正确 10 | if (req.body.oldPassword !== user[0].password) { 11 | res.json({ 12 | status: 201, 13 | info: '旧密码不正确' 14 | }) 15 | } else { 16 | await db.user.update({ _id: req.body.uid }, { $set: { password: req.body.password } }) 17 | res.json({ 18 | status: 200, 19 | info: '密码修改成功,请重新登录' 20 | }) 21 | } 22 | } else { 23 | res.json({ 24 | status: 104, 25 | info: '用户不存在' 26 | }) 27 | } 28 | }) 29 | module.exports = router 30 | -------------------------------------------------------------------------------- /server/api/tags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 文章标签 3 | * @author justJokee 4 | */ 5 | const express = require('express') 6 | const router = express.Router() 7 | const db = require('../db/') 8 | 9 | // 获取文档标签统计数量 10 | router.get('/api/front/tags/count', async (req, res) => { 11 | try { 12 | const tags = await db.article.aggregate([ 13 | { $match: { publish: 1 } }, 14 | { $unwind: '$tag' }, 15 | { $group: { _id: '$tag', total: { $sum: 1 } } }, 16 | { $project: { tag: '$_id', _id: 0, total: 1 } } 17 | ]) 18 | res.json({ 19 | status: 200, 20 | data: tags, 21 | total: tags.length, 22 | info: '获取标签统计成功' 23 | }) 24 | } catch (e) { 25 | res.status(500).end() 26 | } 27 | }) 28 | 29 | module.exports = router 30 | -------------------------------------------------------------------------------- /server/api/viewer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 访客信息 3 | * @author justJokee 4 | */ 5 | 6 | const express = require('express') 7 | const router = express.Router() 8 | const db = require('../db/') 9 | const getIp = require('../utils/getIp') 10 | const getBrowser = require('../utils/getBrowser') 11 | const getOS = require('../utils/getOS') 12 | 13 | // 统计访客数量、信息(按前端刷新次数计算) 14 | router.get(['/', '/app/*'], async (req, res, next) => { 15 | try { 16 | if (process.env.NODE_ENV === 'production') { 17 | const ip = getIp(req) 18 | const browser = getBrowser(req.headers['user-agent']) 19 | const system = getOS(req.headers['user-agent']) 20 | await new db.viewer({ 21 | ip, 22 | count: 1, 23 | browser: browser, 24 | system: system, 25 | date: new Date() 26 | }).save() 27 | } 28 | next() 29 | } catch (e) { 30 | next() 31 | } 32 | }) 33 | // 按天返回某一段时间范围内的访客数量 34 | router.get('/api/admin/viewer/history', async (req, res) => { 35 | try { 36 | const doc = await db.viewer.aggregate([ 37 | { 38 | $match: { 39 | date: { 40 | $gte: new Date(parseInt(req.query.start)), 41 | $lte: new Date(parseInt(req.query.end)) 42 | } 43 | } 44 | }, 45 | { 46 | $project: { 47 | date: { $dateToString: { format: '%Y-%m-%d', date: '$date' } } 48 | } 49 | }, 50 | { 51 | $group: { 52 | _id: '$date', 53 | total: { $sum: 1 } 54 | } 55 | }, 56 | { 57 | $project: { 58 | date: '$_id', 59 | value: '$total', 60 | _id: 0 61 | } 62 | }, 63 | { $sort: { date: 1 } } 64 | ]) 65 | res.json({ 66 | status: 200, 67 | data: doc, 68 | info: '查询访问记录成功' 69 | }) 70 | } catch (e) { 71 | res.status(500).end() 72 | } 73 | }) 74 | // 查询访客设备信息 75 | router.get('/api/front/viewer/getDevice', async (req, res) => { 76 | try { 77 | const browser = await db.viewer.aggregate([ 78 | { 79 | $group: { 80 | _id: '$browser', 81 | total: { $sum: 1 } 82 | } 83 | }, 84 | { 85 | $project: { 86 | name: '$_id', 87 | value: '$total', 88 | _id: 0 89 | } 90 | } 91 | ]) 92 | const system = await db.viewer.aggregate([ 93 | { 94 | $group: { 95 | _id: '$system', 96 | total: { $sum: 1 } 97 | } 98 | }, 99 | { 100 | $project: { 101 | name: '$_id', 102 | value: '$total', 103 | _id: 0 104 | } 105 | } 106 | ]) 107 | 108 | res.json({ 109 | status: 200, 110 | data: { browser, system }, 111 | info: '访客设备信息统计成功' 112 | }) 113 | } catch (e) { 114 | res.status(500).end() 115 | } 116 | }) 117 | // 查询访客总数 118 | router.get('/api/front/admin/count', async (req, res) => { 119 | try { 120 | const total = await db.viewer.find({}).count() 121 | const doc = await db.viewer.find({}).sort({ _id: -1 }).limit(1) 122 | res.json({ 123 | status: 200, 124 | data: { total, latest: doc[0] }, 125 | info: '访客设备信息统计成功' 126 | }) 127 | } catch (e) { 128 | res.status(500).end() 129 | } 130 | }) 131 | module.exports = router 132 | -------------------------------------------------------------------------------- /server/api/visitor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 访客信息处理 3 | * @author justJokee 4 | */ 5 | 6 | const express = require('express') 7 | const { Octokit } = require('@octokit/core') 8 | const api = require('../http/server-api') 9 | const router = express.Router() 10 | const db = require('../db/') 11 | const { githubSecret } = require('../db/secret') 12 | 13 | // 存储访客信息 14 | router.post('/api/front/visitor/save', async (req, res) => { 15 | try { 16 | // 自定义用户 17 | if (req.body.type === '0') { 18 | const exist = await db.visitor.find({ name: req.body.name }) 19 | if (exist.length) { 20 | res.json({ status: 100, info: '用户名已存在' }) 21 | return 22 | } 23 | } 24 | 25 | const doc = await new db.visitor(req.body).save() 26 | 27 | res.json({ 28 | status: 200, 29 | data: doc, 30 | info: '访客信息存储成功' 31 | }) 32 | } catch (e) { 33 | res.status(500).end() 34 | } 35 | }) 36 | 37 | // 查看访客是否已经被存储 38 | router.post('/api/front/visitor/existed', async (req, res) => { 39 | try { 40 | // QQ用户 41 | if (req.body.type === '1') { 42 | const exist = await db.visitor.find({ qqOpenId: req.body.qqOpenId }) 43 | if (exist.length) { 44 | // 仅更新昵称和头像 45 | await db.visitor.update( 46 | { qqOpenId: req.body.qqOpenId }, 47 | { $set: { name: req.body.name, imgUrl: req.body.imgUrl } } 48 | ) 49 | const doc = await db.visitor.find({ qqOpenId: req.body.qqOpenId }) 50 | res.json({ status: 200, info: '访客信息已存在', data: { info: doc[0], _saved: 1 } }) 51 | return 52 | } 53 | } 54 | res.json({ 55 | status: 200, 56 | data: { 57 | _saved: 0 58 | }, 59 | info: '访客信息不存在' 60 | }) 61 | } catch (e) { 62 | res.status(500).end() 63 | } 64 | }) 65 | 66 | // github登录 67 | 68 | router.get('/login/git', (req, res) => { 69 | const baseUrl = process.env.NODE_ENV === 'production' ? 'https://mapblog.cn' : 'http://localhost:6180' 70 | //请替换为自己的client_id 71 | let path = `https://github.com/login/oauth/authorize?client_id=${githubSecret.clientId}&scope=['user']&redirect_uri=${baseUrl}/login_github` 72 | res.redirect(path) 73 | res.status(200).end() 74 | }) 75 | router.get('/login_github', (req, res) => { 76 | try { 77 | const baseUrl = process.env.NODE_ENV === 'production' ? 'https://mapblog.cn' : 'http://localhost:6180' 78 | 79 | const params = { 80 | client_id: githubSecret.clientId, 81 | client_secret: githubSecret.secret, 82 | code: req.query.code, 83 | scope: ['user'], 84 | redirect_uri: `${baseUrl}/login_github` 85 | } 86 | api 87 | .post('https://github.com/login/oauth/access_token', JSON.parse(JSON.stringify(params))) 88 | .then((fullData) => { 89 | const arr1 = fullData.split('&') 90 | const arr2 = arr1[0].split('=') 91 | const token = arr2[1] 92 | return token 93 | }) 94 | .then(async (token) => { 95 | let userInfo = {} 96 | const octokit = new Octokit({ auth: `${token}` }) 97 | const info = await octokit.request('GET /user') 98 | // 查看访客表是否已经存在此用户 99 | const visitor = await db.visitor.find({ githubId: info.data.id }) 100 | // 存在此用户 101 | if (visitor.length) { 102 | await db.visitor.update( 103 | { githubId: info.data.id }, 104 | { $set: { name: info.data.login, imgUrl: info.data.avatar_url } } 105 | ) 106 | const doc = await db.visitor.find({ githubId: info.data.id }) 107 | userInfo = { 108 | ...doc[0].toObject(), 109 | // 前端判断存在的标识 110 | _saved: 1 111 | } 112 | } 113 | res.render('gc_back.html', { title: 'github登陆成功', userInfo: JSON.stringify(userInfo) }) 114 | }) 115 | } catch (e) { 116 | res.status(500).end() 117 | } 118 | }) 119 | 120 | module.exports = router 121 | -------------------------------------------------------------------------------- /server/db/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 数据库连接相关入口 3 | * @author justjokee 4 | */ 5 | 6 | const mongoose = require('mongoose') 7 | const md5 = require('js-md5') 8 | const { db: dbSecret, userSecret } = require('./secret') 9 | const { 10 | userSchema, 11 | visitorsSchema, 12 | categorySchema, 13 | articleSchema, 14 | commentSchema, 15 | msgBoardSchema, 16 | newsSchema, 17 | counterSchema, 18 | commentIpSchema, 19 | viewerSchema 20 | } = require('./schema') 21 | 22 | // 实现articleId自增序列 23 | articleSchema.pre('save', async function (next) { 24 | const that = this 25 | try { 26 | const counterDoc = await db.counter.find({}) 27 | if (!counterDoc.length) { 28 | await new db.counter({ _id: 'entityId', seq: 2 }).save() 29 | that.articleId = 1 30 | } else { 31 | const curCountDoc = await db.counter.findByIdAndUpdate( 32 | { _id: 'entityId' }, 33 | { 34 | $inc: { 35 | seq: 1 36 | } 37 | } 38 | ) 39 | that.articleId = curCountDoc.seq 40 | } 41 | next() 42 | } catch (e) { 43 | next(e) 44 | } 45 | }) 46 | 47 | const db = { 48 | user: mongoose.model('user', userSchema), 49 | article: mongoose.model('article', articleSchema), 50 | category: mongoose.model('categorys', categorySchema), 51 | comment: mongoose.model('comment', commentSchema), 52 | msgBoard: mongoose.model('msgBoard', msgBoardSchema), 53 | visitor: mongoose.model('visitor', visitorsSchema), 54 | news: mongoose.model('new', newsSchema), 55 | counter: mongoose.model('counter', counterSchema), 56 | commentIp: mongoose.model('commentIp', commentIpSchema), 57 | viewer: mongoose.model('viewer', viewerSchema) 58 | } 59 | 60 | // 初次启动服务后创建用户 61 | const initUser = async () => { 62 | const user = await db.user.find({}) 63 | 64 | if (!user.length) { 65 | const { account, pwd, salt, avatar } = userSecret 66 | await new db.user({ 67 | account, 68 | password: md5(pwd), 69 | salt: salt, 70 | avatar, 71 | lastLoginTime: new Date() 72 | }).save() 73 | console.log() 74 | console.log('you have created account now!') 75 | console.log() 76 | } 77 | } 78 | // TODO: 后续会增加分类管理 79 | const initCategorys = async () => { 80 | const category = await db.category.find({}) 81 | 82 | if (!category.length) { 83 | await new db.category({ 84 | name: '技术文档', 85 | total: 0, 86 | createTime: new Date() 87 | }).save() 88 | await new db.category({ 89 | name: '生活感悟', 90 | total: 0, 91 | createTime: new Date() 92 | }).save() 93 | console.log() 94 | console.log('you have created category now!') 95 | console.log() 96 | } 97 | } 98 | // 建立连接 99 | mongoose.Promise = global.Promise 100 | mongoose.connection.openUri(`mongodb://${dbSecret.user}:${dbSecret.pwd}@localhost:27017/${dbSecret.db}`) 101 | mongoose.connection.once('open', () => { 102 | initUser() 103 | initCategorys() 104 | }) 105 | 106 | module.exports = db 107 | -------------------------------------------------------------------------------- /server/db/schema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc DB schema 3 | * @author justJokee 4 | */ 5 | const mongoose = require('mongoose') 6 | 7 | const userSchema = new mongoose.Schema({ 8 | account: 'string', 9 | password: 'string', 10 | salt: 'string', 11 | avatar: 'string', 12 | lastLoginTime: Date 13 | }) 14 | const viewerSchema = new mongoose.Schema({ 15 | ip: 'string', 16 | count: 'number', 17 | browser: 'string', 18 | system: 'string', 19 | date: 'date' 20 | }) 21 | const visitorsSchema = new mongoose.Schema({ 22 | name: 'string', 23 | imgUrl: 'string', 24 | email: 'string', 25 | link: 'string', 26 | // 0: 自定义用户 1: qq 2: github 27 | type: 'number', 28 | githubId: 'number', 29 | qqOpenId: 'string', 30 | date: 'date' 31 | }) 32 | // 文章分类 33 | const categorySchema = new mongoose.Schema({ 34 | name: { 35 | type: 'string', 36 | required: true 37 | }, 38 | total: 'number', 39 | createTime: 'date', 40 | updateTime: 'date' 41 | }) 42 | const articleSchema = new mongoose.Schema({ 43 | articleId: 'number', 44 | categoryId: mongoose.Schema.Types.ObjectId, 45 | original: 'number', 46 | title: 'string', 47 | abstract: 'string', 48 | content: 'string', 49 | // 当前最新编辑内容,当文档发布后,与content保持严格一致 50 | content_draft: 'string', 51 | content_plain: 'string', 52 | headerPic: 'string', 53 | // 是否发布 0:未发布 1:发布 54 | // 新建文档初始值为 0 55 | // 文档编辑阶段 可保存,可发布/更新; 56 | publish: 'number', 57 | // 是否存在未经发布的编辑内容 0:不存在(即已发布) 1:存在最新编辑内容 58 | editing: 'number', 59 | tag: 'array', 60 | commentNum: 'number', 61 | likeNum: 'number', 62 | pv: 'number', 63 | createTime: 'date', 64 | updateTime: 'date' 65 | }) 66 | const commentSchema = new mongoose.Schema({ 67 | name: 'string', 68 | imgUrl: 'string', 69 | email: 'string', 70 | content: 'string', 71 | link: 'string', 72 | like: 'number', 73 | aite: 'string', 74 | articleId: 'number', 75 | title: 'string', 76 | date: 'date', 77 | // 是否管理员 78 | admin: 'number', 79 | parentId: mongoose.Schema.Types.ObjectId 80 | }) 81 | const msgBoardSchema = new mongoose.Schema({ 82 | name: 'string', 83 | imgUrl: 'string', 84 | email: 'string', 85 | content: 'string', 86 | link: 'string', 87 | like: 'number', 88 | aite: 'string', 89 | parentId: mongoose.Schema.Types.ObjectId, 90 | // 是否管理员 91 | admin: 'number', 92 | date: 'date' 93 | }) 94 | const newsSchema = new mongoose.Schema({ 95 | type: 'string', 96 | // 文章评论/留言板 97 | name: 'string', 98 | ip: 'string', 99 | lng: 'string', 100 | lat: 'string', 101 | date: 'date', 102 | // 国际区域 103 | nation: 'string', 104 | // 省份 105 | province: 'string', 106 | city: 'string', 107 | // 区域 108 | district: 'string', 109 | // 是否已读 110 | read: 'number', 111 | // 存储 _id 112 | articleId: mongoose.Schema.Types.ObjectId, 113 | commentId: mongoose.Schema.Types.ObjectId, 114 | leaveMessageId: mongoose.Schema.Types.ObjectId, 115 | content: 'string' 116 | }) 117 | const commentIpSchema = new mongoose.Schema({ 118 | ip: { 119 | type: 'string', 120 | required: true 121 | }, 122 | // 0: 留言 1: 文章评论 2: 文章赞 123 | type: { 124 | type: 'number', 125 | required: true 126 | }, 127 | msgid: { 128 | type: mongoose.Schema.Types.ObjectId, 129 | required: true 130 | }, 131 | like: { 132 | type: 'number', 133 | required: true 134 | }, 135 | createTime: 'date', 136 | updateTime: 'date' 137 | }) 138 | 139 | const counterSchema = new mongoose.Schema({ 140 | _id: 'string', 141 | seq: 'number' 142 | }) 143 | 144 | module.exports = { 145 | userSchema, 146 | visitorsSchema, 147 | categorySchema, 148 | articleSchema, 149 | commentSchema, 150 | msgBoardSchema, 151 | newsSchema, 152 | counterSchema, 153 | commentIpSchema, 154 | viewerSchema 155 | } 156 | -------------------------------------------------------------------------------- /server/http/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const qs = require('qs') 3 | axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8' 4 | axios.defaults.headers.get['Content-Type'] = 'application/json;charset=UTF-8' 5 | 6 | function ajax(type, url, options) { 7 | return new Promise((resolve, reject) => { 8 | axios({ 9 | method: type, 10 | url: url, 11 | // baseURL: "https://www.mapblog.cn", 12 | baseURL: process.env.BASE_URL, 13 | params: type === 'get' ? options : null, 14 | data: type !== 'get' ? qs.stringify(options) : null 15 | }) 16 | .then((res) => { 17 | if (res.status === 200) { 18 | resolve(res.data) 19 | } else { 20 | reject('request error in ' + url) 21 | } 22 | }) 23 | .catch((err) => { 24 | console.error(err, url) 25 | reject(err) 26 | }) 27 | }) 28 | } 29 | const config = { 30 | get(url, options) { 31 | return new Promise((resolve, reject) => { 32 | ajax('get', url, options) 33 | .then((data) => { 34 | resolve(data) 35 | }) 36 | .catch((e) => { 37 | reject(e) 38 | }) 39 | }) 40 | }, 41 | post(url, options) { 42 | return new Promise((resolve, reject) => { 43 | ajax('post', url, options) 44 | .then((data) => { 45 | resolve(data) 46 | }) 47 | .catch((e) => { 48 | reject(e) 49 | }) 50 | }) 51 | }, 52 | patch(url, options) { 53 | return new Promise((resolve, reject) => { 54 | ajax('patch', url, options) 55 | .then((data) => { 56 | resolve(data) 57 | }) 58 | .catch((e) => { 59 | reject(e) 60 | }) 61 | }) 62 | }, 63 | put(url, options) { 64 | return new Promise((resolve, reject) => { 65 | ajax('put', url, options) 66 | .then((data) => { 67 | resolve(data) 68 | }) 69 | .catch((e) => { 70 | reject(e) 71 | }) 72 | }) 73 | }, 74 | delete(url, options) { 75 | return new Promise((resolve, reject) => { 76 | ajax('delete', url, options) 77 | .then((data) => { 78 | resolve(data) 79 | }) 80 | .catch((e) => { 81 | reject(e) 82 | }) 83 | }) 84 | } 85 | } 86 | 87 | module.exports = config 88 | -------------------------------------------------------------------------------- /server/http/server-api.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const qs = require('qs') 3 | axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8' 4 | function ajax(type, url, options) { 5 | let _params = {} 6 | if (type === 'get') _params = { params: options } 7 | if (type === 'post') _params = { data: qs.stringify(options) } 8 | return new Promise((resolve, reject) => { 9 | axios({ 10 | method: type, 11 | url: url, 12 | ..._params 13 | }) 14 | .then((_res) => { 15 | if (_res.status === 200) { 16 | resolve(_res.data) 17 | } else { 18 | reject('request error in ' + url) 19 | } 20 | }) 21 | .catch((err) => { 22 | console.log(err, url) 23 | }) 24 | }) 25 | } 26 | const config = { 27 | get(url, options) { 28 | return ajax('get', url, options) 29 | }, 30 | post(url, options) { 31 | return ajax('post', url, options) 32 | }, 33 | patch(url, options) { 34 | return ajax('patch', url, options) 35 | } 36 | } 37 | 38 | module.exports = config 39 | -------------------------------------------------------------------------------- /server/middleware/confirmToken.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc api鉴权中间件 3 | * @author justJokee 4 | */ 5 | const verify = require('../utils/verify') 6 | const confirmToken = (req, res, next) => { 7 | if (!req.headers.authorization) { 8 | res.json({ status: 401, info: '无访问权限或token已过期' }) 9 | } else { 10 | const token = req.headers.authorization 11 | 12 | verify(token, (err) => { 13 | if (err) { 14 | res.json({ status: 401, info: '无访问权限或token已过期' }) 15 | } else { 16 | next() 17 | } 18 | }) 19 | } 20 | } 21 | module.exports = confirmToken 22 | -------------------------------------------------------------------------------- /server/middleware/confirmUnpublish.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 对于前后端通用的文章系列接口,阻断前端对草稿箱的访问 3 | * @param {*} req 4 | * @param {*} res 5 | * @param {*} next 6 | * @returns 7 | * @author justJokee 8 | */ 9 | const verify = require('../utils/verify') 10 | const confirmUnpublish = (req, res, next) => { 11 | if (req.publish === '0') { 12 | if (!req.headers.authorization) { 13 | res.json({ status: 401, info: '无访问权限或token已过期' }) 14 | } else { 15 | const token = req.headers.authorization 16 | 17 | verify(token, (err) => { 18 | if (err) { 19 | res.json({ status: 401, info: '无访问权限或token已过期' }) 20 | } else { 21 | next() 22 | } 23 | }) 24 | } 25 | } else next() 26 | } 27 | module.exports = confirmUnpublish 28 | -------------------------------------------------------------------------------- /server/middleware/ratelimit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 限流中间件(简单实现) 3 | * @author justJokee 4 | */ 5 | 6 | const lruCache = require('lru-cache') 7 | const getIp = require('../utils/getIp') 8 | const { highLimitApis, HEIGHLIMIT, HEIGHLIMITTL } = require('../utils/highLimitApis') 9 | // 每个ip一分钟最大限制100次请求 10 | const LIMIT = 100 11 | const LRU = new lruCache({ 12 | max: 6000, 13 | // 默认时间窗口1分钟 14 | ttl: 1000 * 60 15 | // 过期后立即删除 Note that this may significantly degrade performance 16 | // ttlAutopurge: true 17 | }) 18 | 19 | module.exports = function ratelimit(req, res, next) { 20 | res.header('Access-Control-Allow-Origin', 'admin.mapblog.cn') 21 | res.header('Access-Control-Allow-Headers', 'X-Requested-With') 22 | res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS') 23 | const ip = getIp(req) 24 | const url = `${req.baseUrl}${req.url}` 25 | const key = `${ip}:${url}` 26 | const blackKey = `black:${ip}:${url}` 27 | const value = LRU.get(key) 28 | const isHeighLimitApi = highLimitApis.includes(url) 29 | const limit = isHeighLimitApi ? HEIGHLIMIT : LIMIT 30 | const option = isHeighLimitApi ? { ttl: HEIGHLIMITTL } : {} 31 | 32 | // cache中存在当前ip对应的接口信息 33 | if (value) { 34 | if (value < limit) { 35 | LRU.set(key, value + 1, option) 36 | next() 37 | } 38 | // 请求次数超限 39 | else { 40 | LRU.delete(key) 41 | LRU.set(blackKey, Date.now()) 42 | // 返回限流状态 43 | response(res) 44 | } 45 | } 46 | // cache中不存在当前请求ip信息 47 | else { 48 | // ip已经在黑名单 49 | if (LRU.get(blackKey)) { 50 | // 返回限流状态 51 | response(res) 52 | } else { 53 | LRU.set(key, 1, option) 54 | next() 55 | } 56 | } 57 | } 58 | 59 | function response(res) { 60 | res.json({ 61 | status: 429, 62 | info: '访问次数超限,请稍后再试' 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /server/plugin/lastMod.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 为每一条存储/更新的数据添加最后修改时间 3 | * @param {*} schema 4 | * @param {*} options 5 | */ 6 | const moment = require('moment') 7 | module.exports = function lastModifiePlugin(schema, options) { 8 | schema.add({ lastMod: 'date' }) 9 | schema.pre('save', function(next) { 10 | this.lastMod = moment().format() 11 | }) 12 | schema.pre('update', function(next) { 13 | this.lastMod = moment().format() 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /server/utils/getBrowser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取当前浏览类型 3 | */ 4 | 5 | module.exports = function getBrowser(userAgent) { 6 | //判断是否Opera浏览器 7 | const isOpera = userAgent.indexOf('Opera') > -1 8 | // 判断是否IE浏览器 9 | const isIE = userAgent.indexOf('compatible') > -1 && userAgent.indexOf('MSIE') > -1 && !isOpera 10 | // 判断是否IE的Edge浏览器 11 | const isEdge = userAgent.indexOf('Windows NT 6.1; Trident/7.0;') > -1 && !isIE 12 | // 判断是否Firefox浏览器 13 | const isFF = userAgent.indexOf('Firefox') > -1 14 | // 判断是否Safari浏览器 15 | const isSafari = userAgent.indexOf('Safari') > -1 && userAgent.indexOf('Chrome') == -1 16 | // 判断Chrome浏览器 17 | const isChrome = userAgent.indexOf('Chrome') > -1 && userAgent.indexOf('Safari') > -1 18 | 19 | if (isChrome) return 'Chrome' 20 | if (isFF) return 'FireFox' 21 | if (isSafari) return 'Safari' 22 | if (isOpera) return 'Opera' 23 | if (isEdge) return 'Edge' 24 | if (isIE) { 25 | const reIE = new RegExp('MSIE (\\d+\\.\\d+);') 26 | reIE.test(userAgent) 27 | const fIEVersion = parseFloat(RegExp['$1']) 28 | if (fIEVersion) return 'IE' + fIEVersion 29 | return 'IE' 30 | } 31 | return '未知' 32 | } 33 | -------------------------------------------------------------------------------- /server/utils/getIp.js: -------------------------------------------------------------------------------- 1 | const getIp = function(req) { 2 | let ip = req.get('X-Real-IP') || req.get('X-Forwarded-For') || req.ip 3 | if (ip.split(',').length > 0) { 4 | ip = ip.split(',')[0] 5 | } 6 | return ip 7 | } 8 | 9 | module.exports = getIp 10 | -------------------------------------------------------------------------------- /server/utils/getOS.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取当前操作系统 3 | * @returns 4 | */ 5 | 6 | module.exports = function getOS(userAgent) { 7 | if (userAgent.indexOf('Window') > 0) { 8 | return 'Windows' 9 | } else if (userAgent.indexOf('Mac OS X') > 0) { 10 | return 'MacOS' 11 | } else if (userAgent.indexOf('Linux') > 0) { 12 | return 'Linux' 13 | } else { 14 | return '未知' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/utils/highLimitApis.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 限制高频访问的api名单 3 | * 1. 时间窗口 10s 4 | * 2. 限制次数 3 5 | * @author justJokee 6 | */ 7 | 8 | exports.HEIGHLIMIT = 3 9 | exports.HEIGHLIMITTL = 1000 * 10 10 | 11 | exports.highLimitApis = ['/api/front/comments/save', '/api/front/messageBoard/save'] 12 | -------------------------------------------------------------------------------- /server/utils/reviseTime.js: -------------------------------------------------------------------------------- 1 | //time为Date.now() 2 | const reviseTime = function(time){ 3 | //new Data(str)会把时间转换为本地时间,虽然传入的time参数为UTC标准时间,但是在调用方法时,new Data 4 | //应该在内部转换为了本地时间,一开始在time中加上了1000*60*60*8,小时数会比本地时间多了8小时。 5 | //服务器若设置在国外,则应当换算时区 6 | let localStamp = time 7 | let localTime = new Date(localStamp), 8 | year = localTime.getFullYear(), 9 | month = localTime.getMonth()+1, 10 | day = localTime.getDate(), 11 | hours = localTime.getHours(), 12 | minutes = localTime.getMinutes(), 13 | finTime 14 | for(let i = 0;i < 9;i++){ 15 | if(i === minutes){ 16 | minutes = "0" + minutes 17 | } 18 | } 19 | finTime = year + "年" + month + "月" + day + "日" + hours + "时" + minutes + "分" 20 | return finTime 21 | } 22 | 23 | module.exports = reviseTime -------------------------------------------------------------------------------- /server/utils/schedule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 定时任务 - 爬取豆瓣电影等 3 | * @author justJokee 4 | */ 5 | 6 | const schedule = require('node-schedule') 7 | const DoubanSpider = require('douban-spider-v') 8 | const fs = require('fs-extra') 9 | const path = require('path') 10 | const { doubanConfig } = require('../db/secret') 11 | // 确保文件目录存在 12 | fs.ensureDirSync(path.join(__dirname, '../files/movies/collect')) 13 | fs.ensureDirSync(path.join(__dirname, '../files/movies/wish')) 14 | fs.ensureDirSync(path.join(__dirname, '../files/movies/do')) 15 | 16 | const moviesPath = { 17 | getMovieCollect: path.join(__dirname, '../files/movies/collect'), 18 | getMovieWish: path.join(__dirname, '../files/movies/wish'), 19 | getMovieDo: path.join(__dirname, '../files/movies/do') 20 | } 21 | 22 | let cache = { 23 | getMovieCollect: [], 24 | getMovieWish: [], 25 | getMovieDo: [] 26 | } 27 | const douban = new DoubanSpider({ 28 | uid: doubanConfig.uid 29 | }) 30 | 31 | function startSchedule() { 32 | // 每天凌晨1点进行爬取 33 | schedule.scheduleJob('0 0 1 * * *', async () => { 34 | console.log('定时任务触发 -->>>>>') 35 | getMovies() 36 | }) 37 | } 38 | async function getMovies() { 39 | await handleMovies('getMovieCollect') 40 | await sleep() 41 | await handleMovies('getMovieWish') 42 | await sleep() 43 | await handleMovies('getMovieDo') 44 | } 45 | 46 | async function handleMovies(method) { 47 | try { 48 | const res = await douban[method]() 49 | cache[method].push(res.data) 50 | console.log(`[${method}]: 第1页爬取成功 -->>>>`) 51 | fs.writeFileSync(`${moviesPath[method]}/pageTotal.txt`, res.page.totalPage + '', 'utf8') 52 | if (res.page.totalPage > 1) { 53 | // 保存总页码数 54 | 55 | for (let i = 2; i <= res.page.totalPage; i++) { 56 | // 爬取速度 30s/1页,避免触发反爬机制 57 | await sleep() 58 | const res = await douban[method](i) 59 | cache[method].push(res.data) 60 | console.log(`[${method}]: 第${i}页爬取成功 -->>>>`) 61 | } 62 | } 63 | // 写入json文件 64 | cache[method].forEach((doc, index) => { 65 | fs.ensureDirSync(moviesPath[method]) 66 | fs.writeFileSync(`${moviesPath[method]}/${index + 1}.json`, JSON.stringify(doc), 'utf8') 67 | }) 68 | // 释放空间 69 | cache[method] = [] 70 | } catch (e) { 71 | console.log('爬虫解析错误 -->>>>', e) 72 | cache = { 73 | getMovieCollect: [], 74 | getMovieWish: [], 75 | getMovieDo: [] 76 | } 77 | } 78 | } 79 | async function sleep(ms = 1000 * 30) { 80 | await new Promise((resolve) => setTimeout(resolve, ms)) 81 | } 82 | exports.startSchedule = startSchedule 83 | -------------------------------------------------------------------------------- /server/utils/verify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc jwt verify 3 | * @author justJokee 4 | */ 5 | const jwt = require('jsonwebtoken') 6 | const { userSecret } = require('../db/secret') 7 | 8 | module.exports = async function verify(token, cb) { 9 | const secret = `${userSecret.salt}` 10 | jwt.verify(token, secret, (err) => { 11 | cb(err) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 66 | 71 | -------------------------------------------------------------------------------- /src/api/article.js: -------------------------------------------------------------------------------- 1 | import http from '@/http/' 2 | export default { 3 | // 获取文章列表 4 | getArticles(payload) { 5 | return http.get('/api/front/article/gets', payload).then((data) => { 6 | return data 7 | }) 8 | }, 9 | // 获取文章详情 10 | getArticle(payload) { 11 | return http.get('/api/front/article/detail', payload).then((data) => { 12 | return data 13 | }) 14 | }, 15 | // 获取文章评论 16 | getArticleComments(payload) { 17 | return http.get('/api/front/comments/get', payload).then((data) => { 18 | return data 19 | }) 20 | }, 21 | // 获取上一篇文章和下一篇文章 22 | getPrevnextArticle(payload) { 23 | return http.get('/api/front/article/prevnext', payload).then((data) => { 24 | return data 25 | }) 26 | }, 27 | // 文章搜索 28 | searchArticle(payload) { 29 | return http.get('/api/front/article/search', payload).then((data) => { 30 | return data 31 | }) 32 | }, 33 | // 文章归档 34 | getArchives(payload) { 35 | return http.get('/api/front/article/archives', payload).then((data) => { 36 | return data 37 | }) 38 | }, 39 | // 文章归档 40 | likeArticle(payload) { 41 | return http.patch('/api/front/article/like', payload).then((data) => { 42 | return data 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/category.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 分类 3 | * @author Justjokee 4 | */ 5 | import http from '@/http/' 6 | export default { 7 | // 获取分类 8 | getCategory(payload) { 9 | return http.get('/api/front/category/get', payload).then((data) => { 10 | return data 11 | }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/api/comments.js: -------------------------------------------------------------------------------- 1 | import http from '@/http/' 2 | export default { 3 | // 获取文章评论 4 | getArticleComments(payload) { 5 | return http.get('/api/front/comments/get', payload).then((data) => { 6 | return data 7 | }) 8 | }, 9 | // 点赞留言 10 | likeArticleComment(payload) { 11 | return http.patch('/api/front/comments/like', payload).then((data) => { 12 | return data 13 | }) 14 | }, 15 | // 发表文章评论 /api/front/comments/save 16 | saveArticleComment(payload) { 17 | return http.post('/api/front/comments/save', payload).then((data) => { 18 | return data 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/api/entertainment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 娱乐 3 | * @author Justjokee 4 | */ 5 | import http from '@/http/' 6 | export default { 7 | // 获取电影、电视剧等影视记录 8 | getMovies(payload) { 9 | return http.get('/api/front/douban/get', payload).then((data) => { 10 | return data 11 | }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import article from './article' 2 | import visitor from './visitor' 3 | import comments from './comments' 4 | import messageBoard from './messageBoard' 5 | import category from './category' 6 | import tags from './tags' 7 | import entertainment from './entertainment' 8 | import qiniu from './qiniu' 9 | 10 | export default { 11 | ...article, 12 | ...visitor, 13 | ...comments, 14 | ...messageBoard, 15 | ...category, 16 | ...tags, 17 | ...entertainment, 18 | ...qiniu 19 | } 20 | -------------------------------------------------------------------------------- /src/api/messageBoard.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 留言板 3 | * @author Justjokee 4 | */ 5 | import http from '@/http/' 6 | export default { 7 | // 获取留言列表 8 | getMessageBoard(payload) { 9 | return http.get('/api/front/messageBoard/gets', payload).then((data) => { 10 | return data 11 | }) 12 | }, 13 | // 存储留言 14 | saveMessageBoard(payload) { 15 | return http.post('/api/front/messageBoard/save', payload).then((data) => { 16 | return data 17 | }) 18 | }, 19 | // 点赞留言 20 | likeMessageBoard(payload) { 21 | return http.patch('/api/front/messageBoard/like', payload).then((data) => { 22 | return data 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/api/qiniu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取七牛云token 3 | * @author Justjokee 4 | */ 5 | 6 | import http from '@/http/' 7 | export default { 8 | // 获取文章所有的tag 9 | getQiniuToken(payload) { 10 | return http.get('/api/front/qiniu/getToken', payload).then((data) => { 11 | return data 12 | }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/api/tags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取文章标签 3 | * @author Justjokee 4 | */ 5 | 6 | import http from '@/http/' 7 | export default { 8 | // 获取文章所有的tag 9 | getTags(payload) { 10 | return http.get('/api/front/tags/count', payload).then((data) => { 11 | return data 12 | }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/api/visitor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 访客信息 3 | * @author Justjokee 4 | */ 5 | 6 | import http from '@/http/' 7 | export default { 8 | // 存储访客信息 9 | saveVisitor(payload) { 10 | return http.post('/api/front/visitor/save', payload).then((data) => { 11 | return data 12 | }) 13 | }, 14 | isExistedVisitor(payload) { 15 | return http.post('/api/front/visitor/existed', payload).then((data) => { 16 | return data 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue' 4 | // eslint-disable-next-line no-unused-vars 5 | import loadEle from '@/utils/loadEle' 6 | import App from './App.vue' 7 | import moment from 'moment' 8 | import api from '@/api/' 9 | import empty from '@/components/empty' 10 | import { createRouter } from './router' 11 | import { createStore } from './store' 12 | import { sync } from 'vuex-router-sync' 13 | 14 | import mergeAsyncData from '@/mixins/mergeAsyncData' 15 | // Vue.config.productionTip = false 16 | Vue.filter('formatDate', (val) => { 17 | return moment(val).format('YYYY-MM-DD HH:mm') 18 | }) 19 | Vue.mixin(mergeAsyncData) 20 | Vue.prototype.$moment = moment 21 | Vue.prototype.$api = api 22 | Vue.component('empty', empty) 23 | Vue.config.devtools = true 24 | /* eslint-disable no-new */ 25 | export function createApp() { 26 | const router = createRouter() 27 | const store = createStore() 28 | sync(store, router) 29 | const app = new Vue({ 30 | router, 31 | store, 32 | render: (h) => h(App) 33 | }) 34 | 35 | return { app, router, store } 36 | } 37 | -------------------------------------------------------------------------------- /src/assets/css/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.27.0 2 | https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+bash+http+java+js-extras+pug+pure+python+r+jsx+tsx+ruby+rust+sass+scss+sql+typescript&plugins=line-numbers+toolbar+copy-to-clipboard */ 3 | code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green} 4 | pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right} 5 | div.code-toolbar{position:relative}div.code-toolbar>.toolbar{position:absolute;z-index:10;top:.3em;right:.2em;transition:opacity .3s ease-in-out;opacity:0}div.code-toolbar:hover>.toolbar{opacity:1}div.code-toolbar:focus-within>.toolbar{opacity:1}div.code-toolbar>.toolbar>.toolbar-item{display:inline-block}div.code-toolbar>.toolbar>.toolbar-item>a{cursor:pointer}div.code-toolbar>.toolbar>.toolbar-item>button{background:0 0;border:0;color:inherit;font:inherit;line-height:normal;overflow:visible;padding:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}div.code-toolbar>.toolbar>.toolbar-item>a,div.code-toolbar>.toolbar>.toolbar-item>button,div.code-toolbar>.toolbar>.toolbar-item>span{color:#bbb;font-size:.8em;padding:0 .5em;background:#f5f2f0;background:rgba(224,224,224,.2);box-shadow:0 2px 0 0 rgba(0,0,0,.2);border-radius:.5em}div.code-toolbar>.toolbar>.toolbar-item>a:focus,div.code-toolbar>.toolbar>.toolbar-item>a:hover,div.code-toolbar>.toolbar>.toolbar-item>button:focus,div.code-toolbar>.toolbar>.toolbar-item>button:hover,div.code-toolbar>.toolbar>.toolbar-item>span:focus,div.code-toolbar>.toolbar>.toolbar-item>span:hover{color:inherit;text-decoration:none} 6 | -------------------------------------------------------------------------------- /src/assets/font/sf-arch/SF Arch Rival Extended Bold.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "SF Arch Rival Extended Bold"; 3 | src: url("SF Arch Rival Extended Bold.eot"); /* IE9 */ 4 | src: url("SF Arch Rival Extended Bold.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */ 5 | 6 | url("SF Arch Rival Extended Bold.woff") format("woff"), /* chrome、firefox */ 7 | url("SF Arch Rival Extended Bold.ttf") format("truetype"), /* chrome、firefox、opera、Safari, Android, iOS 4.2+ */ 8 | 9 | url("SF Arch Rival Extended Bold.svg#SF Arch Rival Extended Bold") format("svg"); /* iOS 4.1- */ 10 | font-style: normal; 11 | font-weight: normal; 12 | } 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/font/sf-arch/SF Arch Rival Extended Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/font/sf-arch/SF Arch Rival Extended Bold.eot -------------------------------------------------------------------------------- /src/assets/font/sf-arch/SF Arch Rival Extended Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/font/sf-arch/SF Arch Rival Extended Bold.ttf -------------------------------------------------------------------------------- /src/assets/font/sf-arch/SF Arch Rival Extended Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/font/sf-arch/SF Arch Rival Extended Bold.woff -------------------------------------------------------------------------------- /src/assets/img/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/img/github.png -------------------------------------------------------------------------------- /src/assets/img/icp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/img/icp.png -------------------------------------------------------------------------------- /src/assets/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/img/loading.gif -------------------------------------------------------------------------------- /src/assets/img/qq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/img/qq.png -------------------------------------------------------------------------------- /src/assets/img/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/src/assets/img/share.png -------------------------------------------------------------------------------- /src/components/base/miss.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/components/dot/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 点状形状包裹器 3 | @author: justJokee 4 | 5 | 10 | 18 | 43 | -------------------------------------------------------------------------------- /src/components/emoji/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 留言、文章评论展示 3 | @author: justJokee 4 | 5 | 27 | 80 | 98 | -------------------------------------------------------------------------------- /src/components/empty/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 无数据占位符 3 | @author: justJokee 4 | 5 | 10 | 24 | 38 | -------------------------------------------------------------------------------- /src/components/note/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: note包裹容器 3 | @author: justJokee 4 | 5 | 13 | 21 | 40 | -------------------------------------------------------------------------------- /src/components/rating/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 评分 3 | @author: justJokee 4 | 5 | 10 | 37 | 47 | -------------------------------------------------------------------------------- /src/components/splitLine/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 分割线 3 | @author: justJokee 4 | 5 | 10 | 18 | 51 | -------------------------------------------------------------------------------- /src/entry-client.js: -------------------------------------------------------------------------------- 1 | import { createApp } from './app' 2 | import { storage } from '@/utils/storage' 3 | import R_O_P from 'resize-observer-polyfill' 4 | import Vue from 'vue' 5 | import VueLazyload from '@/utils/lazyLoad' 6 | const loading = require('@/assets/img/loading.gif') 7 | if (!window.ResizeObserver) { 8 | window.ResizeObserver = R_O_P 9 | } 10 | // eslint-disable-next-line no-undef 11 | Vue.use(VueLazyload, { 12 | loading: loading 13 | }) 14 | 15 | Vue.mixin({ 16 | beforeRouteUpdate(to, from, next) { 17 | const { asyncData } = this.$options 18 | if (asyncData) { 19 | asyncData({ 20 | store: this.$store, 21 | route: to 22 | }) 23 | .then((res) => { 24 | Object.assign(this.$data, res) 25 | next() 26 | }) 27 | .catch(next) 28 | } else { 29 | next() 30 | } 31 | } 32 | }) 33 | 34 | const { app, router, store } = createApp() 35 | Prism.plugins.toolbar.registerButton('macostyle', function () { 36 | const content = document.createElement('div') 37 | content.setAttribute('class', 'toolbar-item__content') 38 | content.innerHTML = '' 39 | return content 40 | }) 41 | // 将服务端渲染时的状态写入vuex中 42 | if (window.__INITIAL_STATE__) { 43 | store.replaceState(window.__INITIAL_STATE__.state) 44 | } 45 | // 同步访客登录信息 46 | if (storage.getVisitor()) store.commit('setVisitor', storage.getVisitor()) 47 | router.onReady(() => { 48 | router.beforeResolve(async (to, from, next) => { 49 | const matched = router.getMatchedComponents(to) 50 | const prevMatched = router.getMatchedComponents(from) 51 | // 我们只关心之前没有渲染的组件 52 | // 所以我们对比它们,找出两个匹配列表的差异组件 53 | let diffed = false 54 | const activated = matched.filter((c, i) => { 55 | return diffed || (diffed = prevMatched[i] !== c) 56 | }) 57 | if (!activated.length) { 58 | return next() 59 | } 60 | try { 61 | // 这里如果有加载指示器(loading indicator),就触发 62 | await Promise.all( 63 | activated.map(async (Component) => { 64 | if (Component.asyncData) { 65 | const res = await Component.asyncData({ store, route: to }) 66 | Component.__COMPONENT_ASYNCDATA__ = res || {} 67 | } 68 | }) 69 | ) 70 | // 停止加载指示器(loading indicator) 71 | next() 72 | } catch (e) { 73 | console.error(`[entry-client]: async data fetch error -> ${e}`) 74 | next(e) 75 | } 76 | }) 77 | 78 | app.$mount('#app') 79 | }) 80 | -------------------------------------------------------------------------------- /src/entry-server.js: -------------------------------------------------------------------------------- 1 | import { createApp } from './app' 2 | 3 | export default (context) => { 4 | const { app, router, store } = createApp() 5 | const meta = app.$meta() 6 | return new Promise((resolve, reject) => { 7 | router.push(context.url) 8 | context.meta = meta 9 | router.onReady(async () => { 10 | const matchedComponents = app.$router.getMatchedComponents() 11 | if (!matchedComponents.length) { 12 | return reject({ code: 404 }) 13 | } 14 | // 对所有匹配的路由组件调用 `asyncData()` 15 | try { 16 | const componentRes = await Promise.all( 17 | matchedComponents.map(async (Component) => { 18 | if (Component.asyncData) { 19 | const res = await Component.asyncData({ 20 | store, 21 | route: router.currentRoute, 22 | isServer: true, 23 | _ip: context._ip 24 | }) 25 | return { res, Component } 26 | } 27 | }) 28 | ) 29 | const __COMPONENT_ASYNCDATA__ = componentRes.map((eRes) => { 30 | if (eRes) { 31 | const { res, Component } = eRes 32 | // 将路由组件的 asyncData 返回值挂载到组件实例的构造项上 33 | // 用于 data & asyncData 的合并策略 34 | Component.__COMPONENT_ASYNCDATA__ = res 35 | return res || {} 36 | } 37 | }) 38 | 39 | // 在所有预取钩子(preFetch hook) resolve 后, 40 | // 我们的 store 现在已经填充入渲染应用程序所需的状态。 41 | // 当我们将状态附加到上下文, 42 | // 并且 `template` 选项用于 renderer 时, 43 | // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 44 | context.state = { 45 | state: store.state, 46 | __COMPONENT_ASYNCDATA__ 47 | } 48 | resolve(app) 49 | } catch (e) { 50 | console.error(`[entry-server]: async data fetch error -> ${e}`) 51 | reject(e) 52 | } 53 | }, reject) 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /src/http/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const qs = require('qs') 3 | import { errorCode } from '@/utils/errorCode' 4 | import { Message } from 'element-ui' 5 | 6 | axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8' 7 | //response拦截器 8 | axios.interceptors.response.use( 9 | (config) => { 10 | return config 11 | }, 12 | (error) => { 13 | let errRes = JSON.parse(JSON.stringify(error)) 14 | if (errRes && errRes.response && errRes.response.status === 500) { 15 | _message('warning', '服务器错误') 16 | } 17 | return Promise.reject(error) 18 | } 19 | ) 20 | function ajax(type, url, options) { 21 | return new Promise((resolve, reject) => { 22 | axios({ 23 | method: type, 24 | url: url, 25 | baseURL: process.env.BASE_URL, 26 | params: type === 'get' ? options : null, 27 | data: type !== 'get' ? qs.stringify(options) : null 28 | }) 29 | .then((res) => { 30 | if (res.status === 200) { 31 | errorCode(res.data.status) 32 | resolve(res.data) 33 | } else { 34 | reject('request error in ' + url) 35 | } 36 | }) 37 | .catch((err) => { 38 | console.error(err, url) 39 | reject(err) 40 | }) 41 | }) 42 | } 43 | const config = { 44 | get(url, options) { 45 | return ajax('get', url, options) 46 | }, 47 | post(url, options) { 48 | return ajax('post', url, options) 49 | }, 50 | patch(url, options) { 51 | return ajax('patch', url, options) 52 | }, 53 | put(url, options) { 54 | return ajax('put', url, options) 55 | }, 56 | delete(url, options) { 57 | return ajax('delete', url, options) 58 | } 59 | } 60 | 61 | function _message(t, m) { 62 | Message({ 63 | type: t, 64 | message: m, 65 | center: true 66 | }) 67 | } 68 | 69 | export default config 70 | -------------------------------------------------------------------------------- /src/index.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Marco's Blog 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/mixins/mergeAsyncData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc data & asyncData合并策略 3 | * @author justJokee 4 | */ 5 | export default { 6 | created() { 7 | // client 端做同步 8 | if ( 9 | !this.$isServer && 10 | window.__INITIAL_STATE__ && 11 | window.__INITIAL_STATE__.__COMPONENT_ASYNCDATA__ && 12 | this.$router && 13 | this.$router.getMatchedComponents().length 14 | ) { 15 | const matcheComps = this.$router.getMatchedComponents() 16 | matcheComps.forEach((ec, i) => (ec.__COMPONENT_ASYNCDATA__ = window.__INITIAL_STATE__.__COMPONENT_ASYNCDATA__[i])) 17 | window.__INITIAL_STATE__.__COMPONENT_ASYNCDATA__ = null 18 | } 19 | // 保证client端在开发模式下的热更新正常 20 | if (!this.$isServer) HMR(this) 21 | // server 端合并策略 & client 端的首次合并 & client 端接管后的合并 22 | if (this.constructor && this.constructor.extendOptions && this.constructor.extendOptions.__COMPONENT_ASYNCDATA__) { 23 | const data = this.constructor.extendOptions.__COMPONENT_ASYNCDATA__ 24 | Object.assign(this.$data, data) 25 | this.constructor.extendOptions.__COMPONENT_ASYNCDATA__ = null 26 | if (process.env.NODE_ENV === 'development') console.log('[data merge]: merge asyncData success') 27 | } 28 | } 29 | } 30 | 31 | async function HMR(vm) { 32 | if ( 33 | process.env.NODE_ENV === 'development' && 34 | vm.constructor && 35 | vm.constructor.extendOptions && 36 | vm.constructor.extendOptions.asyncData && 37 | !vm.constructor.extendOptions.__COMPONENT_ASYNCDATA__ 38 | ) { 39 | const matched = vm.$router.getMatchedComponents() 40 | await Promise.all( 41 | matched.map(async (Component) => { 42 | if (Component.asyncData) { 43 | const res = await Component.asyncData({ store: vm.$store, route: vm.$route }) 44 | Object.assign(vm.$data, res) 45 | } 46 | }) 47 | ) 48 | } 49 | return 50 | } 51 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import Meta from 'vue-meta' 4 | const miss = () => import('@/components/base/miss') 5 | const home = () => import('@/views/home/') 6 | const articleDetail = () => import('@/views/article/articleDetail') 7 | const messageBoard = () => import('@/views/messageBoard/') 8 | const archives = () => import('@/views/archives/') 9 | const tags = () => import('@/views/tags/') 10 | const articleFilter = () => import('@/views/article-filter/') 11 | const category = () => import('@/views/category/') 12 | const movies = () => import('@/views/movies/') 13 | 14 | Vue.use(Router) 15 | Vue.use(Meta) 16 | // 避免重复点击相同路由 报错问题 17 | const originalPush = Router.prototype.push 18 | Router.prototype.push = function push(location) { 19 | return originalPush.call(this, location).catch((err) => err) 20 | } 21 | export function createRouter() { 22 | return new Router({ 23 | mode: 'history', 24 | routes: [ 25 | { 26 | path: '*', 27 | name: 'miss', 28 | component: miss 29 | }, 30 | { 31 | path: '/', 32 | name: 'home', 33 | component: home 34 | }, 35 | { 36 | path: '/app/article/:id', 37 | name: 'articleDetail', 38 | component: articleDetail 39 | }, 40 | { 41 | path: '/app/messageBoard', 42 | name: 'messageBoard', 43 | component: messageBoard 44 | }, 45 | { 46 | path: '/app/archives', 47 | name: 'archives', 48 | component: archives 49 | }, 50 | { 51 | path: '/app/tags', 52 | name: 'tags', 53 | component: tags 54 | }, 55 | { 56 | path: '/app/category', 57 | name: 'category', 58 | component: category 59 | }, 60 | { 61 | path: '/app/articles/:type/:param', 62 | name: 'articleFilter', 63 | component: articleFilter 64 | }, 65 | { 66 | path: '/app/movies', 67 | name: 'movies', 68 | component: movies 69 | } 70 | ], 71 | scrollBehavior(to, from, savedPosition) { 72 | if (to.hash || to.query.anchor) return false 73 | if (savedPosition) { 74 | return savedPosition 75 | } else { 76 | return { x: 0, y: 0 } 77 | } 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import actions from './actions' 4 | import mutations from './mutations' 5 | 6 | Vue.use(Vuex) 7 | export function createStore() { 8 | return new Vuex.Store({ 9 | state: { 10 | activeCatalog: '', 11 | rollBack: false, 12 | // 访客信息 13 | visitorInfo: {}, 14 | // 文章目录树 15 | catalogs: [], 16 | // 文章归档 按月统计 17 | archives: [], 18 | // 文章分类 19 | category: [], 20 | // 文章标签 21 | tags: [], 22 | // 最新评论 23 | newComments: [], 24 | // 最新文章 25 | newArticles: [], 26 | totals: { 27 | article: 0, 28 | tag: 0, 29 | category: 0 30 | } 31 | }, 32 | mutations, 33 | actions 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setCatalogs(state, catalogs) { 3 | state.catalogs = catalogs 4 | }, 5 | setArchives(state, archives) { 6 | state.archives = archives 7 | }, 8 | setCategory(state, category) { 9 | state.category = category 10 | }, 11 | setTags(state, tags) { 12 | state.tags = tags 13 | }, 14 | setNewComments(state, newComments) { 15 | state.newComments = newComments 16 | }, 17 | setNewArticles(state, newArticles) { 18 | state.newArticles = newArticles 19 | }, 20 | setActiveCatalog(state, id) { 21 | state.activeCatalog = id 22 | }, 23 | setRollBack(state, rollBack) { 24 | state.rollBack = rollBack 25 | }, 26 | setVisitor(state, info) { 27 | state.visitorInfo = info 28 | }, 29 | setTotals(state, kv) { 30 | state.totals[kv.key] = kv.value 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/style/function.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 函数 3 | */ 4 | 5 | @function merge($maps...) { 6 | $copy: (); 7 | @each $mv in $maps { 8 | @each $k, $v in $mv { 9 | $copy: map-merge( 10 | $copy, 11 | ( 12 | $k: $v 13 | ) 14 | ); 15 | } 16 | } 17 | @return $copy; 18 | } 19 | -------------------------------------------------------------------------------- /src/style/global.scss: -------------------------------------------------------------------------------- 1 | // 文字 2 | 3 | $font12: 12px; 4 | $font14: 14px; 5 | $font16: 16px; 6 | $font18: 18px; 7 | $font56: 56px; 8 | 9 | // 字体文件 10 | @font-face { 11 | font-family: 'sf-arch'; 12 | src: url('~@/assets/font/sf-arch/SF Arch Rival Extended Bold.woff') format('woff'), 13 | url('~@/assets/font/sf-arch/SF Arch Rival Extended Bold.ttf') format('truetype'); 14 | } 15 | -------------------------------------------------------------------------------- /src/style/index.scss: -------------------------------------------------------------------------------- 1 | @import './theme/map.scss'; 2 | @import './mixins.scss'; 3 | @import './global.scss'; 4 | -------------------------------------------------------------------------------- /src/style/mixins.scss: -------------------------------------------------------------------------------- 1 | // 全局混入宏 2 | 3 | @mixin flex { 4 | display: flex; 5 | } 6 | @mixin flex-box-space { 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | } 11 | @mixin flex-box-center { 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | } 16 | @mixin flex-box-wrap-space { 17 | display: flex; 18 | flex-wrap: wrap; 19 | justify-content: space-between; 20 | } 21 | @mixin flex-box-end-space { 22 | display: flex; 23 | justify-content: flex-end; 24 | } 25 | 26 | @mixin themeify($themes: $themes) { 27 | @each $key, $theme-map in $themes { 28 | $d_theme: if($key==light, null, '.mapblog-theme-#{$key}'); 29 | #{$d_theme} & { 30 | $theme-map-copy: () !global; 31 | @each $theme-map-key, $theme-map-value in $theme-map { 32 | $theme-map-copy: map-merge( 33 | $theme-map-copy, 34 | ( 35 | $theme-map-key: $theme-map-value 36 | ) 37 | ) !global; 38 | } 39 | @content; 40 | $theme-map-copy: null !global; 41 | } 42 | } 43 | } 44 | 45 | @function themed($key) { 46 | @return map-get($theme-map-copy, $key); 47 | } 48 | $break_points: ( 49 | xs: 'screen and (max-width: 768px)', 50 | sm: 'screen and (min-width: 769px)', 51 | md: 'screen and (min-width: 992px)', 52 | lg: 'screen and (min-width: 1200px)' 53 | ); 54 | 55 | @mixin respond-to($point) { 56 | $query: map-get($break_points, $point); 57 | @media #{$query} { 58 | @content; 59 | } 60 | } 61 | @mixin clamp($num){ 62 | display: -webkit-box; 63 | -webkit-line-clamp: $num; 64 | -webkit-box-orient: vertical; 65 | overflow: hidden; 66 | } 67 | -------------------------------------------------------------------------------- /src/style/reset.scss: -------------------------------------------------------------------------------- 1 | // 全局样式覆盖 2 | @import './index.scss'; 3 | body, 4 | button, 5 | input, 6 | p, 7 | div, 8 | section, 9 | article, 10 | td, 11 | th, 12 | span, 13 | textarea, 14 | form, 15 | footer, 16 | header, 17 | nav, 18 | main, 19 | address, 20 | aside, 21 | pre, 22 | ul, 23 | li, 24 | canvas { 25 | margin: 0; 26 | padding: 0; 27 | outline: none; 28 | box-sizing: border-box; 29 | } 30 | body { 31 | font: 400 14px/20px -apple-system, BlinkMacSystemFont, Arial, Helvetica, Tahoma, '华文细黑', 'Microsoft YaHei', 32 | '微软雅黑', sans-serif; 33 | 34 | @include themeify() { 35 | color: themed('color-default'); 36 | } 37 | } 38 | li { 39 | list-style: none; 40 | } 41 | a { 42 | text-decoration: none; 43 | @include themeify() { 44 | color: themed('color-default'); 45 | } 46 | } 47 | #app { 48 | overflow: hidden; 49 | } 50 | .code-toolbar { 51 | border-radius: 8px; 52 | padding-top: 24px; 53 | > pre { 54 | border-radius: 0 0 8px 8px; 55 | } 56 | > .toolbar { 57 | opacity: 1 !important; 58 | width: 100%; 59 | top: 0px !important; 60 | right: 0px !important; 61 | background: #30343f; 62 | border-radius: 8px 8px 0 0; 63 | .toolbar-item:first-child { 64 | float: right; 65 | margin-right: 12px; 66 | } 67 | .copy-to-clipboard-button { 68 | color: #bbb !important; 69 | cursor: pointer; 70 | background: transparent !important; 71 | } 72 | .copy-to-clipboard-button:hover { 73 | color: #bbb !important; 74 | } 75 | } 76 | .toolbar-item__content { 77 | width: 100%; 78 | height: 32px; 79 | padding: 0 14px; 80 | display: flex; 81 | align-items: center; 82 | span { 83 | display: inline-block; 84 | width: 12px; 85 | height: 12px; 86 | border-radius: 6px; 87 | background: rgb(252, 98, 93); 88 | box-shadow: rgb(253, 188, 64) 20px 0px, rgb(53, 205, 75) 40px 0px; 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/style/theme/dark/color.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc: 颜色表 3 | */ 4 | 5 | $color: (); 6 | -------------------------------------------------------------------------------- /src/style/theme/dark/font.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc: 字体表 ,切换主题有文字变化时在此文件补充 3 | */ 4 | 5 | $font: ( 6 | font12: 12px, 7 | font14: 14px 8 | ); 9 | -------------------------------------------------------------------------------- /src/style/theme/dark/index.scss: -------------------------------------------------------------------------------- 1 | // 暗黑色系 2 | @import './color.scss'; 3 | @import './font.scss'; 4 | @import '~@/style/function.scss'; 5 | 6 | $dark: merge($color, $font); 7 | -------------------------------------------------------------------------------- /src/style/theme/light/color.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc: 颜色表 3 | */ 4 | 5 | $color: ( 6 | color-ele-primary: #409eff, 7 | color-ele-success: #67c23a, 8 | color-ele-danger: #f56c6c, 9 | color-ele-holder: #c0c4cc, 10 | color-default: #4c4948, 11 | color-navbar: #eee, 12 | color-navbar-rollup-bg: rgba(255, 255, 255, 0.8), 13 | color-navbar-rollup-color: #4c4948, 14 | color-home-article-detail: #858585, 15 | color-title: #fff, 16 | color-avatar-bg: #c0c4cc, 17 | color-avatar-icon: #fff, 18 | color-hr-border: #a4d8fa, 19 | bg-header-mask: rgba(0, 0, 0, 0.5), 20 | color-copyright-border: #eee, 21 | color-copyright-label: #46adf1, 22 | color-comments: #4c4948, 23 | color-list-hover: #40a0ffb4, 24 | color-catalog-active: #03c1b4 25 | ); 26 | -------------------------------------------------------------------------------- /src/style/theme/light/font.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc: 字体表 ,切换主题有文字变化时在此文件补充 3 | */ 4 | 5 | $font: ( 6 | font12: 12px, 7 | font14: 14px 8 | ); 9 | -------------------------------------------------------------------------------- /src/style/theme/light/index.scss: -------------------------------------------------------------------------------- 1 | // 亮色,默认色系 2 | @import './color.scss'; 3 | @import './font.scss'; 4 | @import '~@/style/function.scss'; 5 | 6 | $light: merge($color, $font); 7 | -------------------------------------------------------------------------------- /src/style/theme/map.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc: 主题映射表 3 | * @use key即为主题后缀 .mapblog—theme-${key} 4 | */ 5 | @import './dark/index.scss'; 6 | @import './light/index.scss'; 7 | 8 | $themes: ( 9 | dark: $dark, 10 | light: $light 11 | ); 12 | -------------------------------------------------------------------------------- /src/utils/cls.js: -------------------------------------------------------------------------------- 1 | export function cls(cls) { 2 | if (document.getElementsByClassName) { 3 | return document.getElementsByClassName(cls)[0] 4 | } else { 5 | let arr = document.getElementsByTagName('*') 6 | let tempArr = [] 7 | for (let i = 0; i < arr.length; i++) { 8 | let clsArr = arr[i].className.split(' ') 9 | for (let k = 0; k < clsArr.length; k++) { 10 | if (clsArr[k] === cls) { 11 | tempArr.push(arr[i]) 12 | } 13 | } 14 | } 15 | return tempArr[0] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/errorCode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 错误码 3 | * @author justJokee 4 | */ 5 | import { Message } from 'element-ui' 6 | export function errorCode(code) { 7 | switch (code) { 8 | case 200: 9 | break 10 | case 100: 11 | _message('warning', '昵称已存在') 12 | break 13 | case 101: 14 | _message('warning', '您已经点过赞了 ~') 15 | break 16 | case 102: 17 | _message('warning', '文章不存在或已删除 ~') 18 | break 19 | case 429: 20 | _message('warning', '访问次数超限,请稍后再试 ~') 21 | break 22 | } 23 | } 24 | 25 | function _message(t, m) { 26 | Message({ 27 | type: t, 28 | message: m, 29 | center: true 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/generateTree.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 将打平的用层级关系维护的树状数据,组装成树结构 3 | * @param {*} flatList Object[] 4 | * @param {*} options { level: Number} level,维护层级关系的字段 5 | * @used 目前用于自动生成文章的目录结构 6 | * @returns Array 7 | * @author justJokee 8 | */ 9 | export function generateTree(flatList, options = { level: 'level' }) { 10 | const result = [] 11 | let stack = [] 12 | flatList.forEach((item) => { 13 | // 栈为空,则直接入栈 14 | if (!stack.length) { 15 | item.children = [] 16 | stack.push(item) 17 | result.push(item) 18 | } else { 19 | // 当前元素级别大于栈底元素,则倒序遍历栈,找到其父级并挂载 20 | if (item[options.level] > stack[0][options.level]) { 21 | stack.reverse().some((el) => { 22 | if (el[options.level] < item[options.level]) { 23 | if (!el.children) el.children = [] 24 | el.children.push(item) 25 | return true 26 | } 27 | }) 28 | stack.reverse() 29 | stack.push(item) 30 | } 31 | // 完成子树收集 32 | else { 33 | // 清空栈 34 | stack = [] 35 | item.children = [] 36 | stack.push(item) 37 | result.push(item) 38 | } 39 | } 40 | }) 41 | return result 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/getBrowserInfo.js: -------------------------------------------------------------------------------- 1 | export function getBrowserInfo() { 2 | let agent = navigator.userAgent.toLowerCase(), 3 | reg_ie = /msie [\d.]+;/gi, 4 | reg_ff = /firefox\/[\d.]+/gi, 5 | reg_chrome = /chrome\/[\d.]+/gi, 6 | reg_saf = /safari\/[\d.]+/gi 7 | //IE11以下版本 8 | if (agent.indexOf('msie') > 0) { 9 | return agent.match(reg_ie) 10 | } 11 | //IE11版本中不包括MSIE字段 12 | if (agent.indexOf('trident') > 0 && agent.indexOf('rv') > 0) { 13 | return 'IE ' + agent.match(/rv:(\d+\.\d+)/)[1] 14 | } 15 | //firefox 16 | if (agent.indexOf('firefox') > 0) { 17 | return agent.match(reg_ff) 18 | } 19 | //Chrome 20 | if (agent.indexOf('chrome') > 0) { 21 | return agent.match(reg_chrome) 22 | } 23 | //Safari 24 | if (agent.indexOf('safari') > 0 && agent.indexOf('chrome') < 0) { 25 | return agent.match(reg_saf) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/getElementTop.js: -------------------------------------------------------------------------------- 1 | export function getElementTop(ele) { 2 | let actualTop = ele.offsetTop 3 | let current = ele.offsetParent 4 | while (current !== null) { 5 | actualTop += current.offsetTop 6 | current = current.offsetParent 7 | } 8 | return actualTop 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/getRandomCharacter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取随机字符串 3 | * @param {*} digits: Number 指定位数 4 | * @param {*} join: String 分隔字符串 5 | * @returns String 6 | * @auther justJokee 7 | */ 8 | export function getRandomCharacter(digits = 4, join = '') { 9 | const words = [ 10 | 'A', 11 | 'B', 12 | 'C', 13 | 'D', 14 | 'E', 15 | 'F', 16 | 'G', 17 | 'H', 18 | 'I', 19 | 'J', 20 | 'K', 21 | 'L', 22 | 'M', 23 | 'N', 24 | 'O', 25 | 'P', 26 | 'Q', 27 | 'R', 28 | 'S', 29 | 'T', 30 | 'U', 31 | 'V', 32 | 'W', 33 | 'X', 34 | 'Y', 35 | 'Z' 36 | ] 37 | if (typeof digits !== 'number') throw new Error('参数必须为 number 类型') 38 | return Array(digits) 39 | .fill() 40 | .map(() => words[Math.ceil(Math.random() * 1000) % 26]) 41 | .join(join) 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/getRandomColor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取随机色 3 | * @returns 4 | * @author justJokee 5 | */ 6 | 7 | export function getRandomColor() { 8 | const r = Math.floor(Math.random() * 255) 9 | const g = Math.floor(Math.random() * 255) 10 | const b = Math.floor(Math.random() * 255) 11 | const rgb = `rgb(${r},${g},${b})` 12 | return rgb 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/getScrollTop.js: -------------------------------------------------------------------------------- 1 | export function getScrollTop() { 2 | let scrollTop = 0 3 | if (document.documentElement && document.documentElement.scrollTop) { 4 | scrollTop = document.documentElement.scrollTop 5 | } else if (document.body) { 6 | scrollTop = document.body.scrollTop 7 | } 8 | return scrollTop 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/getUrlParams.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取search & hash路由上的参数 3 | * @param {*} urlStr 4 | * @param {*} field 5 | * @returns string | Object 6 | * @author Justjokee 7 | */ 8 | export function getUrlParams(urlStr, field) { 9 | let searchParams = {} 10 | let hashParams = {} 11 | if (window.location.hash) hashParams = this.getSearchParams(window.location.hash) 12 | searchParams = getSearchParams(urlStr) 13 | if (field) return Object.assign(hashParams, searchParams)[field] 14 | return Object.assign(hashParams, searchParams) 15 | } 16 | 17 | function getSearchParams(urlStr) { 18 | let search = urlStr || document.location.search 19 | search = search.split('?')[1] 20 | let params = {} 21 | if (search) { 22 | if (search.lastIndexOf('/') == search.length - 1) search = search.substr(0, search.length - 1) 23 | let eps = search.split('&') 24 | eps.forEach((ep) => { 25 | let k_v = ep.split('=') 26 | params[k_v[0]] = k_v[1] 27 | }) 28 | } 29 | return params 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/loadEle.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { 3 | Button, 4 | Select, 5 | Tag, 6 | Card, 7 | Dropdown, 8 | DropdownMenu, 9 | DropdownItem, 10 | Pagination, 11 | Input, 12 | Dialog, 13 | Form, 14 | FormItem, 15 | Popover, 16 | Tabs, 17 | TabPane, 18 | Scrollbar, 19 | Message, 20 | Timeline, 21 | TimelineItem, 22 | RadioGroup, 23 | RadioButton, 24 | Menu, 25 | Submenu, 26 | MenuItem, 27 | drawer 28 | } from 'element-ui' 29 | import layout from '@/views/layout/' 30 | Vue.component('layout', layout) 31 | Vue.use(Button) 32 | Vue.use(Select) 33 | Vue.use(Tag) 34 | Vue.use(Card) 35 | Vue.use(Dropdown) 36 | Vue.use(DropdownMenu) 37 | Vue.use(DropdownItem) 38 | Vue.use(Pagination) 39 | Vue.use(Input) 40 | Vue.use(Dialog) 41 | Vue.use(Form) 42 | Vue.use(FormItem) 43 | Vue.use(Popover) 44 | Vue.use(Tabs) 45 | Vue.use(TabPane) 46 | Vue.use(Scrollbar) 47 | Vue.use(Timeline) 48 | Vue.use(TimelineItem) 49 | Vue.use(RadioGroup) 50 | Vue.use(RadioButton) 51 | Vue.use(Menu) 52 | Vue.use(MenuItem) 53 | Vue.use(Submenu) 54 | Vue.use(drawer) 55 | Vue.prototype.$message = Message 56 | -------------------------------------------------------------------------------- /src/utils/requestAnimation.js: -------------------------------------------------------------------------------- 1 | var requestAnimation = function () { 2 | var lastTime = 0 3 | var vendors = ['ms', 'moz', 'webkit', 'o'] 4 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 5 | window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'] 6 | window.cancelAnimationFrame = 7 | window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame'] 8 | } 9 | 10 | if (!window.requestAnimationFrame) { 11 | window.requestAnimationFrame = function (callback) { 12 | var currTime = new Date().getTime() 13 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)) 14 | var id = window.setTimeout(function () { 15 | callback(currTime + timeToCall) 16 | }, timeToCall) 17 | lastTime = currTime + timeToCall 18 | return id 19 | } 20 | } 21 | 22 | if (!window.cancelAnimationFrame) { 23 | window.cancelAnimationFrame = function (id) { 24 | clearTimeout(id) 25 | } 26 | } 27 | } 28 | export { requestAnimation } 29 | -------------------------------------------------------------------------------- /src/utils/scrollTo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 窗口滚动动画 3 | * @param {*} pos 需要设置的scrollTop值 4 | * @param {*} duration 动画时长 5 | * @returns 6 | * @author justJokee 7 | */ 8 | import { getScrollTop } from '@/utils/getScrollTop' 9 | export default function scrollTo(pos = 0, duration = 250) { 10 | // 计算需要滚动的距离 11 | const distance = pos - getScrollTop() 12 | let scrollY = getScrollTop() 13 | let oldTimestamp = null 14 | if (distance == 0) return 15 | 16 | function step(newTimestamp) { 17 | if (oldTimestamp !== null) { 18 | // 上移 19 | if (distance < 0) { 20 | scrollY -= (Math.abs(distance) * (newTimestamp - oldTimestamp)) / duration 21 | if (scrollY <= pos) return (document.scrollingElement.scrollTop = pos) 22 | } 23 | // 下移 24 | else { 25 | scrollY += (Math.abs(distance) * (newTimestamp - oldTimestamp)) / duration 26 | if (scrollY >= pos) return (document.scrollingElement.scrollTop = pos) 27 | } 28 | document.scrollingElement.scrollTop = scrollY 29 | } 30 | oldTimestamp = newTimestamp 31 | window.requestAnimationFrame(step) 32 | } 33 | window.requestAnimationFrame(step) 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/siblings.js: -------------------------------------------------------------------------------- 1 | export function siblings(ele) { 2 | let arr_siblings = [] 3 | let ele_par = ele.parentNode 4 | let children = ele_par.childNodes 5 | for (let i = 0; i < children.length; i++) { 6 | if (children[i].nodeType == 1 && children[i] !== ele) { 7 | arr_siblings.push(children[i]) 8 | } 9 | } 10 | return arr_siblings 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc localStorage信息存储 3 | * @author Justjokee 4 | */ 5 | 6 | // 访客信息key 7 | const visitorKey = 'v_k_i' 8 | 9 | export const storage = { 10 | getVisitor() { 11 | if (localStorage.getItem(visitorKey)) { 12 | return JSON.parse(localStorage.getItem(visitorKey)) 13 | } 14 | }, 15 | setVisitor(info) { 16 | localStorage.setItem(visitorKey, JSON.stringify(info)) 17 | }, 18 | removeVisitor() { 19 | localStorage.removeItem(visitorKey) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/views/archives/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 归档 3 | @author: justJokee 4 | 5 | 53 | 119 | 185 | -------------------------------------------------------------------------------- /src/views/article-filter/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 按条件搜索文章列表 3 | @author: justJokee 4 | 5 | 22 | 90 | 99 | -------------------------------------------------------------------------------- /src/views/article/components/copyright.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 文章版权信息提示 3 | @author: justJokee 4 | 5 | 31 | 52 | 85 | -------------------------------------------------------------------------------- /src/views/article/components/prevnext.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 文章上/下一篇 3 | @author: justJokee 4 | 5 | 29 | 65 | 144 | -------------------------------------------------------------------------------- /src/views/article/components/share.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 文章分享 3 | @author: justJokee 4 | 5 | 43 | 119 | 185 | -------------------------------------------------------------------------------- /src/views/category/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 分类 3 | @author: justJokee 4 | 5 | 23 | 69 | 86 | -------------------------------------------------------------------------------- /src/views/components/article-iterator.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 文章列表迭代器 3 | @author: justJokee 4 | 5 | 43 | 59 | 148 | -------------------------------------------------------------------------------- /src/views/components/comments-item.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 留言、文章评论展示列表 3 | @author: justJokee 4 | 5 | 40 | 72 | 145 | -------------------------------------------------------------------------------- /src/views/components/comments.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 留言、文章评论展示 3 | @author: justJokee 4 | 5 | 41 | 76 | 101 | -------------------------------------------------------------------------------- /src/views/components/search.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 文章搜索 3 | @author: justJokee 4 | 5 | 39 | 99 | 189 | -------------------------------------------------------------------------------- /src/views/components/site-introduction.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 本站简介 3 | @author: justJokee 4 | 5 | 59 | 78 | 154 | -------------------------------------------------------------------------------- /src/views/components/tags-iterator.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 标签迭代容器 3 | @author: justJokee 4 | 5 | 21 | 67 | 80 | -------------------------------------------------------------------------------- /src/views/layout/components/header/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 布局 - 头部组件 3 | @author: justJokee 4 | 5 | 16 | 34 | 67 | -------------------------------------------------------------------------------- /src/views/layout/components/navbar/horizontal-navbar.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: navbar 适配大屏幕 3 | @author: justJokee 4 | 5 | 60 | 77 | 135 | -------------------------------------------------------------------------------- /src/views/layout/components/navbar/vertical-navbar.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: navbar 适配移动端 3 | @author: justJokee 4 | 5 | 55 | 83 | 90 | -------------------------------------------------------------------------------- /src/views/layout/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 布局组件 3 | @author: justJokee 4 | 5 | 51 | 80 | 199 | -------------------------------------------------------------------------------- /src/views/movies/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 电影 3 | @author: justJokee 4 | 5 | 54 | 116 | 182 | -------------------------------------------------------------------------------- /src/views/pannel/components/pannel-archives.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 看板 - 文章归档 3 | @author: justJokee 4 | 5 | 24 | 47 | 71 | -------------------------------------------------------------------------------- /src/views/pannel/components/pannel-articles.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 看板 - 最新文章 3 | @author: justJokee 4 | 5 | 37 | 52 | 60 | -------------------------------------------------------------------------------- /src/views/pannel/components/pannel-catalog.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 看板 - 文章目录树 3 | @author: justJokee 4 | 5 | 18 | 39 | 54 | -------------------------------------------------------------------------------- /src/views/pannel/components/pannel-category.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 看板 - 文章分类 3 | @author: justJokee 4 | 5 | 23 | 51 | 74 | -------------------------------------------------------------------------------- /src/views/pannel/components/pannel-comments.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 看板 - 最新文章评论 3 | @author: justJokee 4 | 5 | 38 | 53 | 73 | -------------------------------------------------------------------------------- /src/views/pannel/components/pannel-introduction.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 看板 - 本站简介 3 | @author: justJokee 4 | 5 | 12 | 26 | 39 | -------------------------------------------------------------------------------- /src/views/pannel/components/pannel-tags.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 看板 - 文章标签 3 | @author: justJokee 4 | 5 | 18 | 35 | 42 | -------------------------------------------------------------------------------- /src/views/pannel/components/tree-folder.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 布局组件 3 | @author: justJokee 4 | 5 | 31 | 64 | 97 | -------------------------------------------------------------------------------- /src/views/pannel/style/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin pannel-frame { 2 | .pannel__item-title { 3 | font-size: 16px; 4 | 5 | > [class*='el-icon-'] { 6 | font-size: 18px; 7 | margin-right: 12px; 8 | } 9 | } 10 | .pannel__item-body { 11 | margin-top: 10px; 12 | } 13 | } 14 | 15 | @mixin articles-comments { 16 | @include pannel-frame; 17 | .pannel__item-body { 18 | .body-item { 19 | display: flex; 20 | padding: 8px 0; 21 | } 22 | .body-pic { 23 | flex: 0 0 auto; 24 | width: 60px; 25 | height: 60px; 26 | overflow: hidden; 27 | img { 28 | transition: all ease 0.38s; 29 | width: 100%; 30 | height: 100%; 31 | object-fit: cover; 32 | } 33 | img:hover { 34 | transform: scale(1.2); 35 | } 36 | } 37 | .body-info { 38 | margin-left: 12px; 39 | &__title a { 40 | transition: all 0.38s ease-in-out; 41 | @include clamp(2); 42 | } 43 | &__title:hover a, 44 | &__title:hover { 45 | @include themeify() { 46 | color: themed('color-ele-primary'); 47 | } 48 | } 49 | &__name { 50 | @include themeify() { 51 | color: themed('color-home-article-detail'); 52 | } 53 | font-size: 12px; 54 | } 55 | &__date { 56 | @include themeify() { 57 | color: themed('color-home-article-detail'); 58 | } 59 | font-size: 12px; 60 | padding: 4px 0; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/views/tags/index.vue: -------------------------------------------------------------------------------- 1 | 2 | @desc: 标签 3 | @author: justJokee 4 | 5 | 12 | 44 | 47 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/.gitkeep -------------------------------------------------------------------------------- /static/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 额~页面走丢了... 6 | 7 | 8 |

(⊙o⊙)…页面走丢了

9 | 10 | -------------------------------------------------------------------------------- /static/gc_back.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= title %> 5 | 8 | 9 | 10 |
11 |

数据传输中,请稍后...

12 |
13 | 14 | 24 | 25 | -------------------------------------------------------------------------------- /static/img/avatar/avatar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/avatar/avatar.jpeg -------------------------------------------------------------------------------- /static/img/cover/archive.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/archive.jpeg -------------------------------------------------------------------------------- /static/img/cover/articles.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/articles.jpeg -------------------------------------------------------------------------------- /static/img/cover/category.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/category.jpg -------------------------------------------------------------------------------- /static/img/cover/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/default.jpg -------------------------------------------------------------------------------- /static/img/cover/home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/home.jpg -------------------------------------------------------------------------------- /static/img/cover/movie.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/movie.jpeg -------------------------------------------------------------------------------- /static/img/cover/movie.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/movie.jpg -------------------------------------------------------------------------------- /static/img/cover/msgboard.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/msgboard.jpeg -------------------------------------------------------------------------------- /static/img/cover/tags.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/cover/tags.jpg -------------------------------------------------------------------------------- /static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justJokee/vue-ssr-blog/f944768f8a87f10841b121fecd3055ca876579ad/static/img/favicon.ico -------------------------------------------------------------------------------- /static/js/ribbon.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Created by zproo on 2017/4/8. 4 | */ 5 | !(function() { 6 | document.addEventListener('touchmove', function(e) { 7 | e.preventDefault() 8 | }) 9 | 10 | function getAttr(script, attr, default_val) { 11 | return Number(script.getAttribute(attr)) || default_val 12 | } 13 | 14 | // 获取自定义配置 15 | var ribbon = document.getElementById('ribbon') // 当前加载的script 16 | var config = { 17 | zIndex: getAttr(ribbon, 'zIndex', -1), // z-index 18 | alpha: getAttr(ribbon, 'alpha', 0.6), // alpha 19 | ribbon_width: getAttr(ribbon, 'size', 100) // size 20 | } 21 | 22 | var canvas = document.createElement('canvas') 23 | canvas.style.cssText = 'position:fixed;top:0;left:0;z-index:' + config.zIndex 24 | document.getElementsByTagName('body')[0].appendChild(canvas) 25 | 26 | var canvasRibbon = canvas, 27 | ctx = canvasRibbon.getContext('2d'), // 获取canvas 2d上下文 28 | dpr = window.devicePixelRatio || 1, // the size of one CSS pixel to the size of one physical pixel. 29 | width = window.innerWidth, // 返回窗口的文档显示区的宽高 30 | height = window.innerHeight, 31 | RIBBON_WIDTH = config.ribbon_width, 32 | path, 33 | math = Math, 34 | r = 0, 35 | PI_2 = math.PI * 2, // 圆周率*2 36 | cos = math.cos, // cos函数返回一个数值的余弦值(-1~1) 37 | random = math.random, // 返回0-1随机数 38 | proportion = dpr == 1 ? 0.7 : 0.5 39 | canvasRibbon.width = width * dpr // 返回实际宽高 40 | canvasRibbon.height = height * dpr 41 | ctx.scale(dpr, dpr) // 水平、竖直方向缩放 42 | ctx.globalAlpha = config.alpha // 图形透明度 43 | 44 | function init() { 45 | ctx.clearRect(0, 0, width, height) // 擦除之前绘制内容 46 | path = [ 47 | { x: 0, y: height * proportion + RIBBON_WIDTH }, 48 | { x: 0, y: height * proportion - RIBBON_WIDTH } 49 | ] 50 | // 路径没有填满屏幕宽度时,绘制路径 51 | while (path[1].x < width + RIBBON_WIDTH) { 52 | draw(path[0], path[1]) 53 | } 54 | } 55 | 56 | function draw(start, end) { 57 | ctx.beginPath() // 创建一个新的路径 58 | ctx.moveTo(start.x, start.y) // path起点 59 | ctx.lineTo(end.x, end.y) // path终点 60 | var nextX = end.x + (random() * 2 - 0.25) * RIBBON_WIDTH, 61 | nextY = geneY(end.y) 62 | ctx.lineTo(nextX, nextY) 63 | ctx.closePath() 64 | 65 | r -= PI_2 / -50 66 | // 随机生成并设置canvas路径16进制颜色 67 | ctx.fillStyle = 68 | '#' + 69 | ( 70 | ((cos(r) * 127 + 128) << 16) | 71 | ((cos(r + PI_2 / 3) * 127 + 128) << 8) | 72 | (cos(r + (PI_2 / 3) * 2) * 127 + 128) 73 | ).toString(16) 74 | ctx.fill() // 根据当前样式填充路径 75 | path[0] = path[1] // 起点更新为当前终点 76 | path[1] = { x: nextX, y: nextY } // 更新终点 77 | } 78 | 79 | function geneY(y) { 80 | var temp = y + (random() * 2 - 1.1) * RIBBON_WIDTH 81 | return temp > height || temp < 0 ? geneY(y) : temp 82 | } 83 | 84 | document.onclick = init 85 | document.ontouchstart = init 86 | init() 87 | })() 88 | -------------------------------------------------------------------------------- /static/qc_back.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | QQConnect JSDK - redirectURI 7 | 14 | 15 | 16 |
17 |

数据传输中,请稍后...

18 |
19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /template/secret.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 密钥管理模板 3 | * @author justJokee 4 | */ 5 | 6 | // 数据库配置 7 | exports.db = { 8 | user: 'admin', 9 | pwd: '12345', 10 | db: 'blog' 11 | } 12 | // 用户密码,签证盐值 13 | exports.userSecret = { 14 | account: 'test', 15 | pwd: '12345', 16 | salt: '12345', 17 | avatar: 'https://avatars.githubusercontent.com/u/35912907?v=4' 18 | } 19 | // github三方服务配置 20 | exports.githubSecret = { 21 | clientId: '', 22 | secret: '' 23 | } 24 | // 七牛云配置 25 | exports.qiniuConfig = { 26 | AccessKey: '', 27 | SecretKey: '', 28 | Bucket: '', 29 | Port: 9000, 30 | // demo 启动后会在本地 /uptoken 上提供获取 uptoken 的接口,所以这里可以填 'token' 31 | UptokenUrl: '', 32 | // Bucket 的外链默认域名,在 Bucket 的内容管理里查看 33 | Domain: '' 34 | } 35 | // 豆瓣uid 36 | exports.doubanConfig = { 37 | uid: '173712770' 38 | } 39 | --------------------------------------------------------------------------------