├── .gitignore ├── LICENSE ├── README.md ├── backstage ├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .stylelintrc.json ├── .travis.yml ├── .umirc.js ├── config │ ├── id_rsa.enc │ └── theme.config.js ├── jest.config.js ├── manifest.json ├── package.json ├── public │ ├── america.svg │ ├── china.svg │ ├── favicon.ico │ └── logo.svg ├── scripts │ └── translate.js ├── server.js └── src │ ├── components │ ├── BraftEditor │ │ ├── BraftEditor.js │ │ └── package.json │ ├── DropOption │ │ ├── DropOption.js │ │ └── package.json │ ├── FilterItem │ │ ├── FilterItem.js │ │ ├── FilterItem.less │ │ └── package.json │ ├── Layout │ │ ├── Bread.js │ │ ├── Bread.less │ │ ├── Header.js │ │ ├── Header.less │ │ ├── Menu.js │ │ ├── Sider.js │ │ ├── Sider.less │ │ └── index.js │ ├── Loader │ │ ├── Loader.js │ │ ├── Loader.less │ │ └── package.json │ ├── MediaLink │ │ ├── MediaLink.js │ │ └── package.json │ ├── Page │ │ ├── Page.js │ │ ├── Page.less │ │ └── package.json │ ├── ScrollBar │ │ ├── index.js │ │ └── index.less │ └── index.js │ ├── layouts │ ├── BaseLayout.js │ ├── BaseLayout.less │ ├── PrimaryLayout.js │ ├── PrimaryLayout.less │ ├── PublicLayout.js │ └── index.js │ ├── locales │ ├── en │ │ └── messages.json │ └── zh │ │ └── messages.json │ ├── models │ └── app.js │ ├── pages │ ├── 404.js │ ├── 404.less │ ├── adminGroup │ │ ├── components │ │ │ ├── Filter.js │ │ │ ├── List.js │ │ │ ├── List.less │ │ │ └── Modal.js │ │ ├── index.js │ │ └── model.js │ ├── adminUser │ │ ├── components │ │ │ ├── Filter.js │ │ │ ├── List.js │ │ │ ├── List.less │ │ │ └── Modal.js │ │ ├── index.js │ │ └── model.js │ ├── article │ │ ├── $id │ │ │ ├── index.js │ │ │ └── model.js │ │ ├── components │ │ │ ├── List.js │ │ │ └── List.less │ │ ├── index.js │ │ └── model.js │ ├── category │ │ ├── components │ │ │ ├── Filter.js │ │ │ ├── List.js │ │ │ ├── List.less │ │ │ └── Modal.js │ │ ├── index.js │ │ └── model.js │ ├── dashboard │ │ ├── components │ │ │ ├── browser.js │ │ │ ├── browser.less │ │ │ ├── comments.js │ │ │ ├── comments.less │ │ │ ├── completed.js │ │ │ ├── completed.less │ │ │ ├── cpu.js │ │ │ ├── cpu.less │ │ │ ├── index.js │ │ │ ├── numberCard.js │ │ │ ├── numberCard.less │ │ │ ├── quote.js │ │ │ ├── quote.less │ │ │ ├── recentSales.js │ │ │ ├── recentSales.less │ │ │ ├── sales.js │ │ │ ├── sales.less │ │ │ ├── user-background.png │ │ │ ├── user.js │ │ │ ├── user.less │ │ │ ├── weather.js │ │ │ └── weather.less │ │ ├── index.js │ │ ├── index.less │ │ ├── model.js │ │ └── services │ │ │ ├── dashboard.js │ │ │ └── weather.js │ ├── index.js │ ├── login │ │ ├── index.js │ │ ├── index.less │ │ └── model.js │ ├── media │ │ ├── components │ │ │ ├── Filter.js │ │ │ ├── List.js │ │ │ ├── List.less │ │ │ └── Modal.js │ │ ├── index.js │ │ └── model.js │ └── request │ │ ├── index.js │ │ └── index.less │ ├── plugins │ └── onError.js │ ├── services │ ├── api.js │ ├── index.js │ └── routes.js │ ├── themes │ ├── default.less │ ├── index.less │ ├── mixin.less │ └── vars.less │ └── utils │ ├── auth.js │ ├── city.js │ ├── config.js │ ├── helpers.js │ ├── index.js │ ├── index.test.js │ ├── model.js │ ├── request.js │ └── theme.js ├── docs ├── img1.jpg ├── img2.jpg └── img3.jpg ├── front ├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── next.config.js ├── nodemon.json ├── package.json ├── pages │ ├── _app.js │ ├── _document.js │ ├── about.js │ ├── index.js │ ├── login.js │ ├── p.js │ └── register.js ├── server │ ├── index.js │ ├── match.js │ ├── renderFromCache.js │ └── rootStaticFiles.js ├── src │ ├── components │ │ ├── Header.js │ │ ├── I18n │ │ │ ├── helpers.js │ │ │ └── index.js │ │ ├── Link │ │ │ ├── helpers.js │ │ │ └── index.js │ │ ├── ListItem.js │ │ ├── ListTitle.js │ │ ├── LoadingIndicator │ │ │ ├── Circle.js │ │ │ ├── Wrapper.js │ │ │ └── index.js │ │ ├── Pagination │ │ │ ├── helpers.js │ │ │ └── index.js │ │ └── TextField.js │ ├── containers │ │ ├── DetailPage │ │ │ └── index.js │ │ ├── HomePage │ │ │ ├── Loadable.js │ │ │ └── index.js │ │ └── NotFoundPage │ │ │ ├── Loadable.js │ │ │ └── index.js │ ├── helpers │ │ ├── Head.js │ │ ├── I18n.js │ │ ├── api.js │ │ ├── auth.js │ │ ├── config.js │ │ ├── index.js │ │ ├── locales │ │ │ ├── en.json │ │ │ └── zh.json │ │ ├── request.js │ │ ├── router.js │ │ ├── seo.json │ │ └── valaditor.js │ ├── hocs │ │ ├── defaultPage.js │ │ └── securePage.js │ └── store │ │ ├── actions.js │ │ ├── appRedux.js │ │ ├── appSaga.js │ │ ├── configureStore.js │ │ ├── rootReducer.js │ │ └── rootSaga.js └── static │ ├── bootstrap.min.css │ ├── global-styles.css │ ├── logo.png │ ├── normalize.css │ ├── nprogress.css │ └── robots.txt ├── lerna.json ├── package.json └── server ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── docker-compose.yml ├── logs └── .gitkeep ├── nginx.conf ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── app.ts ├── assets │ └── dashboard.json ├── config │ └── index.ts ├── constants.ts ├── controllers │ ├── article.controller.ts │ ├── category.controller.ts │ ├── common.controller.ts │ ├── dashboard.controller.ts │ └── index.ts ├── db │ ├── mongodb.ts │ ├── redis.ts │ └── typeorm.ts ├── entities │ ├── abstract.entity.ts │ ├── article.entity.ts │ ├── category.entity.ts │ ├── index.ts │ └── user.entity.ts ├── middlewares │ ├── auth.ts │ └── pipe.ts ├── router.ts ├── routes.js ├── services │ ├── article.service.ts │ ├── category.service.ts │ └── index.ts └── utils │ ├── cache.ts │ ├── crypto.ts │ ├── email.ts │ ├── error.ts │ ├── index.ts │ ├── joi.ts │ ├── jwt.ts │ ├── lbs.ts │ ├── logger.ts │ ├── oss.ts │ ├── random.ts │ ├── regx.ts │ ├── sms.ts │ ├── validate.ts │ └── wechat.ts ├── tests ├── 0.beforeTest.js ├── 1.installTest.js └── helpers.js ├── tmp └── .gitkeep ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 mai血过年 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hotchcms 2 | 3 | 前后端分离的cms建站系统. 4 | 5 | > 迭代中 6 | 7 | * 前台展示 8 | ![](./docs/img1.jpg) 9 | * 后台展示 10 | ![](./docs/img2.jpg) 11 | ![](./docs/img3.jpg) 12 | 13 | ## 服务端 14 | 15 | ### 技术栈 16 | 17 | * 服务 `koa2` 18 | * 数据库 `mongoose` 19 | * 缓存 `redis` 20 | * 路由 `koa-router` 21 | * token验证 `jsonwebtoken` 22 | * 权限 `koa-authority` 23 | * 参数验证 `koa-middle-validator` 24 | * 日志 `tracer` 25 | * 测试 `mocha` `supertest` 26 | 27 | ### 安装 28 | 29 | #### 方法1. Docker 30 | 31 | 1. 修改配置文件 `config/setting.js`。`mongodb.host = localhost` 修改为 `mongodb.host = mongodb`,`redis.host = localhost` 修改为 `redis.host = redis` *(修改值为 docker-compose.yml 文件内配置的地址)* 32 | 2. 安装容器 `$ docker-compose up` 33 | 3. 进入 **Node** 容器 `$ docker exec -it NodeContainerId /bin/bash` 34 | 4. **Node** 容器内执行初始化数据 `$ npm run init` 35 | 36 | #### 方法2. 常规 37 | 38 | 1. 安装 `mongodb^v3.0+`, `redis^v4.0+` `node^v8.0+` 39 | 2. 修改 `config/setting.js` 配置 40 | 3. `$ npm install` 安装依赖 41 | 4. `$ npm start` 启动生产环境 42 | 5. `$ npm run init` 初始化数据 (首次安装时执行) 43 | 44 | ## 管理台 45 | 46 | ### 技术栈 47 | 48 | [antd-admin](https://github.com/zuiidea/antd-admin) 49 | 50 | ### 安装 51 | 52 | 1. 修改 `src/utils/config.js` 文件内 `BASE_URL` 为后端接口地址 53 | 2. `$ npm run build` 打包 54 | 3. `$ npm start` 生产环境启动 55 | 56 | ## 客户端 57 | 58 | ### 技术栈 59 | 60 | * 基础框架 `react` `next.js` 61 | * 服务端 `express` 62 | * 数据层 `redux` `redux-saga` `immutable` 63 | * 请求 `isomorphic-unfetch` 64 | * 样式 `styled-components` 65 | 66 | ### 安装 67 | 68 | 1. 修改 `helpers/config.js` 文件内 `BASE_URL` 等配置 69 | 2. `$ npm run build` 打包 70 | 71 | ## LICENSE 72 | 73 | MIT License 74 | 75 | Copyright (c) 2018 mai血过年 76 | 77 | Permission is hereby granted, free of charge, to any person obtaining a copy 78 | of this software and associated documentation files (the "Software"), to deal 79 | in the Software without restriction, including without limitation the rights 80 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 81 | copies of the Software, and to permit persons to whom the Software is 82 | furnished to do so, subject to the following conditions: 83 | 84 | The above copyright notice and this permission notice shall be included in all 85 | copies or substantial portions of the Software. 86 | 87 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 88 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 89 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 90 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 91 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 92 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 93 | SOFTWARE. 94 | -------------------------------------------------------------------------------- /backstage/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /backstage/.env: -------------------------------------------------------------------------------- 1 | BROWSER=none 2 | HOST=0.0.0.0 3 | PORT=8080 4 | -------------------------------------------------------------------------------- /backstage/.eslintignore: -------------------------------------------------------------------------------- 1 | src/**/*-test.js 2 | src/public 3 | src/routes/chart/ECharts/theme 4 | src/routes/chart/highCharts/mapdata 5 | src/locales/_build/ 6 | src/locales/**/*.js 7 | docs/**/*.js 8 | -------------------------------------------------------------------------------- /backstage/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "rules": { 4 | "jsx-a11y/href-no-hash": "off", 5 | "no-console": "warn", 6 | "valid-jsdoc": "warn" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backstage/.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | npm-debug.log 5 | yarn-error.log 6 | yarn.lock 7 | package-lock.json 8 | 9 | # ide 10 | .idea 11 | 12 | # Mac General 13 | .DS_Store 14 | .AppleDouble 15 | .LSOverride 16 | 17 | # umi 18 | .umi 19 | .umi-production 20 | 21 | # jslingui 22 | src/locales/_build 23 | src/locales/**/*.js -------------------------------------------------------------------------------- /backstage/.prettierignore: -------------------------------------------------------------------------------- 1 | *.svg 2 | *.ejs 3 | .DS_Store 4 | .umi 5 | .umi-production 6 | src/locales/**/*.json -------------------------------------------------------------------------------- /backstage/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /backstage/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard", "stylelint-config-prettier"], 3 | "rules": { 4 | "declaration-empty-line-before": null, 5 | "no-descending-specificity": null, 6 | "selector-pseudo-class-no-unknown": null, 7 | "selector-pseudo-element-colon-notation": null 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backstage/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | script: 5 | - npm run build 6 | before_install: 7 | - | 8 | if [ "$TRAVIS_BRANCH" = "develop" ]; then 9 | for prefixed_envvar in ${!DEV_*}; do 10 | eval export ${prefixed_envvar#DEV_}="${!prefixed_envvar}" 11 | done 12 | elif [ "$TRAVIS_BRANCH" = "master" ]; then 13 | for prefixed_envvar in ${!PROD_*}; do 14 | eval export ${prefixed_envvar#PROD_}="${!prefixed_envvar}" 15 | done 16 | fi 17 | - openssl aes-256-cbc -K $encrypted_18b2305b78c9_key -iv $encrypted_18b2305b78c9_iv -in config/id_rsa.enc -out ~/.ssh/id_rsa -d 18 | - chmod 600 ~/.ssh/id_rsa 19 | - echo -e "Host $HOST\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config 20 | - yarn global add now 21 | after_script: 22 | - scp -o stricthostkeychecking=no -r ./dist root@$HOST:$BUILD_DIR 23 | - cd ./docs && now -A $DOC_NOW_CONFIG -t $NOW_TOKEN && now alias -A $DOC_NOW_CONFIG -t $NOW_TOKEN 24 | -------------------------------------------------------------------------------- /backstage/.umirc.js: -------------------------------------------------------------------------------- 1 | // https://umijs.org/config/ 2 | import { resolve } from 'path' 3 | import { i18n } from './src/utils/config' 4 | 5 | export default { 6 | ignoreMomentLocale: true, 7 | targets: { ie: 9 }, 8 | treeShaking: true, 9 | plugins: [ 10 | [ 11 | // https://umijs.org/plugin/umi-plugin-react.html 12 | 'umi-plugin-react', 13 | { 14 | dva: { immer: true }, 15 | antd: true, 16 | dynamicImport: { 17 | webpackChunkName: true, 18 | loadingComponent: './components/Loader/Loader', 19 | }, 20 | routes: { 21 | exclude: [ 22 | /model\.(j|t)sx?$/, 23 | /service\.(j|t)sx?$/, 24 | /models\//, 25 | /components\//, 26 | /services\//, 27 | ], 28 | update: routes => { 29 | if (!i18n) return routes 30 | 31 | const newRoutes = [] 32 | for (const item of routes[0].routes) { 33 | newRoutes.push(item) 34 | if (item.path) { 35 | newRoutes.push( 36 | Object.assign({}, item, { 37 | path: 38 | `/:lang(${i18n.languages 39 | .map(item => item.key) 40 | .join('|')})` + item.path, 41 | }) 42 | ) 43 | } 44 | } 45 | routes[0].routes = newRoutes 46 | 47 | return routes 48 | }, 49 | }, 50 | dll: { 51 | include: ['dva', 'dva/router', 'dva/saga', 'dva/fetch', 'antd/es'], 52 | }, 53 | pwa: { 54 | manifestOptions: { 55 | srcPath: 'manifest.json', 56 | }, 57 | }, 58 | }, 59 | ], 60 | ], 61 | // Theme for antd 62 | // https://ant.design/docs/react/customize-theme 63 | theme: './config/theme.config.js', 64 | // Webpack Configuration 65 | proxy: {}, 66 | alias: { 67 | api: resolve(__dirname, './src/services/'), 68 | components: resolve(__dirname, './src/components'), 69 | config: resolve(__dirname, './src/utils/config'), 70 | models: resolve(__dirname, './src/models'), 71 | routes: resolve(__dirname, './src/routes'), 72 | services: resolve(__dirname, './src/services'), 73 | themes: resolve(__dirname, './src/themes'), 74 | utils: resolve(__dirname, './src/utils'), 75 | }, 76 | extraBabelPresets: ['@lingui/babel-preset-react'], 77 | extraBabelPlugins: [ 78 | [ 79 | 'import', 80 | { 81 | libraryName: 'lodash', 82 | libraryDirectory: '', 83 | camel2DashComponentName: false, 84 | }, 85 | 'lodash', 86 | ], 87 | ], 88 | } 89 | -------------------------------------------------------------------------------- /backstage/config/id_rsa.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/backstage/config/id_rsa.enc -------------------------------------------------------------------------------- /backstage/config/theme.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const lessToJs = require('less-vars-to-js') 4 | 5 | module.exports = () => { 6 | const themePath = path.join(__dirname, '../src/themes/default.less') 7 | return lessToJs(fs.readFileSync(themePath, 'utf8')) 8 | } 9 | -------------------------------------------------------------------------------- /backstage/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testURL: 'http://localhost:8000', 3 | } 4 | -------------------------------------------------------------------------------- /backstage/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Antd-Admin", 3 | "start_url": ".", 4 | "display": "standalone", 5 | "background_color": "#fff", 6 | "description": "A front-end solution for enterprise applications built upon Ant Design and UmiJS", 7 | "icons": [ 8 | { 9 | "src": "logo.svg", 10 | "sizes": "72x72 96x96 128x128 144x144 152x152 192x192 384x384 512x512" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /backstage/public/china.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 14 | 16 | 18 | 20 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /backstage/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/backstage/public/favicon.ico -------------------------------------------------------------------------------- /backstage/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Created with Sketch. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /backstage/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const compression = require('compression') 3 | const path = require('path') 4 | const resolve = require('path').resolve 5 | 6 | const app = express() 7 | 8 | app.use(compression()) 9 | 10 | function setup (app, options) { 11 | const publicPath = options.publicPath || '/' 12 | const outputPath = options.outputPath || path.resolve(process.cwd(), 'dist') 13 | 14 | app.use(publicPath, express.static(outputPath)) 15 | app.get('*', (req, res) => res.sendFile(path.resolve(outputPath, 'index.html'))) 16 | } 17 | 18 | setup(app, { 19 | outputPath: resolve(process.cwd(), 'dist'), 20 | publicPath: '/', 21 | }) 22 | 23 | app.listen(8080, 'localhost', (err) => { 24 | if (err) { 25 | return console.error(err.message) 26 | } 27 | 28 | console.log('管理平台服务启动, 端口: ' + 8080) 29 | }) -------------------------------------------------------------------------------- /backstage/src/components/BraftEditor/BraftEditor.js: -------------------------------------------------------------------------------- 1 | import BraftEditor from 'braft-editor' 2 | import 'braft-editor/dist/index.css' 3 | 4 | export default BraftEditor 5 | -------------------------------------------------------------------------------- /backstage/src/components/BraftEditor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BraftEditor", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./BraftEditor.js" 6 | } 7 | -------------------------------------------------------------------------------- /backstage/src/components/DropOption/DropOption.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Dropdown, Button, Icon, Menu } from 'antd' 4 | 5 | const DropOption = ({ 6 | onMenuClick, 7 | menuOptions = [], 8 | buttonStyle, 9 | dropdownProps, 10 | }) => { 11 | const menu = menuOptions.map(item => ( 12 | {item.name} 13 | )) 14 | return ( 15 | {menu}} 17 | {...dropdownProps} 18 | > 19 | 23 | 24 | ) 25 | } 26 | 27 | DropOption.propTypes = { 28 | onMenuClick: PropTypes.func, 29 | menuOptions: PropTypes.array.isRequired, 30 | buttonStyle: PropTypes.object, 31 | dropdownProps: PropTypes.object, 32 | } 33 | 34 | export default DropOption 35 | -------------------------------------------------------------------------------- /backstage/src/components/DropOption/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DropOption", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./DropOption.js" 6 | } 7 | -------------------------------------------------------------------------------- /backstage/src/components/FilterItem/FilterItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styles from './FilterItem.less' 4 | 5 | const FilterItem = ({ label = '', children }) => { 6 | const labelArray = label.split('') 7 | return ( 8 |
9 | {labelArray.length > 0 ? ( 10 |
11 | {labelArray.map((item, index) => ( 12 | 13 | {item} 14 | 15 | ))} 16 |
17 | ) : ( 18 | '' 19 | )} 20 |
{children}
21 |
22 | ) 23 | } 24 | 25 | FilterItem.propTypes = { 26 | label: PropTypes.string, 27 | children: PropTypes.element.isRequired, 28 | } 29 | 30 | export default FilterItem 31 | -------------------------------------------------------------------------------- /backstage/src/components/FilterItem/FilterItem.less: -------------------------------------------------------------------------------- 1 | .filterItem { 2 | display: flex; 3 | justify-content: space-between; 4 | 5 | .labelWrap { 6 | width: 64px; 7 | line-height: 28px; 8 | margin-right: 12px; 9 | justify-content: space-between; 10 | display: flex; 11 | overflow: hidden; 12 | } 13 | 14 | .item { 15 | flex: 1; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backstage/src/components/FilterItem/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FilterItem", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./FilterItem.js" 6 | } 7 | -------------------------------------------------------------------------------- /backstage/src/components/Layout/Bread.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, Fragment } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Breadcrumb, Icon } from 'antd' 4 | import Link from 'umi/navlink' 5 | import withRouter from 'umi/withRouter' 6 | import { withI18n } from '@lingui/react' 7 | import { pathMatchRegexp, queryAncestors, addLangPrefix } from 'utils' 8 | import styles from './Bread.less' 9 | 10 | @withI18n() 11 | @withRouter 12 | class Bread extends PureComponent { 13 | generateBreadcrumbs = paths => { 14 | return paths.map((item, key) => { 15 | const content = ( 16 | 17 | {item.icon ? ( 18 | 19 | ) : null} 20 | {item.name} 21 | 22 | ) 23 | 24 | return ( 25 | 26 | {paths.length - 1 !== key ? ( 27 | {content} 28 | ) : ( 29 | content 30 | )} 31 | 32 | ) 33 | }) 34 | } 35 | render() { 36 | const { routeList, location, i18n } = this.props 37 | 38 | // Find a route that matches the pathname. 39 | const currentRoute = routeList.find( 40 | _ => _.route && pathMatchRegexp(_.route, location.pathname) 41 | ) 42 | 43 | // Find the breadcrumb navigation of the current route match and all its ancestors. 44 | const paths = currentRoute 45 | ? queryAncestors(routeList, currentRoute, 'breadcrumbParentId').reverse() 46 | : [ 47 | routeList[0], 48 | { 49 | id: 404, 50 | name: i18n.t`Not Found`, 51 | }, 52 | ] 53 | 54 | return ( 55 | 56 | {this.generateBreadcrumbs(paths)} 57 | 58 | ) 59 | } 60 | } 61 | 62 | Bread.propTypes = { 63 | routeList: PropTypes.array, 64 | } 65 | 66 | export default Bread 67 | -------------------------------------------------------------------------------- /backstage/src/components/Layout/Bread.less: -------------------------------------------------------------------------------- 1 | .bread { 2 | margin-bottom: 24px; 3 | 4 | :global { 5 | .ant-breadcrumb { 6 | display: flex; 7 | align-items: center; 8 | } 9 | } 10 | } 11 | 12 | @media (max-width: 767px) { 13 | .bread { 14 | margin-bottom: 12px; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backstage/src/components/Layout/Sider.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Icon, Switch, Layout } from 'antd' 4 | import { withI18n, Trans } from '@lingui/react' 5 | import ScrollBar from '../ScrollBar' 6 | import { config } from 'utils' 7 | import SiderMenu from './Menu' 8 | import styles from './Sider.less' 9 | 10 | @withI18n() 11 | class Sider extends PureComponent { 12 | render() { 13 | const { 14 | i18n, 15 | menus, 16 | theme, 17 | isMobile, 18 | collapsed, 19 | onThemeChange, 20 | onCollapseChange, 21 | } = this.props 22 | 23 | return ( 24 | 34 |
35 |
36 | logo 37 | {collapsed ? null :

{config.siteName}

} 38 |
39 |
40 | 41 |
42 | 48 | 55 | 56 |
57 | {collapsed ? null : ( 58 |
59 | 60 | 61 | Switch Theme 62 | 63 | 72 |
73 | )} 74 |
75 | ) 76 | } 77 | } 78 | 79 | Sider.propTypes = { 80 | menus: PropTypes.array, 81 | theme: PropTypes.string, 82 | isMobile: PropTypes.bool, 83 | collapsed: PropTypes.bool, 84 | onThemeChange: PropTypes.func, 85 | onCollapseChange: PropTypes.func, 86 | } 87 | 88 | export default Sider 89 | -------------------------------------------------------------------------------- /backstage/src/components/Layout/Sider.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars.less'; 2 | 3 | .sider { 4 | box-shadow: fade(@primary-color, 10%) 0 0 28px 0; 5 | z-index: 10; 6 | :global { 7 | .ant-layout-sider-children { 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: space-between; 11 | } 12 | } 13 | } 14 | 15 | .brand { 16 | z-index: 1; 17 | height: 72px; 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | padding: 0 24px; 22 | box-shadow: 0 1px 9px -3px rgba(0, 0, 0, 0.2); 23 | .logo { 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | 28 | img { 29 | width: 36px; 30 | margin-right: 8px; 31 | } 32 | 33 | h1 { 34 | vertical-align: text-bottom; 35 | font-size: 16px; 36 | text-transform: uppercase; 37 | display: inline-block; 38 | font-weight: 700; 39 | color: @primary-color; 40 | white-space: nowrap; 41 | margin-bottom: 0; 42 | .text-gradient(); 43 | 44 | :local { 45 | animation: fadeRightIn 300ms @ease-in-out; 46 | animation-fill-mode: both; 47 | } 48 | } 49 | } 50 | } 51 | 52 | .menuContainer { 53 | height: ~'calc(100vh - 120px)'; 54 | overflow-x: hidden; 55 | flex: 1; 56 | padding: 24px 0; 57 | 58 | &::-webkit-scrollbar-thumb { 59 | background-color: transparent; 60 | } 61 | 62 | &:hover { 63 | &::-webkit-scrollbar-thumb { 64 | background-color: rgba(0, 0, 0, 0.2); 65 | } 66 | } 67 | 68 | :global { 69 | .ant-menu-inline { 70 | border-right: none; 71 | } 72 | } 73 | } 74 | 75 | .switchTheme { 76 | width: 100%; 77 | height: 48px; 78 | display: flex; 79 | justify-content: space-between; 80 | align-items: center; 81 | padding: 0 16px; 82 | overflow: hidden; 83 | transition: all 0.3s; 84 | 85 | span { 86 | white-space: nowrap; 87 | overflow: hidden; 88 | font-size: 12px; 89 | } 90 | 91 | :global { 92 | .anticon { 93 | min-width: 14px; 94 | margin-right: 4px; 95 | font-size: 14px; 96 | } 97 | } 98 | } 99 | 100 | @keyframes fadeLeftIn { 101 | 0% { 102 | transform: translateX(5px); 103 | opacity: 0; 104 | } 105 | 106 | 100% { 107 | transform: translateX(0); 108 | opacity: 1; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /backstage/src/components/Layout/index.js: -------------------------------------------------------------------------------- 1 | import Header from './Header' 2 | import Menu from './Menu' 3 | import Bread from './Bread' 4 | import Sider from './Sider' 5 | 6 | export { Header, Menu, Bread, Sider } 7 | -------------------------------------------------------------------------------- /backstage/src/components/Loader/Loader.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import classNames from 'classnames' 4 | import styles from './Loader.less' 5 | 6 | const Loader = ({ spinning = true, fullScreen }) => { 7 | return ( 8 |
14 |
15 |
16 |
LOADING
17 |
18 |
19 | ) 20 | } 21 | 22 | Loader.propTypes = { 23 | spinning: PropTypes.bool, 24 | fullScreen: PropTypes.bool, 25 | } 26 | 27 | export default Loader 28 | -------------------------------------------------------------------------------- /backstage/src/components/Loader/Loader.less: -------------------------------------------------------------------------------- 1 | .loader { 2 | background-color: #fff; 3 | width: 100%; 4 | position: absolute; 5 | top: 0; 6 | bottom: 0; 7 | left: 0; 8 | z-index: 100000; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | opacity: 1; 13 | text-align: center; 14 | 15 | &.fullScreen { 16 | position: fixed; 17 | } 18 | 19 | .warpper { 20 | width: 100px; 21 | height: 100px; 22 | display: inline-flex; 23 | flex-direction: column; 24 | justify-content: space-around; 25 | } 26 | 27 | .inner { 28 | width: 40px; 29 | height: 40px; 30 | margin: 0 auto; 31 | text-indent: -12345px; 32 | border-top: 1px solid rgba(0, 0, 0, 0.08); 33 | border-right: 1px solid rgba(0, 0, 0, 0.08); 34 | border-bottom: 1px solid rgba(0, 0, 0, 0.08); 35 | border-left: 1px solid rgba(0, 0, 0, 0.7); 36 | border-radius: 50%; 37 | z-index: 100001; 38 | 39 | :local { 40 | animation: spinner 600ms infinite linear; 41 | } 42 | } 43 | 44 | .text { 45 | width: 100px; 46 | height: 20px; 47 | text-align: center; 48 | font-size: 12px; 49 | letter-spacing: 4px; 50 | color: #000; 51 | } 52 | 53 | &.hidden { 54 | z-index: -1; 55 | opacity: 0; 56 | transition: opacity 1s ease 0.5s, z-index 0.1s ease 1.5s; 57 | } 58 | } 59 | @keyframes spinner { 60 | 0% { 61 | transform: rotate(0deg); 62 | } 63 | 64 | 100% { 65 | transform: rotate(360deg); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /backstage/src/components/Loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Loader", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./Loader.js" 6 | } 7 | -------------------------------------------------------------------------------- /backstage/src/components/MediaLink/MediaLink.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { getMediaUrl } from 'utils/helpers' 4 | 5 | const MediaLink = ({ children }) => { 6 | return React.Children.map(children, child => { 7 | let linkAttrs = {}, 8 | childProps = child.props || {} 9 | 10 | if (childProps.href) { 11 | linkAttrs.href = getMediaUrl(childProps.href) 12 | } else if (childProps.src) { 13 | linkAttrs.src = getMediaUrl(childProps.src) 14 | } 15 | 16 | if (child.type === 'a') linkAttrs.target = '_blank' 17 | 18 | return React.cloneElement(child, linkAttrs) 19 | }) 20 | } 21 | 22 | MediaLink.propTypes = { 23 | children: PropTypes.node, 24 | } 25 | 26 | export default MediaLink 27 | -------------------------------------------------------------------------------- /backstage/src/components/MediaLink/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MediaLink", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./MediaLink.js" 6 | } 7 | -------------------------------------------------------------------------------- /backstage/src/components/Page/Page.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import classnames from 'classnames' 4 | import Loader from '../Loader' 5 | import styles from './Page.less' 6 | 7 | export default class Page extends Component { 8 | render() { 9 | const { className, children, loading = false, inner = false } = this.props 10 | const loadingStyle = { 11 | height: 'calc(100vh - 184px)', 12 | overflow: 'hidden', 13 | } 14 | return ( 15 |
21 | {loading ? : ''} 22 | {children} 23 |
24 | ) 25 | } 26 | } 27 | 28 | Page.propTypes = { 29 | className: PropTypes.string, 30 | children: PropTypes.node, 31 | loading: PropTypes.bool, 32 | inner: PropTypes.bool, 33 | } 34 | -------------------------------------------------------------------------------- /backstage/src/components/Page/Page.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars.less'; 2 | 3 | .contentInner { 4 | background: #fff; 5 | padding: 24px; 6 | box-shadow: @shadow-1; 7 | min-height: ~'calc(100vh - 230px)'; 8 | position: relative; 9 | } 10 | 11 | @media (max-width: 767px) { 12 | .contentInner { 13 | padding: 12px; 14 | min-height: ~'calc(100vh - 160px)'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backstage/src/components/Page/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Page", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./Page.js" 6 | } 7 | -------------------------------------------------------------------------------- /backstage/src/components/ScrollBar/index.js: -------------------------------------------------------------------------------- 1 | import ScrollBar from 'react-perfect-scrollbar' 2 | import 'react-perfect-scrollbar/dist/css/styles.css' 3 | import './index.less' 4 | 5 | export default ScrollBar 6 | -------------------------------------------------------------------------------- /backstage/src/components/ScrollBar/index.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .ps--active-x > .ps__rail-x, 3 | .ps--active-y > .ps__rail-y { 4 | background-color: transparent; 5 | } 6 | 7 | .ps__rail-x:hover > .ps__thumb-x, 8 | .ps__rail-x:focus > .ps__thumb-x { 9 | height: 8px; 10 | } 11 | 12 | .ps__rail-y:hover > .ps__thumb-y, 13 | .ps__rail-y:focus > .ps__thumb-y { 14 | width: 8px; 15 | } 16 | 17 | .ps__rail-y, 18 | .ps__rail-x { 19 | z-index: 9; 20 | } 21 | 22 | .ps__thumb-y { 23 | width: 4px; 24 | right: 4px; 25 | } 26 | 27 | .ps__thumb-x { 28 | height: 4px; 29 | bottom: 4px; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backstage/src/components/index.js: -------------------------------------------------------------------------------- 1 | import FilterItem from './FilterItem' 2 | import DropOption from './DropOption' 3 | import Loader from './Loader' 4 | import ScrollBar from './ScrollBar' 5 | import * as MyLayout from './Layout/index.js' 6 | import Page from './Page' 7 | import BraftEditor from './BraftEditor' 8 | import MediaLink from './MediaLink' 9 | 10 | export { 11 | MyLayout, 12 | FilterItem, 13 | DropOption, 14 | Loader, 15 | Page, 16 | ScrollBar, 17 | BraftEditor, 18 | MediaLink, 19 | } 20 | -------------------------------------------------------------------------------- /backstage/src/layouts/BaseLayout.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, Fragment } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'dva' 4 | import { Helmet } from 'react-helmet' 5 | import { Loader } from 'components' 6 | import { queryLayout } from 'utils' 7 | import NProgress from 'nprogress' 8 | import config from 'utils/config' 9 | import withRouter from 'umi/withRouter' 10 | 11 | import PublicLayout from './PublicLayout' 12 | import PrimaryLayout from './PrimaryLayout' 13 | import './BaseLayout.less' 14 | 15 | const LayoutMap = { 16 | primary: PrimaryLayout, 17 | public: PublicLayout, 18 | } 19 | 20 | @withRouter 21 | @connect(({ loading }) => ({ loading })) 22 | class BaseLayout extends PureComponent { 23 | previousPath = '' 24 | 25 | render() { 26 | const { loading, children, location } = this.props 27 | const Container = LayoutMap[queryLayout(config.layouts, location.pathname)] 28 | 29 | const currentPath = location.pathname + location.search 30 | if (currentPath !== this.previousPath) { 31 | NProgress.start() 32 | } 33 | 34 | if (!loading.global) { 35 | NProgress.done() 36 | this.previousPath = currentPath 37 | } 38 | 39 | return ( 40 | 41 | 42 | {config.siteName} 43 | 44 | 45 | {children} 46 | 47 | ) 48 | } 49 | } 50 | 51 | BaseLayout.propTypes = { 52 | loading: PropTypes.object, 53 | } 54 | 55 | export default BaseLayout 56 | -------------------------------------------------------------------------------- /backstage/src/layouts/BaseLayout.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars.less'; 2 | @import '~themes/index.less'; 3 | 4 | :global { 5 | #nprogress { 6 | pointer-events: none; 7 | 8 | .bar { 9 | background: @primary-color; 10 | position: fixed; 11 | z-index: 2048; 12 | top: 0; 13 | left: 0; 14 | right: 0; 15 | width: 100%; 16 | height: 2px; 17 | } 18 | 19 | .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px @primary-color, 0 0 5px @primary-color; 26 | opacity: 1; 27 | transform: rotate(3deg) translate(0, -4px); 28 | } 29 | 30 | .spinner { 31 | display: block; 32 | position: fixed; 33 | z-index: 1031; 34 | top: 15px; 35 | right: 15px; 36 | } 37 | 38 | .spinner-icon { 39 | width: 18px; 40 | height: 18px; 41 | box-sizing: border-box; 42 | border: solid 2px transparent; 43 | border-top-color: @primary-color; 44 | border-left-color: @primary-color; 45 | border-radius: 50%; 46 | 47 | :local { 48 | animation: nprogress-spinner 400ms linear infinite; 49 | } 50 | } 51 | } 52 | 53 | .nprogress-custom-parent { 54 | overflow: hidden; 55 | position: relative; 56 | 57 | #nprogress { 58 | .bar, 59 | .spinner { 60 | position: absolute; 61 | } 62 | } 63 | } 64 | } 65 | 66 | @keyframes nprogress-spinner { 67 | 0% { 68 | transform: rotate(0deg); 69 | } 70 | 71 | 100% { 72 | transform: rotate(360deg); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /backstage/src/layouts/PrimaryLayout.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars.less'; 2 | 3 | .backTop { 4 | right: 50px; 5 | 6 | :global { 7 | .ant-back-top-content { 8 | background: @primary-color; 9 | opacity: 0.3; 10 | transition: all 0.3s; 11 | box-shadow: 0 0 15px 1px rgba(69, 65, 78, 0.1); 12 | 13 | &:hover { 14 | opacity: 1; 15 | } 16 | } 17 | } 18 | } 19 | 20 | .content { 21 | padding: 24px; 22 | min-height: ~'calc(100% - 72px)'; 23 | // overflow-y: scroll; 24 | } 25 | 26 | .container { 27 | height: 100vh; 28 | flex: 1; 29 | width: ~'calc(100% - 256px)'; 30 | overflow-y: scroll; 31 | overflow-x: hidden; 32 | } 33 | 34 | .footer { 35 | background: #fff; 36 | margin-top: 0; 37 | margin-bottom: 0; 38 | padding-top: 24px; 39 | padding-bottom: 24px; 40 | min-height: 72px; 41 | } 42 | 43 | @media (max-width: 767px) { 44 | .content { 45 | padding: 12px; 46 | } 47 | 48 | .backTop { 49 | right: 20px; 50 | bottom: 20px; 51 | } 52 | 53 | .container { 54 | height: 100vh; 55 | flex: 1; 56 | width: 100%; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backstage/src/layouts/PublicLayout.js: -------------------------------------------------------------------------------- 1 | export default ({ children }) => { 2 | return children 3 | } 4 | -------------------------------------------------------------------------------- /backstage/src/layouts/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import withRouter from 'umi/withRouter' 3 | import { LocaleProvider } from 'antd' 4 | import { I18nProvider } from '@lingui/react' 5 | import { langFromPath, defaultLanguage } from 'utils' 6 | import zh_CN from 'antd/lib/locale-provider/zh_CN' 7 | import en_US from 'antd/lib/locale-provider/en_US' 8 | 9 | import BaseLayout from './BaseLayout' 10 | 11 | const languages = { 12 | zh: zh_CN, 13 | en: en_US, 14 | } 15 | 16 | @withRouter 17 | class Layout extends Component { 18 | state = { 19 | catalogs: {}, 20 | } 21 | 22 | language = defaultLanguage 23 | 24 | componentDidMount() { 25 | const language = langFromPath(this.props.location.pathname) 26 | this.language = language 27 | this.loadCatalog(language) 28 | } 29 | 30 | shouldComponentUpdate(nextProps, nextState) { 31 | const language = langFromPath(nextProps.location.pathname) 32 | const preLanguage = this.language 33 | const { catalogs } = nextState 34 | 35 | if (preLanguage !== language && !catalogs[language]) { 36 | this.loadCatalog(language) 37 | this.language = language 38 | return false 39 | } 40 | this.language = language 41 | 42 | return true 43 | } 44 | 45 | loadCatalog = async language => { 46 | const catalog = await import(/* webpackMode: "lazy", webpackChunkName: "i18n-[index]" */ 47 | `@lingui/loader!../locales/${language}/messages.json`) 48 | 49 | this.setState(state => ({ 50 | catalogs: { 51 | ...state.catalogs, 52 | [language]: catalog, 53 | }, 54 | })) 55 | } 56 | 57 | render() { 58 | const { location, children } = this.props 59 | const { catalogs } = this.state 60 | 61 | let language = langFromPath(location.pathname) 62 | // If the language pack is not loaded or is loading, use the default language 63 | if (!catalogs[language]) language = defaultLanguage 64 | 65 | return ( 66 | 67 | 68 | {children} 69 | 70 | 71 | ) 72 | } 73 | } 74 | 75 | export default Layout 76 | -------------------------------------------------------------------------------- /backstage/src/locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "/dashboard": "/dashboard", 3 | "Add Param": "Add Param", 4 | "Address": "Address", 5 | "Age": "Age", 6 | "Are you sure delete this record?": "Are you sure delete this record?", 7 | "Author": "Author", 8 | "Avatar": "Avatar", 9 | "Categories": "Categories", 10 | "Clear notifications": "Clear notifications", 11 | "Comments": "Comments", 12 | "Create": "Create", 13 | "Create User": "Create User", 14 | "CreateTime": "CreateTime", 15 | "Dark": "Dark", 16 | "Delete": "Delete", 17 | "Email": "Email", 18 | "Female": "Female", 19 | "Gender": "Gender", 20 | "Hi,": "Hi,", 21 | "Image": "Image", 22 | "Light": "Light", 23 | "Male": "Male", 24 | "Name": "Name", 25 | "NickName": "NickName", 26 | "Not Found": "Not Found", 27 | "Operation": "Operation", 28 | "Params": "Params", 29 | "Password": "Password", 30 | "New Password": "New Password", 31 | "Phone": "Phone", 32 | "Pick an address": "Pick an address", 33 | "Please pick an address": "Please pick an address", 34 | "Publised": "Publised", 35 | "Publish Date": "Publish Date", 36 | "Reset": "Reset", 37 | "Search": "Search", 38 | "Search Name": "Search Name", 39 | "Send": "Send", 40 | "Sign in": "Sign in", 41 | "Sign out": "Sign out", 42 | "Switch Theme": "Switch Theme", 43 | "Tags": "Tags", 44 | "The input is not valid E-mail!": "The input is not valid E-mail!", 45 | "The input is not valid phone!": "The input is not valid phone!", 46 | "Title": "Title", 47 | "Total {total} Items": "Total {total} Items", 48 | "Unpublished": "Unpublished", 49 | "Update": "Update", 50 | "Update User": "Update User", 51 | "Username": "Username", 52 | "Views": "Views", 53 | "Visibility": "Visibility", 54 | "You have viewed all notifications.": "You have viewed all notifications.", 55 | 56 | "Admin Group": "Admin Group", 57 | "Pls input the right Email": "Pls input the right Email", 58 | "Pls input the right Phone": "Pls input the right Phone", 59 | "Create Admin User": "Create Admin User", 60 | "Update Admin User": "Update Admin User", 61 | "Create Admin Group": "Create Admin Group", 62 | "Update Admin Group": "Update Admin Group", 63 | "Description": "Description", 64 | "Gradation": "Gradation", 65 | "Authority": "Authority", 66 | "Search NickName": "Search NickName", 67 | "Published": "Published", 68 | "Offline": "Offline", 69 | "Draft": "Draft", 70 | "Trash": "Trash", 71 | "UpdateTime": "UpdateTime", 72 | "Cover": "Cover", 73 | "Category": "Category", 74 | "Remove": "Remove", 75 | "Is Top": "Is Top", 76 | "Content": "Content", 77 | "Publish": "Publish", 78 | "Sort": "Sort", 79 | "Path": "Path", 80 | "Dir": "Dir", 81 | "Is Show": "Is Show", 82 | "Yes": "Yes", 83 | "No": "No", 84 | "Keywords": "Keywords", 85 | "Size": "Size", 86 | "Type": "Type", 87 | "Overview": "Overview" 88 | } -------------------------------------------------------------------------------- /backstage/src/locales/zh/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "/dashboard": "/zh/dashboard", 3 | "Add Param": "添加参数", 4 | "Address": "地址", 5 | "Age": "年龄", 6 | "Are you sure delete this record?": "您确定要删除这条记录吗?", 7 | "Author": "作者", 8 | "Avatar": "头像", 9 | "Categories": "类别", 10 | "Clear notifications": "清空消息", 11 | "Comments": "评论数", 12 | "Create": "创建", 13 | "Create User": "创建用户", 14 | "CreateTime": "创建时间", 15 | "Dark": "暗", 16 | "Delete": "删除", 17 | "Email": "电子邮件", 18 | "Female": "女", 19 | "Gender": "性别", 20 | "Hi,": "你好,", 21 | "Image": "图像", 22 | "Light": "明", 23 | "Male": "男性", 24 | "Name": "名字", 25 | "NickName": "昵称", 26 | "Not Found": "未找到", 27 | "Operation": "操作", 28 | "Params": "参数", 29 | "Password": "密码", 30 | "New Password": "新密码", 31 | "Phone": "电话", 32 | "Pick an address": "选择地址", 33 | "Please pick an address": "选择地址", 34 | "Publised": "已发布", 35 | "Publish Date": "发布日期", 36 | "Reset": "重置", 37 | "Search": "搜索", 38 | "Search Name": "搜索名字", 39 | "Send": "发送", 40 | "Sign in": "登录", 41 | "Sign out": "退出登录", 42 | "Switch Theme": "切换主题", 43 | "Tags": "标签", 44 | "The input is not valid E-mail!": "输入的电子邮件无效!", 45 | "The input is not valid phone!": "输入无效的手机!", 46 | "Title": "标题", 47 | "Total {total} Items": "总共 {total} 条记录", 48 | "Unpublished": "未发布", 49 | "Update": "更新", 50 | "Update User": "更新用户", 51 | "Username": "用户名", 52 | "Views": "浏览数", 53 | "Visibility": "可见性", 54 | "You have viewed all notifications.": "您已查看所有通知", 55 | 56 | "Admin Group": "管理组", 57 | "Pls input the right Email": "请输入正确的邮箱地址", 58 | "Pls input the right Phone": "请输入正确的手机号码", 59 | "Create Admin User": "创建管理员", 60 | "Update Admin User": "更新管理员", 61 | "Create Admin Group": "创建管理组", 62 | "Update Admin Group": "更新管理组", 63 | "Description": "描述", 64 | "Gradation": "级别", 65 | "Authority": "权限", 66 | "Search NickName": "搜索昵称", 67 | "Published": "已发布", 68 | "Offline": "已下线", 69 | "Draft": "草稿", 70 | "Trash": "回收站", 71 | "UpdateTime": "更新时间", 72 | "Cover": "封面", 73 | "Category": "分类", 74 | "Remove": "删除", 75 | "Is Top": "是否置顶", 76 | "Content": "内容", 77 | "Publish": " 发布", 78 | "Sort": "排序", 79 | "Path": "路径", 80 | "Dir": "目录", 81 | "Is Show": "是否展示", 82 | "Yes": "是", 83 | "No": "否", 84 | "Keywords": "关键词", 85 | "Size": "大小", 86 | "Type": "类型", 87 | "Overview": "概览" 88 | } -------------------------------------------------------------------------------- /backstage/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Icon } from 'antd' 3 | import { Page } from 'components' 4 | import styles from './404.less' 5 | 6 | const Error = () => ( 7 | 8 |
9 | 10 |

404 Not Found

11 |
12 |
13 | ) 14 | 15 | export default Error 16 | -------------------------------------------------------------------------------- /backstage/src/pages/404.less: -------------------------------------------------------------------------------- 1 | .error { 2 | color: black; 3 | text-align: center; 4 | position: absolute; 5 | top: 30%; 6 | margin-top: -50px; 7 | left: 50%; 8 | margin-left: -100px; 9 | width: 200px; 10 | 11 | :global .anticon { 12 | font-size: 48px; 13 | margin-bottom: 16px; 14 | } 15 | 16 | h1 { 17 | font-family: cursive; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backstage/src/pages/adminGroup/components/List.less: -------------------------------------------------------------------------------- 1 | .table { 2 | :global { 3 | .ant-table td { 4 | white-space: nowrap; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backstage/src/pages/adminUser/components/List.less: -------------------------------------------------------------------------------- 1 | .table { 2 | :global { 3 | .ant-table td { 4 | white-space: nowrap; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backstage/src/pages/article/$id/model.js: -------------------------------------------------------------------------------- 1 | import { message } from 'antd' 2 | import modelExtend from 'dva-model-extend' 3 | import { routerRedux } from 'dva/router' 4 | import { pathMatchRegexp } from 'utils' 5 | import { model } from 'utils/model' 6 | import { delay } from 'utils' 7 | import { 8 | queryArticle, 9 | updateArticle, 10 | queryCategoryList, 11 | } from 'api' 12 | 13 | export default modelExtend(model, { 14 | namespace: 'articleDetail', 15 | 16 | state: { 17 | detail: {}, 18 | categoryList: [], 19 | }, 20 | 21 | subscriptions: { 22 | setup({ dispatch, history }) { 23 | history.listen(({ pathname }) => { 24 | const match = pathMatchRegexp('/article/:_id', pathname) 25 | if (match) { 26 | dispatch({ type: 'queryCategoryList' }) 27 | dispatch({ type: 'query', payload: { _id: match[1] } }) 28 | } else { 29 | // clear state cache 30 | dispatch({ type: 'updateState', payload: { detail: {}, categoryList: [] } }) 31 | } 32 | }) 33 | }, 34 | }, 35 | 36 | effects: { 37 | *query({ payload }, { call, put }) { 38 | const data = yield call(queryArticle, payload) 39 | if (data.code === '0000') { 40 | yield put({ 41 | type: 'updateState', 42 | payload: { 43 | detail: data.result, 44 | }, 45 | }) 46 | } else { 47 | throw data 48 | } 49 | }, 50 | 51 | *queryCategoryList({ payload }, { call, put }) { 52 | const data = yield call(queryCategoryList, payload) 53 | if (data.code === '0000') { 54 | yield put({ 55 | type: 'updateState', 56 | payload: { 57 | categoryList: data.result, 58 | }, 59 | }) 60 | } else { 61 | throw data 62 | } 63 | }, 64 | 65 | *update({ payload }, { call, put }) { 66 | const data = yield call(updateArticle, payload) 67 | if (data.code === '0000') { 68 | message.success('编辑完成') 69 | yield call(delay, 1000) 70 | yield put(routerRedux.goBack()) 71 | } else { 72 | throw data 73 | } 74 | }, 75 | 76 | }, 77 | }) 78 | -------------------------------------------------------------------------------- /backstage/src/pages/article/components/List.less: -------------------------------------------------------------------------------- 1 | .table { 2 | :global { 3 | .ant-table td { 4 | white-space: nowrap; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backstage/src/pages/article/model.js: -------------------------------------------------------------------------------- 1 | import modelExtend from 'dva-model-extend' 2 | import { router, pathMatchRegexp } from 'utils' 3 | import { 4 | queryArticleList, 5 | createArticle, 6 | removeArticle, 7 | removeArticleList, 8 | } from 'api' 9 | import { pageModel } from 'utils/model' 10 | 11 | export default modelExtend(pageModel, { 12 | namespace: 'article', 13 | 14 | state: { 15 | selectedRowKeys: [], 16 | }, 17 | 18 | subscriptions: { 19 | setup({ dispatch, history }) { 20 | history.listen(location => { 21 | if (pathMatchRegexp('/article', location.pathname)) { 22 | dispatch({ 23 | type: 'query', 24 | payload: { 25 | status: 1, 26 | ...location.query, 27 | }, 28 | }) 29 | } 30 | }) 31 | }, 32 | }, 33 | 34 | effects: { 35 | *query({ payload }, { call, put }) { 36 | const data = yield call(queryArticleList, payload) 37 | if (data.code === '0000') { 38 | yield put({ 39 | type: 'querySuccess', 40 | payload: { 41 | list: data.result.list, 42 | pagination: { 43 | current: Number(payload.page) || 1, 44 | pageSize: Number(payload.pageSize) || 10, 45 | total: data.result.total, 46 | }, 47 | }, 48 | }) 49 | } else { 50 | throw data 51 | } 52 | }, 53 | 54 | *delete({ payload }, { call, put, select }) { 55 | const data = yield call(removeArticle, { _id: payload }) 56 | const { selectedRowKeys } = yield select(_ => _.article) 57 | if (data.code === '0000') { 58 | yield put({ 59 | type: 'updateState', 60 | payload: { 61 | selectedRowKeys: selectedRowKeys.filter(_ => _ !== payload), 62 | }, 63 | }) 64 | } else { 65 | throw data 66 | } 67 | }, 68 | 69 | *multiDelete({ payload }, { call, put }) { 70 | const data = yield call(removeArticleList, payload) 71 | if (data.code === '0000') { 72 | yield put({ type: 'updateState', payload: { selectedRowKeys: [] } }) 73 | } else { 74 | throw data 75 | } 76 | }, 77 | 78 | *create({ payload }, { call, put }) { 79 | const data = yield call(createArticle, payload) 80 | if (data.code === '0000') { 81 | router.push({ 82 | pathname: `/article/${data.result._id}`, 83 | }) 84 | } else { 85 | throw data 86 | } 87 | }, 88 | }, 89 | }) 90 | -------------------------------------------------------------------------------- /backstage/src/pages/category/components/List.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Table, Modal } from 'antd' 4 | import { DropOption } from 'components' 5 | import { Trans, withI18n } from '@lingui/react' 6 | import styles from './List.less' 7 | 8 | const { confirm } = Modal 9 | 10 | @withI18n() 11 | class List extends PureComponent { 12 | handleMenuClick = (record, e) => { 13 | const { onDeleteItem, onEditItem, i18n } = this.props 14 | 15 | if (e.key === '1') { 16 | onEditItem(record) 17 | } else if (e.key === '2') { 18 | confirm({ 19 | title: i18n.t`Are you sure delete this record?`, 20 | onOk() { 21 | onDeleteItem(record._id) 22 | }, 23 | }) 24 | } 25 | } 26 | 27 | render() { 28 | const { onDeleteItem, onEditItem, i18n, ...tableProps } = this.props 29 | 30 | const columns = [ 31 | { 32 | title: Sort, 33 | dataIndex: 'sort', 34 | key: 'sort', 35 | }, 36 | { 37 | title: Name, 38 | dataIndex: 'name', 39 | key: 'name', 40 | }, 41 | { 42 | title: Dir, 43 | dataIndex: 'path', 44 | key: 'path', 45 | }, 46 | { 47 | title: Is Show, 48 | dataIndex: 'state', 49 | key: 'state', 50 | render: text => (text ? Yes : No), 51 | }, 52 | { 53 | title: Operation, 54 | key: 'operation', 55 | render: (text, record) => { 56 | return ( 57 | this.handleMenuClick(record, e)} 59 | menuOptions={[ 60 | { key: '1', name: i18n.t`Update` }, 61 | { key: '2', name: i18n.t`Delete` }, 62 | ]} 63 | /> 64 | ) 65 | }, 66 | }, 67 | ] 68 | 69 | return ( 70 | record._id} 77 | /> 78 | ) 79 | } 80 | } 81 | 82 | List.propTypes = { 83 | onDeleteItem: PropTypes.func, 84 | onEditItem: PropTypes.func, 85 | location: PropTypes.object, 86 | } 87 | 88 | export default List 89 | -------------------------------------------------------------------------------- /backstage/src/pages/category/components/List.less: -------------------------------------------------------------------------------- 1 | .table { 2 | :global { 3 | .ant-table td { 4 | white-space: nowrap; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/browser.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Table, Tag } from 'antd' 4 | import { Color } from 'utils' 5 | import styles from './browser.less' 6 | 7 | const status = { 8 | 1: { 9 | color: Color.green, 10 | }, 11 | 2: { 12 | color: Color.red, 13 | }, 14 | 3: { 15 | color: Color.blue, 16 | }, 17 | 4: { 18 | color: Color.yellow, 19 | }, 20 | } 21 | 22 | function Browser({ data }) { 23 | const columns = [ 24 | { 25 | title: 'name', 26 | dataIndex: 'name', 27 | className: styles.name, 28 | }, 29 | { 30 | title: 'percent', 31 | dataIndex: 'percent', 32 | className: styles.percent, 33 | render: (text, it) => {text}%, 34 | }, 35 | ] 36 | return ( 37 |
key} 42 | dataSource={data} 43 | /> 44 | ) 45 | } 46 | 47 | Browser.propTypes = { 48 | data: PropTypes.array, 49 | } 50 | 51 | export default Browser 52 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/browser.less: -------------------------------------------------------------------------------- 1 | .percent { 2 | text-align: right !important; 3 | } 4 | 5 | .name { 6 | text-align: left !important; 7 | } 8 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/comments.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Table, Tag } from 'antd' 4 | import { Color } from 'utils' 5 | import styles from './comments.less' 6 | 7 | const status = { 8 | 1: { 9 | color: Color.green, 10 | text: 'APPROVED', 11 | }, 12 | 2: { 13 | color: Color.yellow, 14 | text: 'PENDING', 15 | }, 16 | 3: { 17 | color: Color.red, 18 | text: 'REJECTED', 19 | }, 20 | } 21 | 22 | function Comments({ data }) { 23 | const columns = [ 24 | { 25 | title: 'avatar', 26 | dataIndex: 'avatar', 27 | width: 48, 28 | className: styles.avatarcolumn, 29 | render: text => ( 30 | 34 | ), 35 | }, 36 | { 37 | title: 'content', 38 | dataIndex: 'content', 39 | render: (text, it) => ( 40 |
41 |
{it.name}
42 |

{it.content}

43 |
44 | {status[it.status].text} 45 | {it.date} 46 |
47 |
48 | ), 49 | }, 50 | ] 51 | return ( 52 |
53 |
key} 58 | dataSource={data.filter((item, key) => key < 3)} 59 | /> 60 | 61 | ) 62 | } 63 | 64 | Comments.propTypes = { 65 | data: PropTypes.array, 66 | } 67 | 68 | export default Comments 69 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/comments.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .comments { 4 | :global .ant-table-thead > tr > th { 5 | background: #fff; 6 | border-bottom: solid 1px @border-color-base; 7 | } 8 | 9 | .avatar { 10 | width: 48px; 11 | height: 48px; 12 | background-position: center; 13 | background-size: cover; 14 | border-radius: 50%; 15 | background: #f8f8f8; 16 | display: inline-block; 17 | } 18 | 19 | .content { 20 | text-align: left; 21 | color: #757575; 22 | } 23 | 24 | .date { 25 | color: #a3a3a3; 26 | line-height: 30px; 27 | } 28 | 29 | .daterow { 30 | display: flex; 31 | justify-content: space-between; 32 | } 33 | 34 | .name { 35 | font-size: 14px; 36 | color: #474747; 37 | text-align: left; 38 | } 39 | 40 | .avatarcolumn { 41 | vertical-align: top; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/completed.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .sales { 4 | .title { 5 | margin-left: 32px; 6 | font-size: 16px; 7 | } 8 | } 9 | 10 | .radiusdot { 11 | width: 12px; 12 | height: 12px; 13 | margin-right: 8px; 14 | border-radius: 50%; 15 | display: inline-block; 16 | } 17 | 18 | .legend { 19 | text-align: right; 20 | color: #999; 21 | font-size: 14px; 22 | 23 | li { 24 | height: 48px; 25 | line-height: 48px; 26 | display: inline-block; 27 | 28 | & + li { 29 | margin-left: 24px; 30 | } 31 | } 32 | } 33 | 34 | .tooltip { 35 | background: #fff; 36 | padding: 20px; 37 | font-size: 14px; 38 | 39 | .tiptitle { 40 | font-weight: 700; 41 | font-size: 16px; 42 | margin-bottom: 8px; 43 | } 44 | 45 | .tipitem { 46 | height: 32px; 47 | line-height: 32px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/cpu.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Color } from 'utils' 4 | import CountUp from 'react-countup' 5 | import { 6 | LineChart, 7 | Line, 8 | XAxis, 9 | YAxis, 10 | CartesianGrid, 11 | ResponsiveContainer, 12 | } from 'recharts' 13 | import styles from './cpu.less' 14 | 15 | const countUpProps = { 16 | start: 0, 17 | duration: 2.75, 18 | useEasing: true, 19 | useGrouping: true, 20 | separator: ',', 21 | } 22 | 23 | function Cpu({ usage = 0, space = 0, cpu = 0, data }) { 24 | return ( 25 |
26 |
27 |
28 |

usage

29 |

30 | 31 |

32 |
33 |
34 |

space

35 |

36 | 37 |

38 |
39 |
40 |

cpu

41 |

42 | 43 |

44 |
45 |
46 | 47 | 48 | 53 | 54 | 59 | 66 | 67 | 68 |
69 | ) 70 | } 71 | 72 | Cpu.propTypes = { 73 | data: PropTypes.array, 74 | usage: PropTypes.number, 75 | space: PropTypes.number, 76 | cpu: PropTypes.number, 77 | } 78 | 79 | export default Cpu 80 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/cpu.less: -------------------------------------------------------------------------------- 1 | .cpu { 2 | .number { 3 | display: flex; 4 | height: 64px; 5 | justify-content: space-between; 6 | margin-bottom: 32px; 7 | 8 | .item { 9 | text-align: center; 10 | height: 64px; 11 | width: 100%; 12 | position: relative; 13 | 14 | & + .item { 15 | &::before { 16 | content: ''; 17 | display: block; 18 | width: 1px; 19 | height: 40px; 20 | position: absolute; 21 | background: #f5f5f5; 22 | top: 12px; 23 | } 24 | } 25 | 26 | p { 27 | color: #757575; 28 | 29 | &:first-child { 30 | font-size: 16px; 31 | } 32 | 33 | &:last-child { 34 | font-size: 20px; 35 | font-weight: 700; 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/index.js: -------------------------------------------------------------------------------- 1 | import NumberCard from './numberCard' 2 | import Quote from './quote' 3 | import Sales from './sales' 4 | import Weather from './weather' 5 | import RecentSales from './recentSales' 6 | import Comments from './comments' 7 | import Completed from './completed' 8 | import Browser from './browser' 9 | import Cpu from './cpu' 10 | import User from './user' 11 | 12 | export { 13 | NumberCard, 14 | Quote, 15 | Sales, 16 | Weather, 17 | RecentSales, 18 | Comments, 19 | Completed, 20 | Browser, 21 | Cpu, 22 | User, 23 | } 24 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/numberCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Icon, Card } from 'antd' 4 | import CountUp from 'react-countup' 5 | import styles from './numberCard.less' 6 | 7 | function NumberCard({ icon, color, title, number, countUp }) { 8 | return ( 9 | 14 | 15 |
16 |

{title || 'No Title'}

17 |

18 | 27 |

28 |
29 |
30 | ) 31 | } 32 | 33 | NumberCard.propTypes = { 34 | icon: PropTypes.string, 35 | color: PropTypes.string, 36 | title: PropTypes.string, 37 | number: PropTypes.number, 38 | countUp: PropTypes.object, 39 | } 40 | 41 | export default NumberCard 42 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/numberCard.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .numberCard { 4 | padding: 32px; 5 | margin-bottom: 24px; 6 | cursor: pointer; 7 | 8 | .iconWarp { 9 | font-size: 54px; 10 | float: left; 11 | } 12 | 13 | .content { 14 | width: 100%; 15 | padding-left: 78px; 16 | 17 | .title { 18 | line-height: 16px; 19 | font-size: 16px; 20 | margin-bottom: 8px; 21 | height: 16px; 22 | .text-overflow(); 23 | } 24 | 25 | .number { 26 | line-height: 32px; 27 | font-size: 24px; 28 | height: 32px; 29 | .text-overflow(); 30 | margin-bottom: 0; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/quote.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styles from './quote.less' 4 | 5 | function Quote({ name, content, title, avatar }) { 6 | return ( 7 |
8 |
{content}
9 |
10 |
11 |

-{name}-

12 |

{title}

13 |
14 |
18 |
19 |
20 | ) 21 | } 22 | 23 | Quote.propTypes = { 24 | name: PropTypes.string, 25 | content: PropTypes.string, 26 | title: PropTypes.string, 27 | avatar: PropTypes.string, 28 | } 29 | 30 | export default Quote 31 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/quote.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .quote { 4 | color: #fff; 5 | height: 100%; 6 | width: 100%; 7 | padding: 24px; 8 | font-size: 16px; 9 | font-weight: 700; 10 | 11 | .inner { 12 | text-overflow: ellipsis; 13 | word-wrap: normal; 14 | display: -webkit-box; 15 | -webkit-box-orient: vertical; 16 | -webkit-line-clamp: 4; 17 | overflow: hidden; 18 | text-indent: 24px; 19 | } 20 | 21 | .footer { 22 | position: relative; 23 | margin-top: 14px; 24 | 25 | .description { 26 | width: 100%; 27 | 28 | p { 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | white-space: nowrap; 32 | margin-right: 64px; 33 | text-align: right; 34 | 35 | &:last-child { 36 | font-weight: 100; 37 | } 38 | } 39 | } 40 | 41 | .avatar { 42 | width: 48px; 43 | height: 48px; 44 | background-position: center; 45 | background-size: cover; 46 | border-radius: 50%; 47 | position: absolute; 48 | right: 0; 49 | top: 0; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/recentSales.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import moment from 'moment' 3 | import PropTypes from 'prop-types' 4 | import { Table, Tag } from 'antd' 5 | import { Color } from 'utils' 6 | import styles from './recentSales.less' 7 | 8 | const status = { 9 | 1: { 10 | color: Color.green, 11 | text: 'SALE', 12 | }, 13 | 2: { 14 | color: Color.yellow, 15 | text: 'REJECT', 16 | }, 17 | 3: { 18 | color: Color.red, 19 | text: 'TAX', 20 | }, 21 | 4: { 22 | color: Color.blue, 23 | text: 'EXTENDED', 24 | }, 25 | } 26 | 27 | function RecentSales({ data }) { 28 | const columns = [ 29 | { 30 | title: 'NAME', 31 | dataIndex: 'name', 32 | }, 33 | { 34 | title: 'STATUS', 35 | dataIndex: 'status', 36 | render: text => {status[text].text}, 37 | }, 38 | { 39 | title: 'DATE', 40 | dataIndex: 'date', 41 | render: text => moment(text).format('YYYY-MM-DD'), 42 | }, 43 | { 44 | title: 'PRICE', 45 | dataIndex: 'price', 46 | render: (text, it) => ( 47 | ${text} 48 | ), 49 | }, 50 | ] 51 | return ( 52 |
53 |
key} 57 | dataSource={data.filter((item, key) => key < 5)} 58 | /> 59 | 60 | ) 61 | } 62 | 63 | RecentSales.propTypes = { 64 | data: PropTypes.array, 65 | } 66 | 67 | export default RecentSales 68 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/recentSales.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .recentsales { 4 | :global .ant-table-thead > tr > th { 5 | background: #fff; 6 | border-bottom: solid 1px @border-color-base; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/sales.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .sales { 4 | overflow: hidden; 5 | .title { 6 | margin-left: 32px; 7 | font-size: 16px; 8 | } 9 | } 10 | 11 | .radiusdot { 12 | width: 12px; 13 | height: 12px; 14 | margin-right: 8px; 15 | border-radius: 50%; 16 | display: inline-block; 17 | } 18 | 19 | .legend { 20 | text-align: right; 21 | color: #999; 22 | font-size: 14px; 23 | 24 | li { 25 | height: 48px; 26 | line-height: 48px; 27 | display: inline-block; 28 | 29 | & + li { 30 | margin-left: 24px; 31 | } 32 | } 33 | } 34 | 35 | .tooltip { 36 | background: #fff; 37 | padding: 20px; 38 | font-size: 14px; 39 | 40 | .tiptitle { 41 | font-weight: 700; 42 | font-size: 16px; 43 | margin-bottom: 8px; 44 | } 45 | 46 | .tipitem { 47 | height: 32px; 48 | line-height: 32px; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/user-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/backstage/src/pages/dashboard/components/user-background.png -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/user.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Button, Avatar } from 'antd' 4 | import CountUp from 'react-countup' 5 | import { Color } from 'utils' 6 | import styles from './user.less' 7 | 8 | const countUpProps = { 9 | start: 0, 10 | duration: 2.75, 11 | useEasing: true, 12 | useGrouping: true, 13 | separator: ',', 14 | } 15 | 16 | function User({ avatar, username, sales = 0, sold = 0 }) { 17 | return ( 18 |
19 |
20 |
21 | 22 |
{username}
23 |
24 |
25 |
26 |
27 |

EARNING SALES

28 |

29 | 30 |

31 |
32 |
33 |

ITEM SOLD

34 |

35 | 36 |

37 |
38 |
39 |
40 | 43 |
44 |
45 | ) 46 | } 47 | 48 | User.propTypes = { 49 | avatar: PropTypes.string, 50 | username: PropTypes.string, 51 | sales: PropTypes.number, 52 | sold: PropTypes.number, 53 | } 54 | 55 | export default User 56 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/user.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .user { 4 | .header { 5 | display: flex; 6 | justify-content: center; 7 | text-align: center; 8 | color: #fff; 9 | height: 200px; 10 | background-size: cover; 11 | align-items: center; 12 | 13 | .headerinner { 14 | z-index: 2; 15 | } 16 | 17 | &::after { 18 | content: ''; 19 | background-image: url('./user-background.png'); 20 | background-size: cover; 21 | position: absolute; 22 | width: 100%; 23 | height: 200px; 24 | left: 0; 25 | top: 0; 26 | opacity: 0.4; 27 | z-index: 1; 28 | } 29 | 30 | .name { 31 | font-size: 16px; 32 | margin-top: 8px; 33 | } 34 | } 35 | 36 | .number { 37 | display: flex; 38 | height: 116px; 39 | justify-content: space-between; 40 | border-bottom: solid 1px #f5f5f5; 41 | 42 | .item { 43 | text-align: center; 44 | height: 116px; 45 | width: 100%; 46 | position: relative; 47 | padding: 30px 0; 48 | 49 | & + .item { 50 | &::before { 51 | content: ''; 52 | display: block; 53 | width: 1px; 54 | height: 116px; 55 | position: absolute; 56 | background: #f5f5f5; 57 | top: 0; 58 | } 59 | } 60 | 61 | p { 62 | color: #757575; 63 | 64 | &:first-child { 65 | font-size: 16px; 66 | } 67 | 68 | &:last-child { 69 | font-size: 20px; 70 | font-weight: 700; 71 | } 72 | } 73 | } 74 | } 75 | 76 | .footer { 77 | height: 116px; 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | 82 | :global .ant-btn { 83 | color: @purple; 84 | border-color: @purple; 85 | padding: 6px 16px; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/weather.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Spin } from 'antd' 4 | import styles from './weather.less' 5 | 6 | function Weather({ city, icon, dateTime, temperature, name, loading }) { 7 | return ( 8 | 9 |
10 |
11 |
17 |

{name}

18 |
19 |
20 |

{`${temperature}°`}

21 |

22 | {city},{dateTime} 23 |

24 |
25 |
26 | 27 | ) 28 | } 29 | 30 | Weather.propTypes = { 31 | city: PropTypes.string, 32 | icon: PropTypes.string, 33 | dateTime: PropTypes.string, 34 | temperature: PropTypes.string, 35 | name: PropTypes.string, 36 | loading: PropTypes.bool, 37 | } 38 | 39 | export default Weather 40 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/components/weather.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .weather { 4 | color: #fff; 5 | height: 204px; 6 | padding: 24px; 7 | justify-content: space-between; 8 | display: flex; 9 | font-size: 14px; 10 | 11 | .left { 12 | display: flex; 13 | flex-direction: column; 14 | width: 64px; 15 | padding-top: 55px; 16 | 17 | .icon { 18 | width: 64px; 19 | height: 64px; 20 | background-position: center; 21 | background-size: contain; 22 | } 23 | 24 | p { 25 | margin-top: 16px; 26 | } 27 | } 28 | 29 | .right { 30 | display: flex; 31 | flex-direction: column; 32 | width: 50%; 33 | 34 | .temperature { 35 | font-size: 36px; 36 | text-align: right; 37 | height: 64px; 38 | color: #fff; 39 | } 40 | 41 | .description { 42 | overflow: hidden; 43 | text-overflow: ellipsis; 44 | white-space: nowrap; 45 | text-align: right; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/index.less: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | position: relative; 3 | :global { 4 | .ant-card { 5 | border-radius: 0; 6 | margin-bottom: 24px; 7 | &:hover { 8 | box-shadow: 4px 4px 40px rgba(0, 0, 0, 0.05); 9 | } 10 | } 11 | .ant-card-body { 12 | overflow-x: hidden; 13 | } 14 | } 15 | 16 | .weather { 17 | &:hover { 18 | box-shadow: 4px 4px 40px rgba(143, 201, 251, 0.6); 19 | } 20 | } 21 | 22 | .quote { 23 | &:hover { 24 | box-shadow: 4px 4px 40px rgba(246, 152, 153, 0.6); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/model.js: -------------------------------------------------------------------------------- 1 | import { parse } from 'qs' 2 | import modelExtend from 'dva-model-extend' 3 | import { queryDashboard, queryWeather } from 'api' 4 | import { pathMatchRegexp } from 'utils' 5 | import { model } from 'utils/model' 6 | 7 | export default modelExtend(model, { 8 | namespace: 'dashboard', 9 | state: { 10 | weather: { 11 | city: '深圳', 12 | temperature: '30', 13 | name: '晴', 14 | icon: '//s5.sencdn.com/web/icons/3d_50/2.png', 15 | }, 16 | sales: [], 17 | quote: { 18 | avatar: 19 | 'http://img.hb.aicdn.com/bc442cf0cc6f7940dcc567e465048d1a8d634493198c4-sPx5BR_fw236', 20 | }, 21 | numbers: [], 22 | recentSales: [], 23 | comments: [], 24 | completed: [], 25 | browser: [], 26 | cpu: {}, 27 | user: { 28 | avatar: 29 | 'http://img.hb.aicdn.com/bc442cf0cc6f7940dcc567e465048d1a8d634493198c4-sPx5BR_fw236', 30 | }, 31 | }, 32 | subscriptions: { 33 | setup({ dispatch, history }) { 34 | history.listen(({ pathname }) => { 35 | if ( 36 | pathMatchRegexp('/dashboard', pathname) || 37 | pathMatchRegexp('/', pathname) 38 | ) { 39 | dispatch({ type: 'query' }) 40 | dispatch({ type: 'queryWeather' }) 41 | } 42 | }) 43 | }, 44 | }, 45 | effects: { 46 | *query({ payload }, { call, put }) { 47 | const data = yield call(queryDashboard, parse(payload)) 48 | if (data.code === '0000') { 49 | yield put({ 50 | type: 'updateState', 51 | payload: data.result, 52 | }) 53 | } else { 54 | throw data 55 | } 56 | }, 57 | *queryWeather({ payload = {} }, { call, put }) { 58 | payload.location = 'shenzhen' 59 | const result = yield call(queryWeather, payload) 60 | const { success } = result 61 | if (success) { 62 | const data = result.results[0] 63 | const weather = { 64 | city: data.location.name, 65 | temperature: data.now.temperature, 66 | name: data.now.text, 67 | icon: `//s5.sencdn.com/web/icons/3d_50/${data.now.code}.png`, 68 | } 69 | yield put({ 70 | type: 'updateState', 71 | payload: { 72 | weather, 73 | }, 74 | }) 75 | } 76 | }, 77 | }, 78 | }) 79 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/services/dashboard.js: -------------------------------------------------------------------------------- 1 | import { request, config } from 'utils' 2 | 3 | const { api } = config 4 | const { dashboard } = api 5 | 6 | export function query(params) { 7 | return request({ 8 | url: dashboard, 9 | method: 'get', 10 | data: params, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /backstage/src/pages/dashboard/services/weather.js: -------------------------------------------------------------------------------- 1 | import { request, config } from 'utils' 2 | 3 | const { APIV1 } = config 4 | 5 | export function query(params) { 6 | params.key = 'i7sau1babuzwhycn' 7 | return request({ 8 | url: `${APIV1}/weather/now.json`, 9 | method: 'get', 10 | data: params, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /backstage/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import Redirect from 'umi/redirect' 3 | import { withI18n } from '@lingui/react' 4 | 5 | @withI18n() 6 | class Index extends PureComponent { 7 | render() { 8 | const { i18n } = this.props 9 | return 10 | } 11 | } 12 | 13 | export default Index 14 | -------------------------------------------------------------------------------- /backstage/src/pages/login/index.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .form { 4 | position: absolute; 5 | top: 45%; 6 | left: 50%; 7 | margin: -160px 0 0 -160px; 8 | width: 320px; 9 | height: 350px; 10 | padding: 36px; 11 | box-shadow: 0 0 100px rgba(0, 0, 0, 0.08); 12 | 13 | button { 14 | width: 100%; 15 | } 16 | 17 | p { 18 | color: rgb(204, 204, 204); 19 | text-align: center; 20 | margin-top: 16px; 21 | font-size: 12px; 22 | display: flex; 23 | justify-content: space-between; 24 | } 25 | } 26 | 27 | .logo { 28 | text-align: center; 29 | cursor: pointer; 30 | margin-bottom: 24px; 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | 35 | img { 36 | width: 40px; 37 | margin-right: 8px; 38 | } 39 | 40 | span { 41 | vertical-align: text-bottom; 42 | font-size: 16px; 43 | text-transform: uppercase; 44 | display: inline-block; 45 | font-weight: 700; 46 | color: @primary-color; 47 | .text-gradient(); 48 | } 49 | } 50 | 51 | .ant-spin-container, 52 | .ant-spin-nested-loading { 53 | height: 100%; 54 | } 55 | 56 | .footer { 57 | position: absolute; 58 | width: 100%; 59 | bottom: 0; 60 | } 61 | -------------------------------------------------------------------------------- /backstage/src/pages/login/model.js: -------------------------------------------------------------------------------- 1 | import { router, pathMatchRegexp, auth } from 'utils' 2 | import { loginUser } from 'api' 3 | 4 | export default { 5 | namespace: 'login', 6 | 7 | state: {}, 8 | 9 | effects: { 10 | *login({ payload }, { put, call, select }) { 11 | // handle account type 12 | if (/^\d{11}$/.test(payload.account)) { 13 | payload.mobile = payload.account 14 | } else { 15 | payload.email = payload.account 16 | } 17 | delete payload.account 18 | 19 | const data = yield call(loginUser, payload) 20 | const { locationQuery } = yield select(_ => _.app) 21 | if (data.code === '0000') { 22 | auth.set(data.result) 23 | 24 | const { from } = locationQuery 25 | yield put({ type: 'app/query' }) 26 | if (!pathMatchRegexp('/login', from)) { 27 | if (from === '/') router.push('/dashboard') 28 | else router.push(from) 29 | } else { 30 | router.push('/dashboard') 31 | } 32 | } else { 33 | throw data 34 | } 35 | }, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /backstage/src/pages/media/components/List.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Table, Modal } from 'antd' 4 | import { DropOption, MediaLink } from 'components' 5 | import { Trans, withI18n } from '@lingui/react' 6 | import styles from './List.less' 7 | 8 | const { confirm } = Modal 9 | 10 | @withI18n() 11 | class List extends PureComponent { 12 | handleMenuClick = (record, e) => { 13 | const { onDeleteItem, onEditItem, i18n } = this.props 14 | 15 | if (e.key === '1') { 16 | onEditItem(record) 17 | } else if (e.key === '2') { 18 | confirm({ 19 | title: i18n.t`Are you sure delete this record?`, 20 | onOk() { 21 | onDeleteItem(record._id) 22 | }, 23 | }) 24 | } 25 | } 26 | 27 | render() { 28 | const { onDeleteItem, onEditItem, i18n, ...tableProps } = this.props 29 | 30 | const columns = [ 31 | { 32 | title: Path, 33 | dataIndex: 'path', 34 | key: 'path', 35 | render: text => ( 36 | 37 | {text} 38 | 39 | ), 40 | }, 41 | { 42 | title: Size, 43 | dataIndex: 'size', 44 | key: 'size', 45 | }, 46 | { 47 | title: Type, 48 | dataIndex: 'type', 49 | key: 'type', 50 | }, 51 | { 52 | title: Operation, 53 | key: 'operation', 54 | render: (text, record) => { 55 | return ( 56 | this.handleMenuClick(record, e)} 58 | menuOptions={[ 59 | { key: '1', name: i18n.t`Update` }, 60 | { key: '2', name: i18n.t`Delete` }, 61 | ]} 62 | /> 63 | ) 64 | }, 65 | }, 66 | ] 67 | 68 | return ( 69 |
i18n.t`Total ${total} Items`, 74 | }} 75 | className={styles.table} 76 | bordered 77 | columns={columns} 78 | simple 79 | rowKey={record => record._id} 80 | /> 81 | ) 82 | } 83 | } 84 | 85 | List.propTypes = { 86 | onDeleteItem: PropTypes.func, 87 | onEditItem: PropTypes.func, 88 | location: PropTypes.object, 89 | } 90 | 91 | export default List 92 | -------------------------------------------------------------------------------- /backstage/src/pages/media/components/List.less: -------------------------------------------------------------------------------- 1 | .table { 2 | :global { 3 | .ant-table td { 4 | white-space: nowrap; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backstage/src/pages/request/index.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars'; 2 | 3 | .result { 4 | height: 600px; 5 | width: 100%; 6 | background: @hover-color; 7 | border-color: #ddd; 8 | padding: 16px; 9 | margin-top: 16px; 10 | word-break: break-word; 11 | line-height: 2; 12 | overflow: scroll; 13 | } 14 | 15 | .requestList { 16 | padding-right: 24px; 17 | margin-bottom: 24px; 18 | .listItem { 19 | cursor: pointer; 20 | padding-left: 8px; 21 | &.lstItemActive { 22 | background-color: @hover-color; 23 | } 24 | .background-hover(); 25 | } 26 | } 27 | 28 | .paramsBlock { 29 | overflow: visible; 30 | opacity: 1; 31 | height: auto; 32 | transition: opacity 0.3s; 33 | &.hideParams { 34 | width: 0; 35 | height: 0; 36 | opacity: 0; 37 | overflow: hidden; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backstage/src/plugins/onError.js: -------------------------------------------------------------------------------- 1 | import { message } from 'antd' 2 | 3 | export default { 4 | onError(e) { 5 | e.preventDefault() 6 | message.error(e.message) 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /backstage/src/services/api.js: -------------------------------------------------------------------------------- 1 | export default { 2 | queryUserInfo: '/admin-account', 3 | logoutUser: 'PUT /admin-account/sign-out', 4 | loginUser: 'PUT /admin-account/sign-in', 5 | 6 | queryUser: '/user/:_id', 7 | queryUserList: '/users', 8 | updateUser: 'Patch /user/:_id', 9 | createUser: 'POST /user/:_id', 10 | removeUser: 'DELETE /user/:_id', 11 | removeUserList: 'POST /users/delete', 12 | 13 | queryAdminUserList: '/admin-user', 14 | createAdminUser: 'POST /admin-user', 15 | removeAdminUser: 'DELETE /admin-user/:_id', 16 | updateAdminUser: 'PUT /admin-user/:_id', 17 | 18 | queryAdminGroupList: '/admin-group', 19 | createAdminGroup: 'POST /admin-group', 20 | removeAdminGroup: 'DELETE /admin-group/:_id', 21 | updateAdminGroup: 'PUT /admin-group/:_id', 22 | queryAllAdminGroupList: '/admin-group/all', 23 | 24 | queryAuthoritiesOwned: '/authority', 25 | 26 | queryCategoryList: '/category', 27 | createCategory: 'POST /category', 28 | removeCategory: 'DELETE /category/:_id', 29 | updateCategory: 'PUT /category/:_id', 30 | removeCategoryList: 'POST /category/multi', 31 | 32 | queryMediaList: '/media', 33 | createMedia: 'POST /media', 34 | removeMedia: 'DELETE /media/:_id', 35 | updateMedia: 'PUT /media/:_id', 36 | removeMediaList: 'POST /media/multi', 37 | 38 | queryArticle: '/article/:_id', 39 | queryArticleList: '/article', 40 | createArticle: 'POST /article', 41 | updateArticle: 'PUT /article/:_id', 42 | removeArticle: 'DELETE /article/:_id', 43 | removeArticleList: 'POST /article/multi', 44 | 45 | queryPostList: '/posts', 46 | 47 | queryDashboard: '/dashboard', 48 | 49 | media: '/media', 50 | } 51 | -------------------------------------------------------------------------------- /backstage/src/services/index.js: -------------------------------------------------------------------------------- 1 | import { has, set, isNull, isObject } from 'lodash' 2 | import md5 from 'md5' 3 | import request from 'utils/request' 4 | import { API_URL, secret } from 'utils/config' 5 | import auth from 'utils/auth' 6 | 7 | import routes from './routes' 8 | import api from './api' 9 | 10 | const encrypted = data => { 11 | if (has(data, 'password')) { 12 | set(data, 'password', md5(data.password + secret)) 13 | } 14 | return data 15 | } 16 | 17 | /** 18 | * filter the empty params (not '') 19 | * @param {Object} data [description] 20 | * @return {Object} [description] 21 | */ 22 | const filterNull = data => { 23 | if (isObject(data)) { 24 | for (let key in data) { 25 | isNull(data[key]) && delete data[key] 26 | } 27 | } 28 | return data 29 | } 30 | 31 | const gen = params => { 32 | let url = API_URL + params 33 | let method = 'GET' 34 | 35 | const paramsArray = params.split(' ') 36 | if (paramsArray.length === 2) { 37 | method = paramsArray[0] 38 | url = API_URL + paramsArray[1] 39 | } 40 | 41 | return function(data) { 42 | return request({ 43 | url, 44 | data: encrypted(filterNull(data)), 45 | method, 46 | headers: { 47 | authorization: auth.get(), 48 | }, 49 | }) 50 | } 51 | } 52 | 53 | const APIFunction = {} 54 | for (const key in api) { 55 | APIFunction[key] = gen(api[key]) 56 | } 57 | 58 | APIFunction.queryWeather = params => { 59 | params.key = 'i7sau1babuzwhycn' 60 | return request({ 61 | url: `${API_URL}/weather/now.json`, 62 | data: params, 63 | }) 64 | } 65 | 66 | APIFunction.queryRouteList = () => { 67 | return Promise.resolve(routes) 68 | } 69 | 70 | module.exports = APIFunction 71 | -------------------------------------------------------------------------------- /backstage/src/services/routes.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: '1', 4 | icon: 'dashboard', 5 | name: 'Dashboard', 6 | zhName: '仪表盘', 7 | route: '/dashboard', 8 | }, 9 | { 10 | id: '2', 11 | name: 'System', 12 | zhName: '系统', 13 | icon: 'setting', 14 | }, 15 | { 16 | id: '21', 17 | breadcrumbParentId: '1', 18 | menuParentId: '2', 19 | name: 'Management user', 20 | zhName: '管理员', 21 | icon: 'user', 22 | route: '/adminUser', 23 | }, 24 | { 25 | id: '22', 26 | breadcrumbParentId: '1', 27 | menuParentId: '2', 28 | name: 'Management group', 29 | zhName: '管理组', 30 | icon: 'team', 31 | route: '/adminGroup', 32 | }, 33 | { 34 | id: '3', 35 | name: 'Content', 36 | zhName: '内容', 37 | icon: 'file-text', 38 | }, 39 | { 40 | id: '31', 41 | breadcrumbParentId: '1', 42 | menuParentId: '3', 43 | name: 'Category', 44 | zhName: '分类', 45 | icon: 'filter', 46 | route: '/category', 47 | }, 48 | { 49 | id: '32', 50 | breadcrumbParentId: '1', 51 | menuParentId: '3', 52 | name: 'Article', 53 | zhName: '文章', 54 | icon: 'file-text', 55 | route: '/article', 56 | }, 57 | { 58 | id: '33', 59 | breadcrumbParentId: '32', 60 | menuParentId: '-1', 61 | name: 'Article Edit', 62 | zhName: '文章编辑', 63 | route: '/article/:_id', 64 | }, 65 | { 66 | id: '34', 67 | breadcrumbParentId: '1', 68 | menuParentId: '3', 69 | name: 'Media', 70 | zhName: '资源', 71 | icon: 'cloud', 72 | route: '/media', 73 | }, 74 | { 75 | id: '9', 76 | breadcrumbParentId: '1', 77 | name: 'Request', 78 | zhName: 'Request', 79 | icon: 'api', 80 | route: '/request', 81 | }, 82 | ] 83 | -------------------------------------------------------------------------------- /backstage/src/themes/default.less: -------------------------------------------------------------------------------- 1 | // 本文件是对 ant-design: 2 | // https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less 3 | // 相应变量值的覆盖 4 | // 注意:只需写出要覆盖的变量即可(不需要覆盖的变量不要写) 5 | 6 | @import '../../node_modules/antd/lib/style/themes/default.less'; 7 | 8 | @border-radius-base: 3px; 9 | @border-radius-sm: 2px; 10 | @shadow-color: rgba(0, 0, 0, 0.05); 11 | @shadow-1-down: 4px 4px 40px @shadow-color; 12 | @border-color-split: #f4f4f4; 13 | @border-color-base: #e5e5e5; 14 | @font-size-base: 13px; 15 | @text-color: #666; 16 | @hover-color: #f9f9fc; 17 | -------------------------------------------------------------------------------- /backstage/src/themes/index.less: -------------------------------------------------------------------------------- 1 | @import '~themes/vars.less'; 2 | 3 | body { 4 | height: 100%; 5 | overflow-y: hidden; 6 | background-color: #f8f8f8; 7 | } 8 | 9 | ::-webkit-scrollbar-thumb { 10 | background-color: #e6e6e6; 11 | } 12 | 13 | ::-webkit-scrollbar { 14 | width: 8px; 15 | height: 8px; 16 | } 17 | 18 | :global { 19 | .ant-breadcrumb { 20 | & > span { 21 | &:last-child { 22 | color: #999; 23 | font-weight: normal; 24 | } 25 | } 26 | } 27 | 28 | .ant-breadcrumb-link { 29 | .anticon + span { 30 | margin-left: 4px; 31 | } 32 | } 33 | 34 | .ant-table { 35 | .ant-table-thead > tr > th { 36 | text-align: center; 37 | } 38 | 39 | .ant-table-tbody > tr > td { 40 | text-align: center; 41 | } 42 | 43 | &.ant-table-small { 44 | .ant-table-thead > tr > th { 45 | background: #f7f7f7; 46 | } 47 | 48 | .ant-table-body > table { 49 | padding: 0; 50 | } 51 | } 52 | } 53 | 54 | .ant-table-pagination { 55 | float: none !important; 56 | display: table; 57 | margin: 16px auto !important; 58 | } 59 | 60 | .ant-popover-inner { 61 | border: none; 62 | border-radius: 0; 63 | box-shadow: 0 0 20px rgba(100, 100, 100, 0.2); 64 | } 65 | 66 | .vertical-center-modal { 67 | display: flex; 68 | align-items: center; 69 | justify-content: center; 70 | 71 | .ant-modal { 72 | top: 0; 73 | 74 | .ant-modal-body { 75 | max-height: 80vh; 76 | overflow-y: auto; 77 | } 78 | } 79 | } 80 | 81 | .ant-form-item-control { 82 | vertical-align: middle; 83 | } 84 | 85 | .ant-modal-mask { 86 | background-color: rgba(55, 55, 55, 0.2); 87 | } 88 | 89 | .ant-modal-content { 90 | box-shadow: none; 91 | } 92 | 93 | .ant-select-dropdown-menu-item { 94 | padding: 12px 16px !important; 95 | } 96 | 97 | .margin-right { 98 | margin-right: 16px; 99 | } 100 | 101 | a:focus { 102 | text-decoration: none; 103 | } 104 | 105 | // self styles 106 | 107 | .cover .ant-upload-list-picture-card .ant-upload-list-item { 108 | width: 260px; 109 | height: 150px; 110 | } 111 | } 112 | @media (min-width: 1600px) { 113 | :global { 114 | .ant-col-xl-48 { 115 | width: 20%; 116 | } 117 | 118 | .ant-col-xl-96 { 119 | width: 40%; 120 | } 121 | } 122 | } 123 | @media (max-width: 767px) { 124 | :global { 125 | .ant-pagination-item, 126 | .ant-pagination-next, 127 | .ant-pagination-options, 128 | .ant-pagination-prev { 129 | margin-bottom: 8px; 130 | } 131 | 132 | .ant-card { 133 | .ant-card-head { 134 | padding: 0 12px; 135 | } 136 | 137 | .ant-card-body { 138 | padding: 12px; 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /backstage/src/themes/mixin.less: -------------------------------------------------------------------------------- 1 | @import '~themes/default'; 2 | 3 | @dark-half: #494949; 4 | @purple: #d897eb; 5 | @shadow-1: 4px 4px 20px 0 rgba(0, 0, 0, 0.01); 6 | @shadow-2: 4px 4px 40px 0 rgba(0, 0, 0, 0.05); 7 | @transition-ease-in: all 0.3s ease-out; 8 | @transition-ease-out: all 0.3s ease-out; 9 | @ease-in: ease-in; 10 | 11 | .text-overflow { 12 | white-space: nowrap; 13 | text-overflow: ellipsis; 14 | overflow: hidden; 15 | } 16 | 17 | .text-gradient { 18 | background-image: -webkit-gradient( 19 | linear, 20 | 37.219838% 34.532506%, 21 | 36.425669% 93.178216%, 22 | from(#29cdff), 23 | to(#0a60ff), 24 | color-stop(0.37, #148eff) 25 | ); 26 | -webkit-background-clip: text; 27 | -webkit-text-fill-color: transparent; 28 | } 29 | 30 | .background-hover { 31 | transition: @transition-ease-in; 32 | &:hover { 33 | background-color: @hover-color; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backstage/src/themes/vars.less: -------------------------------------------------------------------------------- 1 | @import '~themes/default.less'; 2 | @import '~themes/mixin.less'; 3 | -------------------------------------------------------------------------------- /backstage/src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import config from './config' 2 | 3 | const JWT_NAME = `${config.prefix}JWT` 4 | const INAUTH_NAME = `${config.prefix}InAuth` 5 | 6 | export default { 7 | set(jwt) { 8 | localStorage.setItem(JWT_NAME, jwt) 9 | }, 10 | get() { 11 | return `Bearer ${localStorage.getItem(JWT_NAME)}` 12 | }, 13 | login() { 14 | localStorage.setItem(INAUTH_NAME, 'true') 15 | }, 16 | logout() { 17 | localStorage.removeItem(JWT_NAME) 18 | localStorage.removeItem(INAUTH_NAME) 19 | }, 20 | loggedIn() { 21 | return !!localStorage.getItem(INAUTH_NAME) 22 | }, 23 | logoutJump() { 24 | let from = window.location.pathname 25 | window.location = `${window.location.origin}/login?from=${from}` 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /backstage/src/utils/config.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'http://localhost:3030' 2 | const API_URL = `${BASE_URL}/backstage-api` 3 | 4 | module.exports = { 5 | siteName: 'Hotchcms', 6 | copyright: 'Hotchcms © 2018 luckcoding@gmail.com', 7 | logoPath: '/logo.svg', 8 | BASE_URL, 9 | API_URL, 10 | fixedHeader: true, // sticky primary layout header 11 | secret: 'hotchcms', // api password secret 12 | prefix: 'hotchcms', // storage key 13 | 14 | get mediaApiUrl() { 15 | return `${API_URL}/media` 16 | }, 17 | 18 | constant: { 19 | CANCEL_REQUEST_MESSAGE: 'cancle request', 20 | ROLE_TYPE: { 21 | ADMIN: 'admin', 22 | DEFAULT: 'admin', 23 | DEVELOPER: 'developer', 24 | }, 25 | }, 26 | 27 | /* Layout configuration, specify which layout to use for route. */ 28 | layouts: [ 29 | { 30 | name: 'primary', 31 | include: [/.*/], 32 | exlude: [/(\/(en|zh))*\/login/], 33 | }, 34 | ], 35 | 36 | /* I18n configuration, `languages` and `defaultLanguage` are required currently. */ 37 | i18n: { 38 | /* Countrys flags: https://www.flaticon.com/packs/countrys-flags */ 39 | languages: [ 40 | { 41 | key: 'en', 42 | title: 'English', 43 | flag: '/america.svg', 44 | }, 45 | { 46 | key: 'zh', 47 | title: '中文', 48 | flag: '/china.svg', 49 | }, 50 | ], 51 | defaultLanguage: 'en', 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /backstage/src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | import config from './config' 2 | 3 | export function getMediaUrl(path) { 4 | if (typeof path === 'string') { 5 | return path.indexOf('http') === 0 6 | ? path 7 | : `${config.BASE_URL}/upload/${path}` 8 | } else { 9 | return null 10 | } 11 | } 12 | 13 | export function handleFileListUrl(fileList) { 14 | return fileList.map(fileItem => ({ 15 | ...fileItem, 16 | url: getMediaUrl(fileItem.url), 17 | })) 18 | } 19 | -------------------------------------------------------------------------------- /backstage/src/utils/index.test.js: -------------------------------------------------------------------------------- 1 | import { pathMatchRegexp } from './index' 2 | import pathToRegexp from 'path-to-regexp' 3 | 4 | describe('test pathMatchRegexp', () => { 5 | it('get right', () => { 6 | expect(pathMatchRegexp('/user', '/zh/user')).toEqual( 7 | pathToRegexp('/user').exec('/user') 8 | ) 9 | expect(pathMatchRegexp('/user', '/user')).toEqual( 10 | pathToRegexp('/user').exec('/user') 11 | ) 12 | 13 | expect(pathMatchRegexp('/user/:id', '/zh/user/1')).toEqual( 14 | pathToRegexp('/user/:id').exec('/user/1') 15 | ) 16 | expect(pathMatchRegexp('/user/:id', '/user/1')).toEqual( 17 | pathToRegexp('/user/:id').exec('/user/1') 18 | ) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /backstage/src/utils/model.js: -------------------------------------------------------------------------------- 1 | import modelExtend from 'dva-model-extend' 2 | 3 | export const model = { 4 | reducers: { 5 | updateState(state, { payload }) { 6 | return { 7 | ...state, 8 | ...payload, 9 | } 10 | }, 11 | }, 12 | } 13 | 14 | export const pageModel = modelExtend(model, { 15 | state: { 16 | list: [], 17 | pagination: { 18 | showSizeChanger: true, 19 | showQuickJumper: true, 20 | current: 1, 21 | total: 0, 22 | pageSize: 10, 23 | }, 24 | }, 25 | 26 | reducers: { 27 | querySuccess(state, { payload }) { 28 | const { list, pagination } = payload 29 | return { 30 | ...state, 31 | list, 32 | pagination: { 33 | ...state.pagination, 34 | ...pagination, 35 | }, 36 | } 37 | }, 38 | }, 39 | }) 40 | 41 | export const listModel = modelExtend(model, { 42 | state: { 43 | list: [], 44 | }, 45 | 46 | reducers: { 47 | querySuccess(state, { payload }) { 48 | const { list } = payload 49 | return { 50 | ...state, 51 | list, 52 | } 53 | }, 54 | }, 55 | }) 56 | -------------------------------------------------------------------------------- /backstage/src/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { cloneDeep, isEmpty } from 'lodash' 3 | import pathToRegexp from 'path-to-regexp' 4 | import { message } from 'antd' 5 | import config from 'config' 6 | import qs from 'qs' 7 | 8 | const { CANCEL_REQUEST_MESSAGE } = config.constant 9 | 10 | const { CancelToken } = axios 11 | window.cancelRequest = new Map() 12 | 13 | export default function request(options) { 14 | let { data, url, method = 'get' } = options 15 | const cloneData = cloneDeep(data) 16 | 17 | try { 18 | let domain = '' 19 | const urlMatch = url.match(/[a-zA-z]+:\/\/[^/]*/) 20 | if (urlMatch) { 21 | ;[domain] = urlMatch 22 | url = url.slice(domain.length) 23 | } 24 | 25 | const match = pathToRegexp.parse(url) 26 | url = pathToRegexp.compile(url)(data) 27 | 28 | for (const item of match) { 29 | if (item instanceof Object && item.name in cloneData) { 30 | delete cloneData[item.name] 31 | } 32 | } 33 | url = domain + url 34 | } catch (e) { 35 | message.error(e.message) 36 | } 37 | 38 | options.url = 39 | method.toLocaleLowerCase() === 'get' 40 | ? `${url}${isEmpty(cloneData) ? '' : '?'}${qs.stringify(cloneData)}` 41 | : url 42 | 43 | options.cancelToken = new CancelToken(cancel => { 44 | window.cancelRequest.set(Symbol(Date.now()), { 45 | pathname: window.location.pathname, 46 | cancel, 47 | }) 48 | }) 49 | 50 | return axios(options) 51 | .then(response => { 52 | const { statusText, status, data } = response 53 | 54 | let result = {} 55 | if (typeof data === 'object') { 56 | result = data 57 | if (Array.isArray(data)) { 58 | result.list = data 59 | } 60 | } else { 61 | result.data = data 62 | } 63 | 64 | return Promise.resolve({ 65 | success: true, 66 | message: statusText, 67 | statusCode: status, 68 | ...result, 69 | }) 70 | }) 71 | .catch(error => { 72 | const { response, message } = error 73 | 74 | if (String(message) === CANCEL_REQUEST_MESSAGE) { 75 | return { 76 | success: false, 77 | } 78 | } 79 | 80 | let msg 81 | let statusCode 82 | 83 | if (response && response instanceof Object) { 84 | const { data, statusText } = response 85 | statusCode = response.status 86 | msg = data.message || statusText 87 | } else { 88 | statusCode = 600 89 | msg = error.message || 'Network Error' 90 | } 91 | 92 | /* eslint-disable */ 93 | return Promise.reject({ 94 | success: false, 95 | statusCode, 96 | message: msg, 97 | }) 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /backstage/src/utils/theme.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Color: { 3 | green: '#64ea91', 4 | blue: '#8fc9fb', 5 | purple: '#d897eb', 6 | red: '#f69899', 7 | yellow: '#f8c82e', 8 | peach: '#f797d6', 9 | borderBase: '#e5e5e5', 10 | borderSplit: '#f4f4f4', 11 | grass: '#d6fbb5', 12 | sky: '#c1e0fc', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /docs/img1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/docs/img1.jpg -------------------------------------------------------------------------------- /docs/img2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/docs/img2.jpg -------------------------------------------------------------------------------- /docs/img3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/docs/img3.jpg -------------------------------------------------------------------------------- /front/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | [ 7 | "module-resolver", 8 | { 9 | "root": [ 10 | "./" 11 | ], 12 | "alias": { 13 | "components": "./src/components", 14 | "containers": "./src/containers", 15 | "helpers": "./src/helpers", 16 | "hocs": "./src/hocs", 17 | "pages": "./src/pages", 18 | "store": "./src/store", 19 | "static": "./static" 20 | } 21 | } 22 | ], 23 | [ 24 | "styled-components", 25 | { 26 | "ssr": true, 27 | "displayName": true, 28 | "preprocess": false 29 | } 30 | ], 31 | [ 32 | "transform-assets", 33 | { 34 | "extensions": ["txt", "svg", "png"], 35 | "regExp": ".*/static/(.+)", 36 | "name": "/static/[1]?[sha512:hash:base64:7]" 37 | } 38 | ], 39 | [ 40 | "transform-assets-import-to-string", 41 | { 42 | "baseDir": "static", 43 | "baseUri": "/" 44 | } 45 | ] 46 | ] 47 | } -------------------------------------------------------------------------------- /front/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /front/.eslintignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | next.config.js 4 | static 5 | -------------------------------------------------------------------------------- /front/.gitignore: -------------------------------------------------------------------------------- 1 | # Don't check auto-generated stuff into git 2 | coverage 3 | build 4 | .next 5 | node_modules 6 | stats.json 7 | 8 | # Cruft 9 | .DS_Store 10 | npm-debug.log 11 | .idea 12 | dump.rdb -------------------------------------------------------------------------------- /front/next.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | WebpackBundleSizeAnalyzerPlugin, 3 | } = require('webpack-bundle-size-analyzer') 4 | const webpack = require('webpack') 5 | const withImages = require('next-images') 6 | const withCSS = require('@zeit/next-css') 7 | 8 | const { ANALYZE } = process.env 9 | 10 | module.exports = withCSS( 11 | withImages({ 12 | webpack(config) { 13 | if (ANALYZE) { 14 | config.plugins.push(new WebpackBundleSizeAnalyzerPlugin('stats.txt')) 15 | } 16 | 17 | // config.plugins.push(new webpack.DefinePlugin({ 18 | // 'process.env.API_ENV': JSON.stringify(API_ENV) 19 | // })); 20 | 21 | return config 22 | }, 23 | }) 24 | ) 25 | -------------------------------------------------------------------------------- /front/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["server/**/*.js"] 3 | } 4 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hotchcms-front", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon server/index.js", 8 | "build": "cross-env API_ENV=production next build", 9 | "start": "cross-env NODE_ENV=production node server/index.js", 10 | "lint": "eslint --ext .js . --fix", 11 | "lint-staged": "lint-staged" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "blueimp-md5": "^2.10.0", 17 | "bootstrap": "^4.3.1", 18 | "compression": "^1.7.3", 19 | "cookie": "^0.3.1", 20 | "express": "^4.16.4", 21 | "immutable": "^4.0.0-rc.12", 22 | "isomorphic-unfetch": "^3.0.0", 23 | "js-cookie": "^2.2.0", 24 | "lodash": "^4.17.13", 25 | "next": "^7.0.2", 26 | "next-redux-saga": "^3.0.0", 27 | "next-redux-wrapper": "^2.1.0", 28 | "nprogress": "^0.2.0", 29 | "path-to-regexp": "^2.4.0", 30 | "prop-types": "^15.6.2", 31 | "qs": "^6.6.0", 32 | "react": "^16.6.3", 33 | "react-bootstrap": "^1.0.0-beta.3", 34 | "react-dom": "^16.6.3", 35 | "react-loadable": "^5.5.0", 36 | "react-redux": "^6.0.0", 37 | "redux": "^4.0.1", 38 | "redux-form": "^8.0.4", 39 | "redux-immutable": "^4.0.0", 40 | "redux-saga": "^0.16.2", 41 | "reduxsauce": "^1.0.1", 42 | "reselect": "^4.0.0", 43 | "sitemap": "^2.1.0", 44 | "styled-components": "^4.1.1" 45 | }, 46 | "devDependencies": { 47 | "@zeit/next-css": "^1.0.1", 48 | "babel-eslint": "^10.0.1", 49 | "babel-plugin-module-resolver": "^3.1.1", 50 | "babel-plugin-styled-components": "^1.8.0", 51 | "babel-plugin-transform-assets": "^1.0.2", 52 | "babel-plugin-transform-assets-import-to-string": "^1.2.0", 53 | "cross-env": "^5.2.0", 54 | "eslint": "^5.11.0", 55 | "eslint-config-airbnb": "^17.1.0", 56 | "eslint-config-airbnb-base": "^13.1.0", 57 | "eslint-import-resolver-babel-module": "5.0.0-beta.0", 58 | "eslint-plugin-import": "^2.14.0", 59 | "eslint-plugin-jsx-a11y": "^6.1.2", 60 | "eslint-plugin-react": "^7.11.1", 61 | "eslint-plugin-redux-saga": "^0.10.0", 62 | "husky": "^1.3.0", 63 | "lint-staged": "^8.1.0", 64 | "next-images": "^1.0.1", 65 | "nodemon": "^1.18.6", 66 | "redux-devtools-extension": "^2.13.7", 67 | "webpack-bundle-size-analyzer": "^3.0.0" 68 | }, 69 | "lint-staged": { 70 | "**/*.js": [ 71 | "eslint --ext .js --fix", 72 | "git add" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /front/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import App, { Container } from 'next/app' 3 | import NProgress from 'nprogress' 4 | import Router from 'next/router' 5 | import qs from 'qs' 6 | import { Provider } from 'react-redux' 7 | import withRedux from 'next-redux-wrapper' 8 | import withReduxSaga from 'next-redux-saga' 9 | import { I18nProvider } from 'helpers/I18n' 10 | import configureStore from 'store/configureStore' 11 | 12 | Router.events.on('routeChangeStart', (url) => { 13 | console.log(`Loading: ${url}`) 14 | NProgress.start() 15 | }) 16 | Router.events.on('routeChangeComplete', () => NProgress.done()) 17 | Router.events.on('routeChangeError', () => NProgress.done()) 18 | 19 | class MyApp extends App { 20 | static async getInitialProps({ Component, ctx }) { 21 | try { 22 | // search params 23 | const search = qs.parse(ctx.asPath.split('?')[1] || '') 24 | 25 | let pageInitialProps = {} 26 | 27 | if (Component.getInitialProps) { 28 | pageInitialProps = await Component.getInitialProps({ ...ctx, search }) 29 | } 30 | 31 | // Get the `locale` from the request object on the server. 32 | // In the browser, use the same values that the server serialized. 33 | const { req } = ctx 34 | const { locale } = req || window.__NEXT_DATA__.props 35 | return { 36 | pageProps: { 37 | ...pageInitialProps, 38 | search, 39 | }, 40 | locale, 41 | } 42 | } catch (e) { 43 | console.error(e.message) 44 | } 45 | } 46 | 47 | render() { 48 | const { 49 | Component, pageProps, store, locale, 50 | } = this.props 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ) 60 | } 61 | } 62 | 63 | export default withRedux(configureStore)(withReduxSaga({ async: true })(MyApp)) 64 | -------------------------------------------------------------------------------- /front/pages/_document.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Document, { Head, Main, NextScript } from 'next/document' 3 | import { ServerStyleSheet } from 'styled-components' 4 | 5 | export default class MyDocument extends Document { 6 | static async getInitialProps(ctx) { 7 | const initialProps = await Document.getInitialProps(ctx) 8 | 9 | const sheet = new ServerStyleSheet() 10 | const page = ctx.renderPage(App => props => sheet.collectStyles()) 11 | const styleTags = sheet.getStyleElement() 12 | 13 | return { 14 | ...page, 15 | ...initialProps, 16 | styleTags, 17 | } 18 | } 19 | 20 | render() { 21 | return ( 22 | 23 | 24 | {/* reset */} 25 | 26 | 27 | {/* global */} 28 | 29 | 30 | {/* Import CSS for nprogress */} 31 | 32 | 33 | {/* Latest compiled and minified CSS */} 34 | 35 | 36 | {this.props.styleTags} 37 | 38 | 39 |
40 | 41 | 42 | 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /front/pages/about.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Header from 'components/Header' 3 | import defaultPage from 'hocs/defaultPage' 4 | import I18n from 'helpers/I18n' 5 | 6 | function About() { 7 | return ( 8 |
9 |
10 |

11 | 12 |

13 |
14 | ) 15 | } 16 | 17 | About.getSettings = () => ({ 18 | title: '关于', 19 | }) 20 | 21 | export default defaultPage(About) 22 | -------------------------------------------------------------------------------- /front/pages/index.js: -------------------------------------------------------------------------------- 1 | import Page from 'containers/HomePage' 2 | export default Page 3 | -------------------------------------------------------------------------------- /front/pages/login.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/front/pages/login.js -------------------------------------------------------------------------------- /front/pages/p.js: -------------------------------------------------------------------------------- 1 | import Page from 'containers/DetailPage' 2 | export default Page 3 | -------------------------------------------------------------------------------- /front/pages/register.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import defaultPage from 'hocs/defaultPage' 3 | import Header from 'components/Header' 4 | 5 | class Register extends React.Component { 6 | static async getSettings() { 7 | return { 8 | title: '注册', 9 | } 10 | } 11 | 12 | render() { 13 | return ( 14 |
15 |
16 |
17 | ) 18 | } 19 | } 20 | 21 | export default defaultPage(Register) 22 | -------------------------------------------------------------------------------- /front/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const next = require('next') 3 | const compression = require('compression') 4 | 5 | const match = require('./match') 6 | const rootStaticFiles = require('./rootStaticFiles') 7 | const renderFromCache = require('./renderFromCache') 8 | 9 | const port = parseInt(process.env.PORT, 10) || 3000 10 | const dev = process.env.NODE_ENV !== 'production' 11 | const app = next({ dev }) 12 | const handle = app.getRequestHandler() 13 | 14 | app.prepare().then(() => { 15 | const server = express() 16 | 17 | // compression 18 | if (!dev) { 19 | server.use(compression()) 20 | } 21 | 22 | // static files 23 | rootStaticFiles({ 24 | server, 25 | files: ['/robots.txt'], 26 | }) 27 | 28 | // language/cache/match routes 29 | match({ 30 | app, 31 | server, 32 | languages: ['en', 'zh'], 33 | defaultLanguage: 'zh', 34 | routes: [ 35 | { route: '/', useCache: false }, 36 | { route: '/about' }, 37 | { route: '/p/:_id' }, 38 | ], 39 | renderFromCache: renderFromCache(app), 40 | }) 41 | 42 | server.get('*', (req, res) => handle(req, res)) 43 | 44 | server.listen(port, (err) => { 45 | if (err) throw err 46 | console.log(`> Ready on http://localhost:${port}`) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /front/server/match.js: -------------------------------------------------------------------------------- 1 | /** 2 | * match render 3 | * to handle diff language url match, url with params and router cache 4 | * @param {Object} options.app next.js app 5 | * @param {Object} options.server express server 6 | * @param {Array} options.routes routes bind 7 | * @param {Array} options.languages support langs 8 | * @param {String} options.defaultLanguage defalut lang 9 | * @return {any} intercept and handle 10 | */ 11 | module.exports = ({ 12 | app, 13 | server, 14 | routes = [], 15 | languages = [], 16 | defaultLanguage = '', 17 | renderFromCache, 18 | }) => { 19 | if ( 20 | !( 21 | app 22 | && server 23 | && Array.isArray(routes) 24 | && Array.isArray(languages) 25 | && typeof defaultLanguage === 'string' 26 | ) 27 | ) throw TypeError('options Error') 28 | 29 | // all the route will be match 30 | // like '/' '/p/:id' '/zh' '/zh/p/:id' '/en' '/en/p/:id' ... 31 | const routesMap = [] 32 | 33 | routes.forEach(({ route, useCache }) => { 34 | ['', ...languages].forEach((language) => { 35 | let packed = `/${language}/${route}` 36 | packed = packed.replace(/\/{2,}/g, '/') 37 | routesMap.push({ 38 | packed, 39 | original: route, 40 | language: language || defaultLanguage, 41 | useCache, 42 | }) 43 | }) 44 | }) 45 | 46 | // render with next.js 47 | routesMap.forEach(({ 48 | packed, original, language, useCache, 49 | }) => { 50 | const parts = original.split('/:') 51 | const renderPath = parts[0] 52 | 53 | const matchKey = parts[1] 54 | 55 | server.get(packed, (req, res) => { 56 | // bind locale in ctx.req.locale 57 | req.locale = language 58 | 59 | // render params 60 | const args = [req, res, renderPath, { [matchKey]: req.params[matchKey] }] 61 | return useCache ? renderFromCache(...args) : app.render(...args) 62 | }) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /front/server/renderFromCache.js: -------------------------------------------------------------------------------- 1 | const LRUCache = require('lru-cache') 2 | 3 | // This is where we cache our rendered HTML pages 4 | const ssrCache = new LRUCache({ 5 | max: 100, 6 | maxAge: 1000 * 60 * 30, // 30 min 7 | }) 8 | 9 | /* 10 | * NB: make sure to modify this to take into account anything that should trigger 11 | * an immediate page change (e.g a locale stored in req.session) 12 | */ 13 | function getCacheKey(req) { 14 | return `${req.url}` 15 | } 16 | 17 | module.exports = app => async function renderFromCache(req, res, pagePath, queryParams) { 18 | const key = getCacheKey(req) 19 | 20 | // If we have a page in the cache, let's serve it 21 | if (ssrCache.has(key)) { 22 | res.setHeader('x-cache', 'HIT') 23 | res.send(ssrCache.get(key)) 24 | return 25 | } 26 | 27 | try { 28 | // If not let's render the page into HTML 29 | const html = await app.renderToHTML(req, res, pagePath, queryParams) 30 | 31 | // Something is wrong with the request, let's skip the cache 32 | if (res.statusCode !== 200) { 33 | res.send(html) 34 | return 35 | } 36 | 37 | // Let's cache this page 38 | ssrCache.set(key, html) 39 | 40 | res.setHeader('x-cache', 'MISS') 41 | res.send(html) 42 | } catch (err) { 43 | app.renderError(err, req, res, pagePath, queryParams) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /front/server/rootStaticFiles.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const path = require('path') 3 | 4 | module.exports = ({ server, files }) => { 5 | files.forEach((file) => { 6 | server.use(file, express.static(path.join(__dirname, '../static', file))) 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /front/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { Container } from 'react-bootstrap' 4 | import LogoPNG from 'static/logo.png' 5 | import Link from './Link' 6 | 7 | const Wrapper = styled.header` 8 | background-color: rgba(0, 0, 0, 0.85); 9 | backdrop-filter: saturate(180%) blur(20px); 10 | 11 | position: sticky; 12 | top: 0; 13 | z-index: 1020; 14 | 15 | .container { 16 | height: 48px; 17 | overflow: hidden; 18 | 19 | display: flex; 20 | align-items: center; 21 | justify-content: space-between; 22 | } 23 | ` 24 | 25 | const Logo = styled.img` 26 | height: 48px; 27 | ` 28 | 29 | const Nav = styled.nav` 30 | a { 31 | padding: 10px 20px; 32 | 33 | color: #999; 34 | transition: ease-in-out color 0.15s; 35 | 36 | &:hover, 37 | &.active { 38 | color: #fff; 39 | } 40 | } 41 | ` 42 | 43 | function Header() { 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 60 | 61 | 62 | ) 63 | } 64 | 65 | export default Header 66 | -------------------------------------------------------------------------------- /front/src/components/I18n/helpers.js: -------------------------------------------------------------------------------- 1 | import { isObject, isEmpty } from 'lodash' 2 | 3 | /** 4 | * Get the final translation result 5 | * @param {Object} params {zh:"zhXX {name}",en:"enXX {name}",...} 6 | * @param {String} locale "zh" 7 | * @param {Object} translations {zh:{id:"zhXX"},en:{id:"enXX"}} 8 | * @return {String} "result" 9 | */ 10 | function t(params, locale, translations) { 11 | params = isObject(params) ? params : {} 12 | locale = typeof locale === 'string' ? locale : '' 13 | translations = isObject(translations) ? translations : {} 14 | 15 | const { id, value, ...other } = params 16 | 17 | // lang text, like: "name is: {name}" 18 | let text = ( 19 | typeof id === 'string' 20 | ? translations[locale][id] 21 | : other[locale] 22 | ) || '' 23 | 24 | // bind {value} 25 | if (isObject(value) && !isEmpty(value)) { 26 | for (const key in value) { 27 | const regx = new RegExp(`{${key}}`, 'g') 28 | text = text.replace(regx, value[key]) 29 | } 30 | } 31 | 32 | return text 33 | } 34 | 35 | class I18n { 36 | constructor(translations, locale) { 37 | if (!isObject(translations) || typeof locale !== 'string') { 38 | throw TypeError('arguments error') 39 | } 40 | this.translations = translations 41 | this.locale = locale 42 | } 43 | 44 | t(options, locale, translations) { 45 | return t(options, locale || this.locale, translations || this.translations) 46 | } 47 | } 48 | export { t } 49 | export default I18n 50 | -------------------------------------------------------------------------------- /front/src/components/Link/helpers.js: -------------------------------------------------------------------------------- 1 | let NEW_WIN = {} 2 | 3 | /** 4 | * 判断是否是全路径 5 | * @param {String} url 路径 6 | * @return {Boolean} 7 | */ 8 | export function isUrl(url) { 9 | return typeof url === 'string' && /(^https?\:\/\/|^(\w+\.)?\w+\.\w+)/.test(url) 10 | } 11 | 12 | /** 13 | * 补全路径,防止外站跳不出去 14 | */ 15 | export function completeUrl(url) { 16 | let outUrl = String(url) 17 | 18 | if (!outUrl.startsWith('http')) { 19 | outUrl = `http://${outUrl}` 20 | } 21 | 22 | return outUrl 23 | } 24 | 25 | export const Jump = { 26 | init() { 27 | NEW_WIN = window.open() 28 | }, 29 | open(url) { 30 | NEW_WIN.location.href = completeUrl(url) 31 | }, 32 | } 33 | 34 | /** 35 | * 多语言路由 36 | * @param {String} locale 当前语言环境 37 | * @param {String} asPath 当前全路径 38 | */ 39 | 40 | /** 41 | * 多语言路由 42 | * @param {String} locale 当前语言环境 43 | * @param {String} asPath 当前全路径 44 | */ 45 | function Href(locale, asPath = '') { 46 | this.locale = locale 47 | this.asPath = asPath 48 | this.regExp = null 49 | this.localePrefix = this.getLocalePrefix() 50 | 51 | return this 52 | } 53 | 54 | /** 55 | * 计算是否需要在路由上加多语言路径 56 | * @return {String} 57 | */ 58 | Href.prototype.getLocalePrefix = function () { 59 | const { asPath, locale } = this 60 | this.regExp = new RegExp(`(^\/${locale}$|^\/${locale}\/)`, 'g') 61 | return this.regExp.test(asPath) ? `/${locale}` : '' 62 | } 63 | 64 | /** 65 | * 路由对应语言环境 66 | * @param {String} href 站内路径 67 | * @param {String} force 强制当前语言环境 68 | * @param {Boolean} clear 69 | * 是否先删除路径的多语言前标 @@处理当前环境为 zh ,但接受路径为 /zh/xxx 等情况, 默认为 true 70 | * @return {String} 最终路径 71 | */ 72 | Href.prototype.prefix = function (href, force, clear = true) { 73 | let pureHref = this.pure(href) 74 | 75 | if (clear) { 76 | pureHref = pureHref.replace(this.regExp, '/') 77 | } 78 | 79 | let localePrefix = String(force || this.localePrefix) 80 | 81 | if (!localePrefix.startsWith('/')) { 82 | localePrefix = `/${localePrefix}` 83 | } 84 | 85 | return `${this.localePrefix}${pureHref}` 86 | } 87 | 88 | /** 89 | * 不添加语言 localePrefix 90 | * @param {String} href 原路径 91 | * @return {String} 处理后的路径 92 | */ 93 | Href.prototype.pure = function (href) { 94 | return `/${href}` 95 | .replace(/\/{2,}/g, '/') // 去除重复 / 96 | .replace(/.+(\/#)/g, '#') // 修改非根路径后的 /# => # 97 | } 98 | 99 | export { Href } 100 | -------------------------------------------------------------------------------- /front/src/components/Link/index.js: -------------------------------------------------------------------------------- 1 | import React, { Children } from 'react' 2 | import PropTypes from 'prop-types' 3 | import get from 'lodash/get' 4 | import { withRouter } from 'next/router' 5 | import NextLink from 'next/link' 6 | import * as helpers from './helpers' 7 | 8 | /** 9 | * 加上语言钩子 10 | * /?/en 11 | * 12 | * 不加语言钩子 13 | * isPure /en 14 | * 15 | * 不使用标签 16 | *
isPure div /zh
17 | * 18 | * 高亮 19 | *
user 20 | */ 21 | class Link extends React.PureComponent { 22 | static contextTypes = { 23 | locale: PropTypes.string.isRequired, 24 | } 25 | 26 | render() { 27 | const { 28 | router, href = '', isPure, activeClassName, children, ...props 29 | } = this.props 30 | const { locale } = this.context 31 | 32 | const factory = new helpers.Href(locale, router.asPath) 33 | const nextHref = isPure ? factory.pure(href) : factory.prefix(href) 34 | 35 | const child = Children.only(children) 36 | let className = get(child.props, 'className') || '' 37 | 38 | if (href === router.pathname && activeClassName) { 39 | className = `${className} ${activeClassName}`.trim() 40 | } 41 | 42 | return ( 43 | 44 | {React.cloneElement(child, { className })} 45 | 46 | ) 47 | } 48 | } 49 | 50 | Link.propTypes = { 51 | href: PropTypes.string, // 要跳转的路径 52 | children: PropTypes.node, 53 | isPure: PropTypes.bool, // 纯粹跳转 54 | activeClassName: PropTypes.string, // 高尚亮样式 55 | } 56 | 57 | export * from './helpers' 58 | export default withRouter(Link) 59 | -------------------------------------------------------------------------------- /front/src/components/ListTitle.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export default styled.h2` 4 | color: #262626; 5 | font-size: 24px; 6 | line-height: 33px; 7 | font-weight: 500; 8 | margin: 0; 9 | padding: 14px 0 14px 10px; 10 | ` 11 | -------------------------------------------------------------------------------- /front/src/components/LoadingIndicator/Circle.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled, { keyframes } from 'styled-components' 4 | 5 | const circleFadeDelay = keyframes` 6 | 0%, 7 | 39%, 8 | 100% { 9 | opacity: 0; 10 | } 11 | 12 | 40% { 13 | opacity: 1; 14 | } 15 | ` 16 | 17 | const Circle = (props) => { 18 | const CirclePrimitive = styled.div` 19 | width: 100%; 20 | height: 100%; 21 | position: absolute; 22 | left: 0; 23 | top: 0; 24 | ${props.rotate 25 | && ` 26 | -webkit-transform: rotate(${props.rotate}deg); 27 | -ms-transform: rotate(${props.rotate}deg); 28 | transform: rotate(${props.rotate}deg); 29 | `} &:before { 30 | content: ''; 31 | display: block; 32 | margin: 0 auto; 33 | width: 15%; 34 | height: 15%; 35 | background-color: ${props.color}; 36 | border-radius: 100%; 37 | animation: ${circleFadeDelay} 1.2s infinite ease-in-out both; 38 | ${props.delay 39 | && ` 40 | -webkit-animation-delay: ${props.delay}s; 41 | animation-delay: ${props.delay}s; 42 | `}; 43 | } 44 | ` 45 | return 46 | } 47 | 48 | Circle.propTypes = { 49 | delay: PropTypes.number, 50 | rotate: PropTypes.number, 51 | color: PropTypes.string, 52 | } 53 | 54 | export default Circle 55 | -------------------------------------------------------------------------------- /front/src/components/LoadingIndicator/Wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Wrapper = styled.div` 4 | margin: 0 auto; 5 | width: ${props => props.size}px; 6 | height: ${props => props.size}px; 7 | position: relative; 8 | ` 9 | 10 | export default Wrapper 11 | -------------------------------------------------------------------------------- /front/src/components/LoadingIndicator/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Circle from './Circle' 4 | import Wrapper from './Wrapper' 5 | 6 | const LoadingIndicator = ({ size, color, ...props }) => ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | 23 | LoadingIndicator.defaultProps = { 24 | color: '#999', 25 | size: 40, 26 | } 27 | 28 | export default LoadingIndicator 29 | -------------------------------------------------------------------------------- /front/src/components/Pagination/helpers.js: -------------------------------------------------------------------------------- 1 | import qs from 'qs' 2 | import { isObject, isNumber } from 'lodash' 3 | 4 | 5 | /** 6 | * 取出单位长度的数组中间的多少个 7 | * @param {Number} midIndex 要取的中间下标 8 | * @param {Number} arrSize 数组长度 9 | * @param {Number} showSize 显示数量 10 | * @return {Array} 下表 11 | */ 12 | export function pickMidIndex(midIndex, arrSize, showSize) { 13 | const showIndexs = [] 14 | 15 | for (let i = 0; i < showSize; i++) { 16 | // 往前推 17 | const prevIndex = midIndex - i 18 | if (prevIndex > 0 && (showIndexs.length < showSize)) { 19 | showIndexs.unshift(prevIndex) 20 | } 21 | 22 | // 往后推 23 | const nextIndex = midIndex + i 24 | if ((i !== 0) && (nextIndex <= arrSize) && (showIndexs.length < showSize)) { 25 | showIndexs.push(nextIndex) 26 | } 27 | } 28 | 29 | return showIndexs 30 | } 31 | 32 | /** 33 | * 取出数组中间的值 34 | * @param {Array} arr 数组 35 | * @param {Number} showSize 个数 36 | * @return {Array} 处理后的数组 37 | */ 38 | export function pickMidArr(arr, showSize) { 39 | const indexs = pickMidIndex(Math.floor((arr.length - 1) / 2), arr.length, showSize) 40 | return indexs.map(index => arr[index]) 41 | } 42 | 43 | /** 44 | * 取逼近最大值 45 | */ 46 | export function handlePlus(input, plus = 0) { 47 | return isNumber(input) 48 | ? ((input >= plus) ? input : plus) 49 | : plus 50 | } 51 | 52 | /** 53 | * 取逼近最小值 54 | */ 55 | export function handleMinus(input, mins = 0) { 56 | return isNumber(input) 57 | ? ((input <= mins) ? input : mins) 58 | : mins 59 | } 60 | 61 | function Pages({ 62 | search, currentPage, pageSize, totalSize, 63 | }) { 64 | this.search = isObject(search) ? search : {} 65 | this.currentPage = handlePlus(currentPage) 66 | this.pageSize = handlePlus(pageSize) 67 | this.totalPage = Math.ceil(handlePlus(totalSize) / pageSize) 68 | return this 69 | } 70 | 71 | Pages.prototype.gen = function gen(page) { 72 | return `?${qs.stringify({ ...this.search, page })}` 73 | } 74 | 75 | Pages.prototype.current = function current() { 76 | return this.gen(this.currentPage) 77 | } 78 | 79 | Pages.prototype.first = function first() { 80 | return this.gen(1) 81 | } 82 | 83 | Pages.prototype.last = function last() { 84 | return this.gen(this.totalPage) 85 | } 86 | 87 | Pages.prototype.prev = function prev() { 88 | return this.gen(handlePlus(this.currentPage - 1, 1)) 89 | } 90 | 91 | Pages.prototype.next = function next() { 92 | return this.gen(handleMinus(this.currentPage + 1, this.totalPage)) 93 | } 94 | 95 | export { Pages } 96 | -------------------------------------------------------------------------------- /front/src/components/Pagination/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Pagination as Page } from 'react-bootstrap' 4 | import { Pages, pickMidIndex } from './helpers' 5 | 6 | const { 7 | First, Prev, Item, Ellipsis, Next, Last, 8 | } = Page 9 | 10 | /** 11 | * 分页 12 | * @param {Object} options.search location.search 13 | * @param {Number} options.page 当前页 14 | * @param {Object} options.pageSize 分页数 15 | * @param {Number} options.total 总页数 16 | * @param {Number} options.showSize 显示按钮个数 17 | */ 18 | const Pagination = ({ 19 | search, page, pageSize, total, showSize, 20 | }) => { 21 | const pages = new Pages({ 22 | search, 23 | currentPage: page, 24 | pageSize, 25 | totalSize: total, 26 | }) 27 | 28 | const currentPage = pages.current() 29 | const firstPage = pages.first() 30 | const lastPage = pages.last() 31 | const prevPage = pages.prev() 32 | const nextPage = pages.next() 33 | const totalPage = pages.totalPage 34 | 35 | const midPages = pickMidIndex(page, totalPage, showSize) 36 | 37 | return ( 38 | 39 | 40 | 41 | {midPages[0] > 1 ? : null} 42 | {midPages.map((p, k) => {p})} 43 | {midPages[midPages.length - 1] < totalPage ? : null} 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | Pagination.defaultProps = { 51 | search: {}, 52 | page: 0, 53 | pageSize: 0, 54 | total: 0, 55 | showSize: 5, 56 | } 57 | 58 | Pagination.propTypes = { 59 | search: PropTypes.object, 60 | page: PropTypes.number, 61 | pageSize: PropTypes.number, 62 | total: PropTypes.number, 63 | showSize: PropTypes.number, 64 | } 65 | 66 | export default Pagination 67 | -------------------------------------------------------------------------------- /front/src/components/TextField.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | export const Input = styled.input`` 5 | 6 | export default function TextField({ 7 | input, 8 | meta: { 9 | touched, error, 10 | }, 11 | classes, 12 | ...props 13 | }) { 14 | const inError = !!error && !!touched 15 | return ( 16 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /front/src/containers/DetailPage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import defaultPage from 'hocs/defaultPage' 4 | import Header from 'components/Header' 5 | import { Container } from 'react-bootstrap' 6 | import request from 'helpers/request' 7 | import styled from 'styled-components' 8 | 9 | const Title = styled.h1` 10 | color: #262626; 11 | font-family: PingFangSC-Medium; 12 | font-size: 30px; 13 | letter-spacing: 0; 14 | line-height: 42px; 15 | text-align: justify; 16 | ` 17 | 18 | const Summary = styled.p` 19 | color: #787878; 20 | font-family: PingFangSC-Regular; 21 | font-size: 16px; 22 | letter-spacing: 0; 23 | line-height: 24px; 24 | 25 | &:after { 26 | border: 0.5px solid #e5e5e5; 27 | content: ''; 28 | display: block; 29 | margin: 20px 0; 30 | width: 58px; 31 | } 32 | ` 33 | 34 | class Detail extends React.Component { 35 | static async getInitialProps({ query }) { 36 | const content = await request('articleDetail', query).then(res => res.toJson()) 37 | return { 38 | content, 39 | } 40 | } 41 | 42 | static async getSettings({ content }) { 43 | return { 44 | title: content.title, 45 | } 46 | } 47 | 48 | render() { 49 | const { content: _ } = this.props 50 | return ( 51 |
52 |
53 | 54 | {_.title} 55 |
56 | {_.author} 57 | {_.createDate} 58 |
59 | {_.subTitle} 60 | 61 |
66 | 67 |
68 | ) 69 | } 70 | } 71 | 72 | Detail.propTypes = { 73 | content: PropTypes.object.isRequired, 74 | } 75 | 76 | export default defaultPage(Detail) 77 | -------------------------------------------------------------------------------- /front/src/containers/HomePage/Loadable.js: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | import LoadingIndicator from 'components/LoadingIndicator' 3 | 4 | export default dynamic({ 5 | loader: () => import('./index'), 6 | loading: LoadingIndicator, 7 | ssr: false, 8 | }) 9 | -------------------------------------------------------------------------------- /front/src/containers/HomePage/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import PropTypes from 'prop-types' 3 | import defaultPage from 'hocs/defaultPage' 4 | import Header from 'components/Header' 5 | import ListTitle from 'components/ListTitle' 6 | import ListItem from 'components/ListItem' 7 | import Pagination from 'components/Pagination' 8 | import request from 'helpers/request' 9 | import I18n from 'helpers/I18n' 10 | import { get } from 'lodash' 11 | import { Container, Row, Col } from 'react-bootstrap' 12 | 13 | import { connect } from 'react-redux' 14 | import { startClock } from 'store/actions' 15 | 16 | class Index extends React.Component { 17 | static async getInitialProps({ search }) { 18 | const { 19 | list, total, pageSize, page, 20 | } = await request('article', search).then(res => res.toPage()) 21 | 22 | return { 23 | list, 24 | pageInfo: { 25 | total, 26 | pageSize, 27 | page, 28 | }, 29 | } 30 | } 31 | 32 | static async getSettings() { 33 | return { 34 | title: '首页', 35 | } 36 | } 37 | 38 | componentDidMount() { 39 | this.props.dispatch(startClock()) 40 | } 41 | 42 | render() { 43 | const { list, search, pageInfo } = this.props 44 | 45 | return ( 46 | 47 |
48 | 49 | 50 |
51 | 52 | 53 | 54 | {list.map((_, key) => ( 55 | 66 | ))} 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | } 80 | 81 | Index.propTypes = { 82 | dispatch: PropTypes.func.isRequired, 83 | list: PropTypes.array.isRequired, 84 | } 85 | 86 | export default connect()(defaultPage(Index)) 87 | -------------------------------------------------------------------------------- /front/src/containers/NotFoundPage/Loadable.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Asynchronously loads the component for NotFoundPage 3 | */ 4 | import Loadable from 'react-loadable' 5 | 6 | import LoadingIndicator from 'components/LoadingIndicator' 7 | 8 | export default Loadable({ 9 | loader: () => import('./index'), 10 | loading: LoadingIndicator, 11 | }) 12 | -------------------------------------------------------------------------------- /front/src/containers/NotFoundPage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default () => ( 4 |
404
5 | ) 6 | -------------------------------------------------------------------------------- /front/src/helpers/Head.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import get from 'lodash/get' 3 | import PropTypes from 'prop-types' 4 | import NextHead from 'next/head' 5 | 6 | const SEO_DATA = require('./seo.json') 7 | 8 | export { SEO_DATA } 9 | 10 | /** 11 | * 替换动态值 12 | * @param {String} str template 13 | * @param {Object} values values 14 | * @return {String} template after repalce 15 | */ 16 | export function valuesReplace(str, values) { 17 | let output = str || '' 18 | Object.keys(values).forEach((key) => { 19 | const regx = new RegExp(`{${key}}`, 'g') 20 | output = output.replace(regx, values[key]) 21 | }) 22 | return output 23 | } 24 | 25 | class Head extends React.PureComponent { 26 | static contextTypes = { 27 | locale: PropTypes.string.isRequired, 28 | } 29 | 30 | render() { 31 | const { name, children, values } = this.props 32 | const { locale } = this.context 33 | 34 | const SEODATA = get(SEO_DATA, `${name}.${locale}`) || {} 35 | 36 | const { title, keywords, description } = SEODATA 37 | return ( 38 | 39 | {valuesReplace(title, values)} 40 | 41 | 42 | {children} 43 | 44 | ) 45 | } 46 | } 47 | 48 | Head.defaultProps = { 49 | values: {}, 50 | } 51 | 52 | Head.propTypes = { 53 | children: PropTypes.node, 54 | name: PropTypes.string, 55 | values: PropTypes.object, 56 | } 57 | 58 | export default Head 59 | -------------------------------------------------------------------------------- /front/src/helpers/I18n.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import I18n, { I18nProvider as Provider } from 'components/I18n' 3 | import { t } from 'components/I18n/helpers' 4 | 5 | const defaultTranslations = {} 6 | 7 | // 引入语言包 8 | require.context('./locales/', true, /\.json$/).keys().forEach((r) => { 9 | const parts = r.replace(/\//g, '').split('.') 10 | const language = parts[parts.length - 2] 11 | const trans = require(`./locales/${language}.json`) 12 | defaultTranslations[language] = trans 13 | }) 14 | 15 | export const defaultLocale = { 16 | locale: '', 17 | get value() { 18 | return this.locale 19 | }, 20 | set value(value) { 21 | this.locale = value 22 | }, 23 | } 24 | 25 | // export const defaultTranslations = { 26 | // zh: { 27 | // 'latest articles': '最新文章', 28 | // 'popular articles': '热门文章', 29 | // }, 30 | // en: { 31 | // 'latest articles': 'latest articles', 32 | // 'popular articles': 'popular articles', 33 | // }, 34 | // } 35 | 36 | export { defaultTranslations } 37 | 38 | // 所支持的语言 39 | export const languages = Object.keys(defaultTranslations) 40 | 41 | export function I18nProvider({ children, ...props }) { 42 | defaultLocale.value = props.locale 43 | return ( 44 | 45 | {children} 46 | 47 | ) 48 | } 49 | 50 | /** 51 | * trans the variable message 52 | * @param {Object} params {zh:'',en:'',...} or {id:''} 53 | * @param {String} locale 'zh' 54 | * @param {Object} translations can be null 55 | */ 56 | export function Trans(params, locale, translations) { 57 | return t( 58 | params, 59 | locale || defaultLocale.value, 60 | translations || defaultTranslations 61 | ) 62 | } 63 | 64 | export default I18n 65 | -------------------------------------------------------------------------------- /front/src/helpers/api.js: -------------------------------------------------------------------------------- 1 | export default { 2 | article: 'api/article', 3 | articleDetail: 'api/article/:_id', 4 | } 5 | -------------------------------------------------------------------------------- /front/src/helpers/auth.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get' 2 | import jsCookie from 'js-cookie' 3 | import { parse as cookieParse } from 'cookie' 4 | 5 | // 存储 key 值 6 | export const STORAGE_USER = 'STORAGE_USER' 7 | export const STORAGE_TOKEN = 'STORAGE_TOKEN' 8 | export const STORAGE_AUTHED = 'STORAGE_AUTHED' 9 | export const LOCALE_LOGOUT = 'LOCALE_LOGOUT' 10 | 11 | /** 12 | * try convert to JSON 13 | * @param {Any} input input 14 | * @return {Object} {} 15 | */ 16 | const toObject = (input) => { 17 | try { 18 | return JSON.parse(input) 19 | } catch (e) { 20 | return {} 21 | } 22 | } 23 | 24 | /** 25 | * 解析 cookie 26 | * @param {Object} ctx 27 | * @return {Object} 返回对象 28 | */ 29 | export function nextCookie(ctx) { 30 | const cookie = process.browser 31 | ? document.cookie 32 | : get(ctx, 'req.headers.cookie') 33 | return typeof cookie === 'string' ? cookieParse(cookie) : {} 34 | } 35 | 36 | /** 37 | * set token 38 | * @param {String} token token str 39 | */ 40 | export function setToken(token) { 41 | jsCookie.set(STORAGE_TOKEN, token, { expires: 1 }) 42 | } 43 | 44 | /** 45 | * get token 46 | * @param {Object} ctx 47 | * @return {String} token 48 | */ 49 | export function getToken(ctx) { 50 | const cookie = nextCookie(ctx) 51 | return cookie[STORAGE_TOKEN] || '' 52 | } 53 | 54 | /** 55 | * login actions 56 | * @param {Token} token token str 57 | */ 58 | export function login(token) { 59 | jsCookie.set(STORAGE_AUTHED, 'YES', { expires: 1 }) 60 | token && setToken(token) 61 | } 62 | 63 | /** 64 | * 判断是否登陆 65 | * @param {Object} ctx 66 | * @return {Boolean} in login ? 67 | */ 68 | export function loggedIn(ctx) { 69 | const cookie = nextCookie(ctx) 70 | return !!cookie[STORAGE_AUTHED] 71 | } 72 | 73 | /** 74 | * logout actions 75 | * @param {String} token token data 76 | */ 77 | export function logout(token) { 78 | jsCookie.remove(STORAGE_AUTHED) 79 | jsCookie.remove(STORAGE_USER) 80 | 81 | token && setToken(token) 82 | 83 | // to support logging out from all windows 84 | // window.localStorage.setItem(LOCALE_LOGOUT, Date.now()) 85 | window.dispatchEvent(new Event(LOCALE_LOGOUT)) 86 | } 87 | 88 | /** 89 | * 获取用户存储的数据 90 | * @param {Object} ctx 91 | * @return {Object} data 92 | */ 93 | export function getUserData(ctx) { 94 | const cookie = nextCookie(ctx) 95 | return toObject(cookie[STORAGE_USER]) 96 | } 97 | 98 | /** 99 | * 存储用户相关的数据 100 | * @param {String} key 101 | * @param {Any} data 102 | * @param {Object} ctx 103 | */ 104 | export function setUserData(key, data, ctx) { 105 | const storageUser = getUserData(ctx) 106 | storageUser[key] = data 107 | 108 | jsCookie.set(STORAGE_USER, JSON.stringify(storageUser), { expires: 1 }) 109 | } 110 | -------------------------------------------------------------------------------- /front/src/helpers/config.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'http://localhost:3030' 2 | const API_URL = `${BASE_URL}/api/` 3 | 4 | export default { 5 | BASE_URL, 6 | API_URL, 7 | getApiUrl(url) { 8 | return /[a-zA-z]+:\/\/[^/]*/.test(url) ? url : `${BASE_URL}/${url}` 9 | }, 10 | secret: 'hotchcms', // api password secret 11 | prefix: 'hotchcms', // storage key 12 | 13 | seo: { 14 | title: 'Hotchcms', 15 | keywords: 'hotchcms,react,next.js', 16 | description: 'A lightweight cms system .', 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /front/src/helpers/index.js: -------------------------------------------------------------------------------- 1 | import config from './config' 2 | 3 | export function getComponentDisplayName(WrappedComponent) { 4 | return WrappedComponent.displayName || WrappedComponent.name || 'Component' 5 | } 6 | 7 | export function getMediaUrl(path) { 8 | if (typeof path === 'string') { 9 | return path.indexOf('http') === 0 10 | ? path 11 | : `${config.BASE_URL}/upload/${path}` 12 | } 13 | return null 14 | } 15 | 16 | export { config } 17 | -------------------------------------------------------------------------------- /front/src/helpers/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "latest articles": "latest articles", 3 | "popular articles": "popular articles" 4 | } -------------------------------------------------------------------------------- /front/src/helpers/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "latest articles": "最新文章", 3 | "popular articles": "热门文章" 4 | } -------------------------------------------------------------------------------- /front/src/helpers/router.js: -------------------------------------------------------------------------------- 1 | import qs from 'qs' 2 | import Router from 'next/router' 3 | import { Href } from 'components/Link/helpers' 4 | import { defaultLocale } from './I18n' 5 | 6 | /** 7 | * 分解路径URL 8 | * @param {String} path 9 | * @return {Object} 10 | */ 11 | function decompose(path = '') { 12 | const parts = path.split('?') 13 | return { 14 | pathname: parts[0], 15 | search: qs.parse(parts[1] || ''), 16 | } 17 | } 18 | 19 | /** 20 | * 解析最终路由 21 | * @param {String} path 路径 22 | * @param {Boolean} tail 是否带上 from 23 | * @return {String} 24 | */ 25 | function handle(path, tail = false) { 26 | const currentPathname = window.location.pathname 27 | const { pathname, search } = decompose(path) 28 | const factory = new Href(defaultLocale.value, currentPathname) 29 | 30 | const params = search 31 | if (tail) { 32 | params.__from__ = currentPathname 33 | } 34 | 35 | return `${factory.prefix(pathname)}?${qs.stringify(params)}` 36 | } 37 | 38 | export default { 39 | push(path, tail) { 40 | Router.push(handle(path, tail)) 41 | }, 42 | replace(path) { 43 | Router.replace(handle(path)) 44 | }, 45 | back(outsite = false) { 46 | if (outsite) { 47 | window.history.back() 48 | } else { 49 | const { search } = decompose(window.location.href) 50 | const pathname = search.__from__ || '/' 51 | Router.replace(pathname) 52 | } 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /front/src/helpers/seo.json: -------------------------------------------------------------------------------- 1 | { 2 | "home": { 3 | "zh": { 4 | "title": "Hotchcms", 5 | "keywords": "cms", 6 | "description": "建站系统" 7 | }, 8 | "en": { 9 | "title": "Hotchcms", 10 | "keywords": "cms", 11 | "description": "cms system" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /front/src/helpers/valaditor.js: -------------------------------------------------------------------------------- 1 | export function getType(input) { 2 | return Object.prototype.toString.call(input).toLowerCase() 3 | } 4 | 5 | export function isJson(input) { 6 | return getType(input) === '[object object]' 7 | } 8 | 9 | export function isArray(input) { 10 | return Array.isArray(input) 11 | } 12 | 13 | export function isFunction(input) { 14 | return getType(input) === '[object function]' 15 | } 16 | 17 | export function isNumber(input) { 18 | return !Number.isNaN(Number(input)) 19 | } 20 | 21 | export function isNull(input) { 22 | return getType(input) === '[object null]' 23 | } 24 | 25 | export function isUndefined(input) { 26 | return typeof input === 'undefined' 27 | } 28 | 29 | export function isObjectWithKey(input) { 30 | return isJson(input) || isArray(input) || isFunction(input) 31 | } 32 | 33 | export function hasNoValue(input) { 34 | return isUndefined(input) || isNull(input) 35 | } 36 | -------------------------------------------------------------------------------- /front/src/hocs/defaultPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | import { getComponentDisplayName, config } from '../helpers' 4 | 5 | import { loadData, tickClock } from '../store/actions' 6 | 7 | const { seo } = config 8 | 9 | export default (Page) => { 10 | class defaultPage extends React.Component { 11 | static displayName = `Connect(${getComponentDisplayName})` 12 | 13 | static async getInitialProps(ctx) { 14 | let pageProps 15 | let settings 16 | 17 | if (Page.getInitialProps) { 18 | pageProps = await Page.getInitialProps(ctx) 19 | } 20 | 21 | if (Page.getSettings) { 22 | settings = await Page.getSettings(pageProps) 23 | } 24 | 25 | /** 26 | * redux startup 27 | */ 28 | const { store, isServer } = ctx 29 | 30 | store.dispatch(tickClock(isServer)) 31 | 32 | if (!store.getState().placeholderData) { 33 | store.dispatch(loadData()) 34 | } 35 | 36 | return { ...pageProps, settings: { ...settings } } 37 | } 38 | 39 | render() { 40 | const { settings, ...pageProps } = this.props 41 | const { 42 | title, keywords, description, append, 43 | } = settings 44 | 45 | return [ 46 | 47 | 48 | {`${title} - ${seo.title}`} 49 | 50 | 51 | 52 | {typeof append === 'function' ? append() : null} 53 | , 54 | , 55 | ] 56 | } 57 | } 58 | 59 | return defaultPage 60 | } 61 | -------------------------------------------------------------------------------- /front/src/hocs/securePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import defaultPage from './defaultPage' 4 | 5 | const securePageHoc = Page => class SecurePage extends React.Component { 6 | static propTypes = { 7 | inLogin: PropTypes.bool.isRequired, 8 | } 9 | 10 | static getInitialProps(ctx) { 11 | return Page.getInitialProps && Page.getInitialProps(ctx) 12 | } 13 | 14 | render() { 15 | const { inLogin } = this.props 16 | return inLogin ? :
404
17 | } 18 | } 19 | 20 | export default Page => defaultPage(securePageHoc(Page)) 21 | -------------------------------------------------------------------------------- /front/src/store/actions.js: -------------------------------------------------------------------------------- 1 | export const actionTypes = { 2 | FAILURE: 'FAILURE', 3 | INCREMENT: 'INCREMENT', 4 | DECREMENT: 'DECREMENT', 5 | RESET: 'RESET', 6 | LOAD_DATA: 'LOAD_DATA', 7 | LOAD_DATA_SUCCESS: 'LOAD_DATA_SUCCESS', 8 | START_CLOCK: 'START_CLOCK', 9 | TICK_CLOCK: 'TICK_CLOCK', 10 | } 11 | 12 | export function failure(error) { 13 | return { 14 | type: actionTypes.FAILURE, 15 | error, 16 | } 17 | } 18 | 19 | export function increment() { 20 | return { type: actionTypes.INCREMENT } 21 | } 22 | 23 | export function decrement() { 24 | return { type: actionTypes.DECREMENT } 25 | } 26 | 27 | export function reset() { 28 | return { type: actionTypes.RESET } 29 | } 30 | 31 | export function loadData() { 32 | return { type: actionTypes.LOAD_DATA } 33 | } 34 | 35 | export function loadDataSuccess(data) { 36 | return { 37 | type: actionTypes.LOAD_DATA_SUCCESS, 38 | data, 39 | } 40 | } 41 | 42 | export function startClock() { 43 | return { type: actionTypes.START_CLOCK } 44 | } 45 | 46 | export function tickClock(isServer) { 47 | return { 48 | type: actionTypes.TICK_CLOCK, 49 | light: !isServer, 50 | ts: Date.now(), 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /front/src/store/appRedux.js: -------------------------------------------------------------------------------- 1 | import { createReducer, createActions } from 'reduxsauce' 2 | import { fromJS } from 'immutable' 3 | 4 | /* ------------- Types and Action Creators ------------- */ 5 | 6 | const { Types, Creators } = createActions({ 7 | startup: null, 8 | 9 | logoutRequest: null, 10 | logoutSuccess: null, 11 | logoutFailure: null, 12 | 13 | queryUserRequest: null, 14 | queryUserSuccess: ['data'], 15 | queryUserFailure: ['error'], 16 | }) 17 | 18 | export { Types } 19 | 20 | export default Creators 21 | 22 | /* ------------- Initial State ------------- */ 23 | 24 | export const initialState = fromJS({ 25 | currentUser: {}, 26 | }) 27 | 28 | /* ------------- Reducers ------------- */ 29 | 30 | export const logoutSuccess = state => state.set('currentUser', {}).set('inLogin', false) 31 | 32 | /* ------------- Hookup Reducers To Types ------------- */ 33 | 34 | export const reducer = createReducer(initialState, { 35 | [Types.LOGOUT_SUCCESS]: logoutSuccess, 36 | }) 37 | -------------------------------------------------------------------------------- /front/src/store/appSaga.js: -------------------------------------------------------------------------------- 1 | /* ------------- Types ------------- */ 2 | 3 | // import Actions, { Types } from './appRedux' 4 | 5 | /** 6 | * Project start up task 7 | */ 8 | export function* startup() { 9 | // if (auth.loggedIn()) yield put(Actions.queryUserRequest()) 10 | } 11 | -------------------------------------------------------------------------------- /front/src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import createSagaMiddleware from 'redux-saga' 3 | import { fromJS } from 'immutable' 4 | import rootReducer from './rootReducer' 5 | import rootSaga from './rootSaga' 6 | 7 | const sagaMiddleware = createSagaMiddleware() 8 | 9 | const bindMiddleware = (middleware) => { 10 | if (process.env.NODE_ENV !== 'production') { 11 | const { composeWithDevTools } = require('redux-devtools-extension') 12 | return composeWithDevTools(applyMiddleware(...middleware)) 13 | } 14 | return applyMiddleware(...middleware) 15 | } 16 | 17 | function configureStore(initialState = {}) { 18 | const store = createStore( 19 | rootReducer, 20 | fromJS(initialState), 21 | bindMiddleware([sagaMiddleware]) 22 | ) 23 | 24 | store.runSagaTask = () => { 25 | store.sagaTask = sagaMiddleware.run(rootSaga) 26 | } 27 | 28 | store.runSagaTask() 29 | return store 30 | } 31 | 32 | export default configureStore 33 | -------------------------------------------------------------------------------- /front/src/store/rootReducer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Combine all reducers in this file and export the combined reducers. 3 | */ 4 | 5 | import { combineReducers } from 'redux-immutable' 6 | import { reducer as formReducer } from 'redux-form' 7 | // import { reducer as globalReducer } from 'containers/App/redux'; 8 | 9 | /** 10 | * Creates the main reducer with the dynamically injected ones 11 | */ 12 | export default combineReducers({ 13 | // global: globalReducer, 14 | form: formReducer, 15 | }) 16 | -------------------------------------------------------------------------------- /front/src/store/rootSaga.js: -------------------------------------------------------------------------------- 1 | /* global fetch */ 2 | 3 | import { delay } from 'redux-saga' 4 | import { 5 | all, call, take, 6 | } from 'redux-saga/effects' 7 | // import es6promise from 'es6-promise' 8 | import 'isomorphic-unfetch' 9 | 10 | import { actionTypes } from './actions' 11 | 12 | // es6promise.polyfill() 13 | 14 | function* runClockSaga() { 15 | yield take(actionTypes.START_CLOCK) 16 | while (true) { 17 | yield call(delay, 1000) 18 | } 19 | } 20 | 21 | // function* loadDataSaga() { 22 | // try { 23 | // const res = yield fetch('https://jsonplaceholder.typicode.com/users') 24 | // const data = yield res.json() 25 | // } catch (err) { 26 | // } 27 | // } 28 | 29 | function* rootSaga() { 30 | yield all([ 31 | call(runClockSaga), 32 | // takeLatest(actionTypes.LOAD_DATA, loadDataSaga), 33 | ]) 34 | } 35 | 36 | export default rootSaga 37 | -------------------------------------------------------------------------------- /front/static/global-styles.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: inherit; 3 | cursor: pointer; 4 | } 5 | 6 | input:-webkit-autofill { 7 | webkit-box-shadow: 0 0 0 30px #fff inset; 8 | } 9 | 10 | .text-center { 11 | text-align: center; 12 | } -------------------------------------------------------------------------------- /front/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/front/static/logo.png -------------------------------------------------------------------------------- /front/static/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: #29d; 8 | 9 | position: fixed; 10 | z-index: 1031; 11 | top: 0; 12 | left: 0; 13 | 14 | width: 100%; 15 | height: 2px; 16 | } 17 | 18 | /* Fancy blur effect */ 19 | #nprogress .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0px; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px #29d, 0 0 5px #29d; 26 | opacity: 1.0; 27 | 28 | -webkit-transform: rotate(3deg) translate(0px, -4px); 29 | -ms-transform: rotate(3deg) translate(0px, -4px); 30 | transform: rotate(3deg) translate(0px, -4px); 31 | } 32 | 33 | /* Remove these to get rid of the spinner */ 34 | #nprogress .spinner { 35 | display: block; 36 | position: fixed; 37 | z-index: 1031; 38 | top: 15px; 39 | right: 15px; 40 | } 41 | 42 | #nprogress .spinner-icon { 43 | width: 18px; 44 | height: 18px; 45 | box-sizing: border-box; 46 | 47 | border: solid 2px transparent; 48 | border-top-color: #29d; 49 | border-left-color: #29d; 50 | border-radius: 50%; 51 | 52 | -webkit-animation: nprogress-spinner 400ms linear infinite; 53 | animation: nprogress-spinner 400ms linear infinite; 54 | } 55 | 56 | .nprogress-custom-parent { 57 | overflow: hidden; 58 | position: relative; 59 | } 60 | 61 | .nprogress-custom-parent #nprogress .spinner, 62 | .nprogress-custom-parent #nprogress .bar { 63 | position: absolute; 64 | } 65 | 66 | @-webkit-keyframes nprogress-spinner { 67 | 0% { -webkit-transform: rotate(0deg); } 68 | 100% { -webkit-transform: rotate(360deg); } 69 | } 70 | @keyframes nprogress-spinner { 71 | 0% { transform: rotate(0deg); } 72 | 100% { transform: rotate(360deg); } 73 | } 74 | -------------------------------------------------------------------------------- /front/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "backstage", 4 | "front", 5 | "server" 6 | ], 7 | "version": "0.0.0" 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "devDependencies": { 5 | "husky": "^1.3.0", 6 | "lerna": "^3.8.0" 7 | }, 8 | "husky": { 9 | "hooks": { 10 | "pre-commit": "lerna run --concurrency 1 --stream lint-staged" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /server/.eslintignore: -------------------------------------------------------------------------------- 1 | tests 2 | -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "es6": true, 5 | "browser": true 6 | }, 7 | "rules": { 8 | "semi": ["warn", "never"], 9 | "consistent-return": 0, 10 | "func-names": 0, 11 | "no-underscore-dangle": 0, 12 | "no-unused-expressions": 0 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .idea 4 | Thumbs.db 5 | npm-debug.log* 6 | !.gitkeep 7 | node_modules 8 | logs/* 9 | !logs/*.gitkeep 10 | static/theme 11 | !static/theme/*.gitkeep 12 | static/upload 13 | !static/upload/*.gitkeep 14 | tmp/* 15 | !tmp/*.gitkeep 16 | install.lock 17 | dump.rdb 18 | ./../.DS_Store -------------------------------------------------------------------------------- /server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Node, Mongo, Redis, Nginx 2 | 3 | version: "3.3" 4 | 5 | services: 6 | api: 7 | image: node:latest 8 | depends_on: 9 | - mongodb 10 | - redis 11 | volumes: 12 | - ./:/home/node/api/ 13 | working_dir: /home/node/api 14 | command: npm start 15 | networks: 16 | - backend 17 | logging: 18 | driver: "json-file" 19 | options: 20 | max-size: "100MB" 21 | max-file: "3" 22 | 23 | mongodb: 24 | image: mongo:latest 25 | volumes: 26 | - mongodb:/data/db/ 27 | networks: 28 | - backend 29 | logging: 30 | driver: "json-file" 31 | options: 32 | max-size: "100MB" 33 | max-file: "3" 34 | 35 | redis: 36 | image: redis:latest 37 | networks: 38 | - backend 39 | volumes: 40 | - redis:/data/redis/ 41 | logging: 42 | driver: "json-file" 43 | options: 44 | max-size: "100MB" 45 | max-file: "3" 46 | 47 | nginx: 48 | image: nginx:stable-alpine 49 | depends_on: 50 | - api 51 | networks: 52 | - backend 53 | volumes: 54 | - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro 55 | ports: 56 | - "80:80" 57 | - "443:443" 58 | logging: 59 | driver: "json-file" 60 | options: 61 | max-size: "100MB" 62 | max-file: "3" 63 | 64 | networks: 65 | backend: 66 | 67 | volumes: 68 | mongodb: 69 | redis: -------------------------------------------------------------------------------- /server/logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/server/logs/.gitkeep -------------------------------------------------------------------------------- /server/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream backend { 2 | server api:3030; 3 | } 4 | 5 | server { 6 | listen 3030 default_server; 7 | 8 | location / { 9 | proxy_pass http://backend; 10 | proxy_http_version 1.1; 11 | proxy_set_header Upgrade $http_upgrade; 12 | proxy_set_header Connection 'upgrade'; 13 | proxy_set_header Host $host; 14 | proxy_cache_bypass $http_upgrade; 15 | } 16 | } -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [ 4 | ".git", 5 | ".svn", 6 | "node_modules/**/node_modules", 7 | "static", 8 | "temp", 9 | "logs" 10 | ], 11 | "verbose": true, 12 | "execMap": { 13 | "js": "node --harmony" 14 | }, 15 | "watch": [ 16 | 17 | ], 18 | "env": { 19 | "NODE_ENV": "development" 20 | }, 21 | "ext": "js json" 22 | } -------------------------------------------------------------------------------- /server/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1 3 | } 4 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hotchcms-server", 3 | "version": "1.0.0", 4 | "directories": { 5 | "lib": "lib" 6 | }, 7 | "scripts": { 8 | "dev": "nodemon --watch src -e ts,tsx --exec ts-node src/app.ts", 9 | "build": "tsc", 10 | "start": "cross-env NODE_ENV=production node ./dist/app", 11 | "init": "node ./src/bin/install", 12 | "test": "./node_modules/.bin/mocha --harmony", 13 | "lint": "eslint --ext .js src --fix", 14 | "lint-staged": "lint-staged" 15 | }, 16 | "author": "luckcoding", 17 | "license": "ISC", 18 | "lint-staged": { 19 | "src/**/*.js": [ 20 | "npm run lint", 21 | "git add" 22 | ] 23 | }, 24 | "dependencies": { 25 | "@koa-lite/controller": "^0.4.1", 26 | "@koa/cors": "^3.1.0", 27 | "@typegoose/typegoose": "^7.6.0", 28 | "adm-zip": "^0.5.5", 29 | "colors": "^1.4.0", 30 | "cross-env": "^7.0.3", 31 | "jsonwebtoken": "^8.5.1", 32 | "kcors": "^2.2.2", 33 | "koa": "^2.13.1", 34 | "koa-body": "^4.2.0", 35 | "koa-jwt": "^4.0.1", 36 | "koa-middle-validator": "^1.2.0", 37 | "lodash.flatten": "^4.4.0", 38 | "moment": "^2.29.1", 39 | "mongoose": "^5.12.3", 40 | "mysql2": "^2.2.5", 41 | "nodemailer": "^6.5.0", 42 | "qcloudsms_js": "^0.1.1", 43 | "ramda": "^0.27.1", 44 | "redis": "^3.1.0", 45 | "reflect-metadata": "^0.1.13", 46 | "request": "^2.88.2", 47 | "shortid": "^2.2.16", 48 | "tracer": "^1.1.4", 49 | "typeorm": "^0.2.32", 50 | "validator": "^13.5.2" 51 | }, 52 | "devDependencies": { 53 | "@types/jsonwebtoken": "^8.5.1", 54 | "@types/koa": "^2.13.1", 55 | "@types/koa__cors": "^3.0.2", 56 | "@types/mongoose": "^5.10.4", 57 | "@types/nodemailer": "^6.4.1", 58 | "@types/ramda": "^0.27.39", 59 | "@types/redis": "^2.8.28", 60 | "@types/request": "^2.48.5", 61 | "@types/shortid": "^0.0.29", 62 | "@types/validator": "^13.1.3", 63 | "chai": "^4.3.4", 64 | "eslint": "^7.23.0", 65 | "eslint-config-airbnb-base": "^14.2.1", 66 | "eslint-plugin-import": "^2.22.1", 67 | "eslint-watch": "^7.0.0", 68 | "lint-staged": "^10.5.4", 69 | "mocha": "^8.3.2", 70 | "nodemon": "^2.0.7", 71 | "shelljs": "^0.8.4", 72 | "supertest": "^6.1.3", 73 | "ts-node": "^9.1.1", 74 | "typescript": "^4.2.4" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/src/app.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import Koa from 'koa' 3 | import http from 'http' 4 | import cors from '@koa/cors' 5 | import koaBody from 'koa-body' 6 | import koaJwt from 'koa-jwt' 7 | import pipe from './middlewares/pipe' 8 | import { logger } from './utils' 9 | import { SYSTEM, JWT } from './config' 10 | import { router } from './router' 11 | import './db/mongodb' 12 | 13 | const app = new Koa() 14 | 15 | // 跨域 16 | app.use(cors()) 17 | 18 | // 请求解析 19 | app.use(koaBody()) 20 | 21 | // jwt 22 | app.use(koaJwt({ 23 | secret: JWT.secret, 24 | passthrough: true 25 | }).unless({ 26 | path: [/^\/docs/] 27 | })) 28 | 29 | app.use(pipe()) // 通讯 30 | 31 | // 路由 32 | app.use(router.routes()) 33 | 34 | // 404 35 | app.use(ctx => { 36 | ctx.status = 404 37 | ctx.body = '请求的API地址不正确或者不存在' 38 | }) 39 | 40 | // 监听错误 41 | app.on('error', (err, ctx) => { 42 | logger.error('服务错误: ', err, ctx) 43 | }) 44 | 45 | /** 46 | * 创建服务 47 | */ 48 | const server = http.createServer(app.callback()); 49 | 50 | server.on('error', (error) => { 51 | logger.error('启动失败: ', error) 52 | }) 53 | 54 | server.on('listening', () => { 55 | logger.info(`服务启动, 端口: ${SYSTEM.port}`) 56 | }) 57 | 58 | if (!module.parent) { 59 | server.listen(SYSTEM.port) 60 | } 61 | -------------------------------------------------------------------------------- /server/src/config/index.ts: -------------------------------------------------------------------------------- 1 | // mongodb 2 | export const DB_PATH = 'mongodb://localhost:27017/hotchcms' 3 | 4 | /** 5 | * mysql 6 | */ 7 | export const MYSQL = { 8 | host: 'localhost', 9 | port: 3306, 10 | username: 'root', 11 | password: '123456..', 12 | database: 'hotchcms', 13 | } 14 | 15 | export const REDIS = { 16 | host: 'localhost', 17 | port: 6379, 18 | db: '4', 19 | family: 'IPv4', 20 | password: undefined, 21 | } 22 | 23 | /** 24 | * 系统配置信息 25 | */ 26 | export const SYSTEM = { 27 | port: 3030, // listen 28 | threshold: 300, // 请求耗时超过多少写入logger 29 | secret: 'hotchcms', // 加密盐值 30 | } 31 | 32 | /** 33 | * jwt 34 | */ 35 | export const JWT = { 36 | secret: 'hotchcms', 37 | expiresIn: 24 * 60 * 60 * 30, // token 失效时间一个月 38 | } 39 | 40 | /** 41 | * 阿里云oss 42 | */ 43 | export const OSS = { 44 | id: '', 45 | secret: '', 46 | host: '', 47 | dir: '', 48 | } 49 | 50 | /** 51 | * 腾讯企业邮箱 52 | */ 53 | export const EMAIL = { 54 | host: '', 55 | port: 465, 56 | user: '', 57 | pass: '', 58 | expiresIn: 5, // 5分钟 59 | } 60 | 61 | /** 62 | * 微信 63 | */ 64 | export const WECHAT = { 65 | // 小程序信息 66 | APPLETS: { 67 | url: 'https://api.weixin.qq.com/sns/jscode2session', 68 | appid: '', 69 | secret: '', 70 | } 71 | } 72 | 73 | /** 74 | * 腾讯云短信 75 | */ 76 | export const SMS = { 77 | appid: 1, 78 | appkey: 'appkey', 79 | templateId: 1, 80 | sign: 'sign', 81 | expiresIn: 60 * 5 // 5分钟 82 | } 83 | 84 | /** 85 | * 腾讯lbs 86 | */ 87 | export const LBS = { 88 | url: 'http://apis.map.qq.com', 89 | key: '', 90 | secret: '', 91 | } 92 | -------------------------------------------------------------------------------- /server/src/constants.ts: -------------------------------------------------------------------------------- 1 | import { values } from 'ramda' 2 | 3 | // 文章状态 4 | export enum ArticleStatusType { 5 | draft = 'draft', 6 | published = 'published', 7 | offlined = 'offlined', 8 | recycled = 'recycled' 9 | } 10 | export const ArticleStatusTypes = values(ArticleStatusType) 11 | 12 | // 文章来源 13 | export enum ArticleFromType { 14 | third = 'third', // 转载 15 | official = 'official', // 编辑(adminUser) 16 | user = 'user', // 用户(User) 17 | } 18 | export const ArticleFromTypes = values(ArticleFromType) 19 | 20 | // 验证码类型 21 | export enum CodeType { 22 | register = 'register', 23 | login = 'login', 24 | } 25 | export const CodeTypes = values(CodeType) 26 | 27 | // 权限类型 28 | export enum RoleType { 29 | root = 'root', // 超级管理员 30 | operator = 'operator', // 普通管理员 31 | user = 'user', // 普通用户 32 | } 33 | export const RoleTypes = values(RoleType) 34 | 35 | /** 36 | * ======================================== 37 | * code信息 38 | */ 39 | export const MsgCode = { 40 | SUCCESS: { code: 0, msg: '成功' }, 41 | TOKEN_FAIL: { code: 1001, msg: '登录信息已过期' }, 42 | WRONG_AUTHED: { code: 1002, msg: '用户名或密码错误' }, 43 | NOT_EXIST: { code: 2000, msg: '不存在' }, 44 | DUPLICATE: { code: 2001, msg: '重复' }, 45 | DENIED: { code: 2002, msg: '请求被拒绝' }, 46 | VALIDATE_FAIL: { code: 3000, msg: '参数错误' }, 47 | INCORRECT: { code: 3001, msg: '内容有误' }, 48 | OFTEN: { code: 4000, msg: '操作过于频繁' }, 49 | } 50 | 51 | export type MsgCodeType = keyof typeof MsgCode 52 | -------------------------------------------------------------------------------- /server/src/controllers/common.controller.ts: -------------------------------------------------------------------------------- 1 | import { BaseContext } from 'koa' 2 | import { isUser } from '../middlewares/auth' 3 | import { Summary, Tag, request, Middle, check } from '@koa-lite/controller' 4 | import { Joi, joiSchema, Lbs, Oss, random, sms } from '../utils' 5 | 6 | @Tag('通用模块') 7 | export class Common { 8 | @request.get('/sms') 9 | @check.query(Joi.object({ 10 | phone: joiSchema.phone, 11 | type: joiSchema.codeType, 12 | })) 13 | @Summary('发送短信') 14 | async sms(ctx: BaseContext) { 15 | 16 | const { phone, type } = await ctx.payload() 17 | const code = random(4) 18 | await sms.sendCode({ phone, code, type }) 19 | 20 | ctx.done({ phone, code, type }) 21 | } 22 | 23 | @request.get('/oss') 24 | @Summary('获取阿里云通证') 25 | @Middle([ isUser('user') ]) 26 | async oss(ctx: BaseContext) { 27 | ctx.done(Oss.getSignInfo()) 28 | } 29 | 30 | @request.get('/geocoder') 31 | @Summary('逆地址解析') 32 | @check.query(Joi.object({ 33 | lat: Joi.string().required().description('经度'), 34 | lng: Joi.string().required().description('经度'), 35 | })) 36 | async geocoder(ctx: BaseContext) { 37 | const { lat, lng } = await ctx.payload() 38 | ctx.done(await Lbs.geocoder(lat, lng)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/src/controllers/dashboard.controller.ts: -------------------------------------------------------------------------------- 1 | import { BaseContext } from 'koa' 2 | import path from 'path' 3 | import { Summary, Tag, request, Prefix } from '@koa-lite/controller' 4 | import { readFile } from '../utils' 5 | 6 | @Tag('首页') 7 | @Prefix('/dashboard') 8 | export class Dashboard { 9 | @request.get('/') 10 | @Summary('首页数据') 11 | async sms(ctx: BaseContext) { 12 | const dataStr = await readFile(path.join(__dirname, '../assets/dashboard.json')) 13 | ctx.done(JSON.parse(dataStr.toString())) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article.controller' 2 | export * from './category.controller' 3 | export * from './common.controller' 4 | export * from './dashboard.controller' 5 | -------------------------------------------------------------------------------- /server/src/db/mongodb.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { DB_PATH } from '../config' 3 | import { logger } from '../utils' 4 | 5 | mongoose.set('useCreateIndex', true) 6 | mongoose.connect(DB_PATH, { 7 | useNewUrlParser: true, 8 | useUnifiedTopology: true, 9 | }) 10 | 11 | const db = mongoose.connection 12 | 13 | db.on('error', err => { 14 | logger.error(`MongoDB connection error: ${err}!`) 15 | process.exit(1) 16 | }) 17 | -------------------------------------------------------------------------------- /server/src/db/redis.ts: -------------------------------------------------------------------------------- 1 | import redis from 'redis' 2 | import { promisify } from 'util' 3 | import { REDIS } from '../config' 4 | import { logger } from '../utils' 5 | 6 | const client = redis.createClient(REDIS) 7 | 8 | client.on('error', err => { 9 | logger.error(`Redis connection error: ${err}!`) 10 | process.exit(1) 11 | }) 12 | 13 | export const set = promisify(client.set).bind(client) 14 | export const get = promisify(client.get).bind(client) 15 | export const del = promisify(client.del).bind(client) 16 | -------------------------------------------------------------------------------- /server/src/db/typeorm.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { BaseContext } from 'koa' 3 | import { createConnection, getConnection, EntityManager } from 'typeorm' 4 | import { MYSQL } from '../config' 5 | import { logger } from '../utils'; 6 | 7 | declare module 'koa' { 8 | export interface BaseContext { 9 | manager: EntityManager, 10 | } 11 | } 12 | 13 | interface Transfer { 14 | manager: EntityManager 15 | } 16 | 17 | 18 | (async function () { 19 | try { 20 | await createConnection({ 21 | type: 'mysql', 22 | ...MYSQL, 23 | extra: { 'insecureAuth': true }, 24 | entities: [ 25 | join(__dirname, '..', '**', '*.entity{.ts,.js}'), 26 | ], 27 | synchronize: true 28 | }) 29 | } catch (e) { 30 | logger.error(e) 31 | process.exit(1) 32 | } 33 | })(); 34 | 35 | // 实例中转 36 | export const Transfer: Transfer = { 37 | manager: null, 38 | } 39 | 40 | // 事务容器 41 | export const transaction = (fn: Function) => async (ctx: BaseContext, ...args) => { 42 | await getConnection().transaction(async manager => { 43 | Transfer.manager = manager // hack 44 | await fn(Object.assign(ctx, { manager }), ...args) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /server/src/entities/abstract.entity.ts: -------------------------------------------------------------------------------- 1 | import * as shortid from "shortid"; 2 | import { 3 | BeforeInsert, 4 | Column, 5 | CreateDateColumn, 6 | PrimaryColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm' 9 | 10 | export abstract class AbstractEntity { 11 | @PrimaryColumn({ type: 'varchar', length: 10 }) 12 | id: string 13 | 14 | @BeforeInsert() 15 | setId() { 16 | this.id = shortid.generate() 17 | } 18 | 19 | /** 创建时间 **/ 20 | @CreateDateColumn({ 21 | type: 'timestamp', 22 | name: 'created_at', 23 | comment: '创建时间', 24 | }) 25 | createdAt: Date 26 | 27 | /** 更新时间 **/ 28 | @UpdateDateColumn({ 29 | type: 'timestamp', 30 | name: 'updated_at', 31 | comment: '更新时间', 32 | }) 33 | updatedAt: Date 34 | 35 | /** 删除时间 **/ 36 | @Column({ 37 | type: 'timestamp', 38 | name: 'deleted_at', 39 | comment: '删除时间', 40 | default: null, 41 | }) 42 | deletedAt?: Date 43 | 44 | /** 是否删除 **/ 45 | @Column({ 46 | type: 'boolean', 47 | name: 'is_deleted', 48 | default: false, 49 | comment: '是否删除', 50 | }) 51 | isDeleted: boolean 52 | 53 | /** 版本 **/ 54 | @Column({ 55 | type: 'int', 56 | default: 1, 57 | comment: '版本', 58 | }) 59 | version: number 60 | } 61 | -------------------------------------------------------------------------------- /server/src/entities/article.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToMany, ManyToOne } from 'typeorm' 2 | import { AbstractEntity } from './abstract.entity' 3 | import { CategoryEntity } from './category.entity'; 4 | import { UserEntity } from './user.entity'; 5 | import { ArticleFromType, ArticleFromTypes, ArticleStatusType, ArticleStatusTypes } from '../constants'; 6 | 7 | /** 8 | * 文章内容 9 | */ 10 | @Entity({ name: 'article' }) 11 | export class ArticleEntity extends AbstractEntity { 12 | @Column({ type: 'varchar', length: 255, comment: '标题', default: null }) 13 | title?: string // 标题 14 | 15 | @Column('varchar', { length: 255, comment: '概述', default: null }) 16 | overview?: string // 概述 17 | 18 | // 文章分类 19 | @ManyToOne(type => CategoryEntity, category => category.articles) 20 | category: CategoryEntity; 21 | 22 | @Column('varchar', { length: 255, comment: '封面', default: null }) 23 | cover?: string // 封面 24 | 25 | @Column({ type: 'simple-array', default: null, comment: '标签' }) 26 | tags: string[] // 标签 27 | 28 | @Column({ type: 'text', default: null, comment: '内容' }) 29 | content?: string 30 | 31 | // @Column({ type: 'tinyint', default: 0, comment: '来源类型 0: 转载, 1: 编辑(adminUser), 2: 用户(User)' }) 32 | @Column({ type: 'enum', enum: ArticleFromType, default: ArticleFromType.official, comment: ArticleFromTypes.join(',') }) 33 | from: ArticleFromType 34 | 35 | // 作者 36 | @ManyToOne(type => UserEntity, user => user.articles) 37 | author?: UserEntity; 38 | 39 | @Column('varchar', { length: 50, comment: '原作者', default: null }) 40 | originalAuthor?: string 41 | 42 | @Column('varchar', { length: 255, comment: '转载地址', default: null }) 43 | originalUrl?: string 44 | 45 | @Column({ type: 'boolean', default: false, comment: '是否置顶' }) 46 | isTop: boolean 47 | 48 | @Column('int', { name: 'view_num', default: 0, comment: '浏览数量' }) 49 | viewNum: number 50 | 51 | // 喜欢该文章的用户ID集合 52 | @ManyToMany(type => UserEntity, user => user.articleLikes) 53 | liked?: UserEntity; 54 | 55 | // 0:草稿 1:上线 2:下线 9:回收站 56 | // @Column({ type: 'tinyint', default: 0, comment: '0:草稿 1:上线 2:下线 9:回收站' }) 57 | @Column({ type: 'enum', enum: ArticleStatusType, default: ArticleStatusType.draft, comment: ArticleStatusTypes.join(',') }) 58 | status: ArticleStatusType 59 | } 60 | -------------------------------------------------------------------------------- /server/src/entities/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | OneToMany, 5 | } from 'typeorm' 6 | import { AbstractEntity } from './abstract.entity' 7 | import { ArticleEntity } from './article.entity' 8 | 9 | @Entity('category') 10 | export class CategoryEntity extends AbstractEntity { 11 | @Column({ type: 'varchar', length: 30, unique: true, comment: '分类名称' }) 12 | name: string 13 | 14 | @Column({ type: 'tinyint', default: 0, comment: '排序' }) 15 | sort: number 16 | 17 | // 分类关联 18 | @OneToMany(type => ArticleEntity, article => article.category) 19 | articles: ArticleEntity[]; 20 | 21 | // 目录 22 | // @prop({ required: true, unique: true, lowercase: true, match: /^[A-z]+$/, minlength: 4 }) 23 | @Column({ type: 'varchar', length: 30, unique: true, comment: '分类名称' }) 24 | path!: string 25 | 26 | @Column({ type: 'boolean', default: false, comment: '是否在导航中显示' }) 27 | display: boolean 28 | 29 | @Column({ type: 'simple-array', default: null, comment: '关键字' }) 30 | keywords: string[] 31 | 32 | @Column('varchar', { length: 255, comment: '描述', default: null }) 33 | description: string 34 | } 35 | -------------------------------------------------------------------------------- /server/src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article.entity' 2 | export * from './category.entity' 3 | export * from './user.entity' 4 | -------------------------------------------------------------------------------- /server/src/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | BeforeInsert, 5 | BeforeUpdate, 6 | OneToMany, 7 | ManyToMany, 8 | } from 'typeorm' 9 | import { sha1 } from '../utils/crypto' 10 | import { RoleType } from '../constants' 11 | import { AbstractEntity } from './abstract.entity' 12 | import { ArticleEntity } from './article.entity' 13 | 14 | @Entity('user') 15 | export class UserEntity extends AbstractEntity { 16 | @Column({ type: 'char', length: 11, unique: true, comment: '手机' }) 17 | phone?: string 18 | 19 | @Column({ type: 'varchar', length: 40, /*select: false,*/ comment: '密码' }) 20 | password: string 21 | 22 | @Column({ type: 'varchar', length: 30, unique: true, default: null, comment: '邮箱' }) 23 | email?: string 24 | 25 | @Column({ type: 'varchar', length: 20, default: null, comment: '昵称' }) 26 | nickname?: string 27 | 28 | @BeforeInsert() 29 | @BeforeUpdate() 30 | async sha1Password() { 31 | this.password && (this.password = sha1(this.password)) 32 | } 33 | 34 | // 文章 35 | @OneToMany(type => ArticleEntity, article => article.author) 36 | articles: ArticleEntity[]; 37 | 38 | // 文章喜欢合集 39 | @ManyToMany(type => ArticleEntity, article => article.liked) 40 | articleLikes: ArticleEntity[]; 41 | 42 | @Column({ type: 'enum', enum: RoleType, default: RoleType.user, comment: 'root|operator|user' }) 43 | role: RoleType 44 | } 45 | -------------------------------------------------------------------------------- /server/src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import { getManager } from 'typeorm' 3 | import { UserEntity } from '../entities' 4 | import { SBError } from '../utils' 5 | 6 | declare module 'koa' { 7 | export interface BaseContext { 8 | user?: UserEntity, 9 | } 10 | } 11 | 12 | const level = ['root', 'operator', 'user'] 13 | type role = 'root' | 'operator' | 'user' 14 | 15 | export const isUser = (role: role = 'user') => async (ctx: Koa.Context, next: () => Promise) => { 16 | const { user } = ctx.state 17 | 18 | if (!user) { 19 | throw new SBError('TOKEN_FAIL') 20 | } 21 | 22 | ctx.user = await getManager().findOne(UserEntity, user.id) 23 | 24 | if (!ctx.user) { 25 | throw new SBError('NOT_EXIST') 26 | } 27 | 28 | if (level.indexOf(role) < level.indexOf(ctx.user.role || 'user')) { 29 | throw new SBError('DENIED') 30 | } 31 | 32 | await next() 33 | } 34 | 35 | export const isAdmin = (ctx: Koa.Context, next: () => Promise) => { 36 | const { user } = ctx.state 37 | 38 | if (!user) { 39 | throw new SBError('TOKEN_FAIL') 40 | } 41 | 42 | return next() 43 | } 44 | -------------------------------------------------------------------------------- /server/src/middlewares/pipe.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import { logger, SBError } from '../utils' 3 | import { SYSTEM } from '../config' 4 | import { MsgCode, MsgCodeType } from '../constants' 5 | import { CheckError } from '@koa-lite/controller' 6 | 7 | declare module 'koa' { 8 | export interface BaseContext { 9 | done: (ret?: any) => void 10 | fail: (code: MsgCodeType, msg?: string) => void 11 | } 12 | } 13 | 14 | const pipe = () => async (ctx: Koa.Context, next:() => Promise) => { 15 | try { 16 | // 通讯超时处理 17 | ctx._effect = { 18 | start: Date.now(), 19 | end() { 20 | const ms = Date.now() - this.start 21 | const level = ms > SYSTEM.threshold ? 'warn' : 'log' 22 | logger[level](`${ctx.method} ${ctx.url} - ${ms}ms`) 23 | } 24 | } 25 | 26 | // 返回 27 | ctx.done = ret => ctx.body = { code: MsgCode.SUCCESS.code, ret } 28 | 29 | await next() 30 | } catch (err) { 31 | if (err instanceof SBError) { 32 | ctx.body = err.getError() 33 | } else if (err instanceof CheckError) { 34 | ctx.body = { 35 | code: MsgCode.VALIDATE_FAIL.code, 36 | msg: err.error.details[0].message 37 | } 38 | } else { 39 | ctx.status = err.status || 500 40 | ctx.body = err.message 41 | if (ctx.status === 500) { 42 | // 只记录服务器错误 43 | ctx.app.emit('error', err, ctx) 44 | } 45 | } 46 | } finally { 47 | ctx._effect.end() 48 | } 49 | } 50 | 51 | export default pipe 52 | -------------------------------------------------------------------------------- /server/src/router.ts: -------------------------------------------------------------------------------- 1 | import { Controller, InjectHandle } from '@koa-lite/controller' 2 | import * as controllers from './controllers' 3 | import { transaction } from './db/typeorm' 4 | 5 | /** 6 | * 事务处理 7 | */ 8 | InjectHandle(({ fn }) => transaction(fn)) 9 | 10 | export const router = new Controller({ 11 | prefix: '/v2', 12 | docs: { 13 | title: '接口文档 - 客户端', 14 | version: 'v2', 15 | description: '业务接口文档 - by Hotchcms', 16 | securities: [{ 17 | type: 'headers', 18 | key: 'Authorization', 19 | value: function (data) { 20 | var ret = (data instanceof Object) && data.ret 21 | return /^Bearer\s/.test(ret) ? ret : '' 22 | }, 23 | }, { 24 | type: 'headers', 25 | key: 'x-device-id', 26 | value: '1234', 27 | }], 28 | }, 29 | }) 30 | .controllers([ 31 | controllers.Article, 32 | controllers.Common, 33 | controllers.Dashboard, 34 | ]) 35 | .get('/', ctx => { ctx.body = 'Hotchcms' }) 36 | -------------------------------------------------------------------------------- /server/src/routes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '/backstage-api': { 3 | '/install': { 4 | get: 'install.status', // 安装状态 5 | post: 'install.install', // 安装 6 | 7 | '/test-database': { 8 | put: 'install.testDatabase', // mongodb 连接测试 9 | }, 10 | 11 | '/test-redis': { 12 | put: 'install.testRedis', // redis 连接测试 13 | }, 14 | }, 15 | 16 | '/admin-account': { 17 | get: '@admin-account.current#【当前账号】查询', 18 | put: '@admin-account.update#【当前账号】更新', 19 | 20 | '/sign-in': { 21 | put: 'admin-account.signIn', // 登录 22 | }, 23 | 24 | '/sign-out': { 25 | put: '@admin-account.signOut#退出', 26 | }, 27 | }, 28 | 29 | '/admin-group': { 30 | post: '*admin-group.create#【管理组】新增', 31 | get: '*admin-group.list#【管理组】查询', 32 | 33 | '/operated': { 34 | get: '*admin-group.operated#【管理组】可操作列表', 35 | }, 36 | 37 | '/all': { 38 | get: '*admin-group.all#【管理组】所有列表', 39 | }, 40 | 41 | '/:_id': { 42 | put: '*admin-group.update#【管理组】更新', 43 | get: '*admin-group.one#【管理组】列表', 44 | delete: '*admin-group.delete#【管理组】删除', 45 | }, 46 | }, 47 | 48 | '/admin-user': { 49 | post: '*admin-user.create#【管理员】新增', 50 | get: '*admin-user.list#【管理员】列表', 51 | 52 | '/:_id': { 53 | put: '*admin-user.update#【管理员】更新', 54 | get: '*admin-user.one#【管理员】查询', 55 | delete: '*admin-user.delete#【管理员】删除', 56 | }, 57 | }, 58 | 59 | '/category': { 60 | post: '*category.create#【分类】新增', 61 | get: '*category.list#【分类】查询', 62 | 63 | '/multi': { 64 | post: '*category.multi#【分类】多条操作', 65 | }, 66 | 67 | '/:_id': { 68 | put: '*category.update#【分类】更新', 69 | get: '*category.one#【分类】列表', 70 | delete: '*category.delete#【分类】删除', 71 | }, 72 | }, 73 | 74 | '/article': { 75 | post: '*article.create#【文章】创建', 76 | get: '*article.list#【文章】列表', 77 | 78 | '/multi': { 79 | post: '*article.multi#【文章】多项操作', 80 | }, 81 | 82 | '/:_id': { 83 | put: '*article.update#【文章】更新', 84 | get: '*article.one#【文章】详情', 85 | delete: '*article.delete#【文章】删除', 86 | }, 87 | }, 88 | 89 | '/authority': { 90 | get: '*authority.list#【权限】列表', 91 | }, 92 | 93 | '/media': { 94 | post: '*media.create#【文件】上传', 95 | get: '*media.list#【文件】列表', 96 | 97 | '/multi': { 98 | post: '*media.multi#【文件】多项操作', 99 | }, 100 | }, 101 | 102 | '/dashboard': { 103 | get: 'dashboard#【统计】dashboard', 104 | }, 105 | }, 106 | 107 | '/api': { 108 | '/article': { 109 | get: 'article.articleList', 110 | 111 | '/:_id': { 112 | get: 'article.articleItem', 113 | }, 114 | }, 115 | }, 116 | } 117 | -------------------------------------------------------------------------------- /server/src/services/article.service.ts: -------------------------------------------------------------------------------- 1 | import { Transfer } from '../db/typeorm' 2 | import { ArticleEntity } from '../entities' 3 | import { SBError } from '../utils' 4 | 5 | class Service { 6 | async findById(id: number): Promise { 7 | const article = await Transfer.manager.findOne(ArticleEntity, id) 8 | if (!article) { 9 | throw new SBError('NOT_EXIST') 10 | } 11 | return article 12 | } 13 | 14 | async findOne(query = {}): Promise { 15 | return await Transfer.manager.findOne(ArticleEntity, query) 16 | } 17 | 18 | async findAll(input): Promise<{ list: ArticleEntity[], count: number }> { 19 | const { page = 1, pageSize = 10, ...query } = input 20 | 21 | const sq = Transfer.manager 22 | .createQueryBuilder(ArticleEntity, 'article') 23 | .leftJoinAndSelect('article.category', 'category') 24 | .where('1 = 1') 25 | 26 | if ('title' in query) { 27 | sq.andWhere('article.title LIKE :title', { title: `%${query.title}%` }) 28 | } 29 | 30 | if ('category' in query) { 31 | sq.andWhere('article.category = :category', { category: query.category }) 32 | } 33 | 34 | sq.orderBy('article.createdAt', 'DESC') 35 | 36 | const count = await sq.getCount() 37 | 38 | const list = await sq 39 | .skip((page - 1) * pageSize) 40 | .take(pageSize) 41 | .getMany() 42 | 43 | return { count, list } 44 | } 45 | 46 | async create(dto: any) { 47 | const qs = Transfer.manager 48 | .createQueryBuilder(ArticleEntity, 'article') 49 | .where('article.title = :title', { title: dto.title }) 50 | 51 | if (await qs.getOne()) { 52 | throw new SBError('DUPLICATE') 53 | } 54 | 55 | let newData = new ArticleEntity() 56 | Object.assign(newData, dto) 57 | 58 | return await Transfer.manager.save(ArticleEntity, newData) 59 | } 60 | 61 | async update(id: number, dto: any) { 62 | let toUpdate = await this.findOne(id) 63 | if (!toUpdate) { 64 | throw new SBError('NOT_EXIST') 65 | } 66 | 67 | let updated = Object.assign(toUpdate, dto) 68 | return await Transfer.manager.save(ArticleEntity, updated) 69 | } 70 | 71 | async delete(id: number) { 72 | return await Transfer.manager.delete(ArticleEntity, id) 73 | } 74 | } 75 | 76 | export const ArticleService = new Service() 77 | -------------------------------------------------------------------------------- /server/src/services/category.service.ts: -------------------------------------------------------------------------------- 1 | import { Transfer } from '../db/typeorm' 2 | import { CategoryEntity } from '../entities' 3 | import { SBError } from '../utils' 4 | 5 | class Service { 6 | async findOne(query = {}): Promise { 7 | return Transfer.manager.findOne(CategoryEntity, query) 8 | } 9 | 10 | async findAll(query = {}): Promise { 11 | const sq = Transfer.manager 12 | .createQueryBuilder(CategoryEntity, 'category') 13 | .where('1 = 1') 14 | 15 | sq.orderBy('category.createdAt', 'DESC') 16 | 17 | return await sq.getMany() 18 | } 19 | 20 | async create(dto: any) { 21 | const qs = Transfer.manager 22 | .createQueryBuilder(CategoryEntity, 'category') 23 | .where('category.name = :name', { name: dto.name }) 24 | 25 | if (await qs.getOne()) { 26 | throw new SBError('DUPLICATE') 27 | } 28 | 29 | let newData = new CategoryEntity() 30 | Object.assign(newData, dto) 31 | return await Transfer.manager.save(CategoryEntity, newData) 32 | } 33 | 34 | async update(id: number, dto: any) { 35 | let toUpdate = await this.findOne(id) 36 | if (!toUpdate) { 37 | throw new SBError('NOT_EXIST') 38 | } 39 | 40 | let updated = Object.assign(toUpdate, dto) 41 | return await Transfer.manager.save(CategoryEntity, updated) 42 | } 43 | 44 | async delete(id: number) { 45 | return await Transfer.manager.delete(CategoryEntity, id) 46 | } 47 | } 48 | 49 | export const CategoryService = new Service() 50 | -------------------------------------------------------------------------------- /server/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article.service' 2 | export * from './category.service' 3 | -------------------------------------------------------------------------------- /server/src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache' 2 | 3 | export const cache = new LRU({ 4 | max: 100, 5 | maxAge: 1000 * 30, // 30s 6 | }) 7 | -------------------------------------------------------------------------------- /server/src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | /** 4 | * 进行 SHA1 加密 5 | * @param {String} value 原值 6 | * @return {String} SHA1 值 7 | */ 8 | export function sha1 (value: string): string { 9 | const sha1 = crypto.createHash('sha1') 10 | sha1.update(value) 11 | return sha1.digest('hex') 12 | } 13 | 14 | /** 15 | * 进行 MD5 加密 16 | * @param {String} value 原值 17 | * @return {String} MD5 值 18 | */ 19 | export function md5 (value: string): string { 20 | const md5 = crypto.createHash('md5') 21 | md5.update(value) 22 | return md5.digest('hex') 23 | } 24 | 25 | /** 26 | * 进行 base64 加密 27 | * @param {String} value 原值 28 | * @param {String} secret 秘钥 29 | * @return {String} BASE64 值 30 | */ 31 | export function base64 (value: string, secret: string): string { 32 | const base64 = crypto.createHmac('sha1', secret) 33 | base64.update(value) 34 | return base64.digest('base64') 35 | } 36 | -------------------------------------------------------------------------------- /server/src/utils/email.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer' 2 | import { EMAIL } from '../config' 3 | import { CodeType } from '../constants' 4 | 5 | const { host, port, user, pass } = EMAIL 6 | 7 | const Types = { 8 | [CodeType.register]: (text: string) => ({ 9 | from: `lansebiji.com(蓝色笔记) <${user}>`, 10 | text: `验证码[${text}]`, 11 | html: `

验证码:${text}

` 12 | }), 13 | } 14 | 15 | class Email { 16 | transporter: any 17 | constructor() { 18 | this.transporter = nodemailer.createTransport({ 19 | host, 20 | // secureConnection: true, 21 | port, 22 | auth: { user, pass }, 23 | logger: false, 24 | debug: false 25 | }) 26 | } 27 | 28 | async send(email = '', text = '', type = CodeType.register) { 29 | return await this.transporter.sendMail({ 30 | to: email, 31 | ...Types[type](text), 32 | }) 33 | } 34 | } 35 | 36 | export const email = new Email() 37 | -------------------------------------------------------------------------------- /server/src/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { MsgCode, MsgCodeType } from '../constants' 2 | 3 | export class SBError extends Error { 4 | public code: number 5 | public msg?: string 6 | public stack: any 7 | 8 | constructor(key: MsgCodeType, msg?: string) { 9 | super() 10 | const map = MsgCode[key] 11 | this.code = map.code 12 | this.msg = msg || map.msg || 'unknown' 13 | Error.captureStackTrace(this, SBError) 14 | } 15 | 16 | public getError() { 17 | return { code: this.code, msg: this.msg } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import util from 'util' 3 | import { URL } from 'url' 4 | 5 | export * from './cache' 6 | export * as crypto from './crypto' 7 | export * from './email' 8 | export * from './error' 9 | export * from './joi' 10 | export * from './jwt' 11 | export * from './lbs' 12 | export * from './logger' 13 | export * from './oss' 14 | export * from './random' 15 | export * as regx from './regx' 16 | export * from './sms' 17 | export * as validate from './validate' 18 | export * from './wechat' 19 | 20 | type PathLike = string | Buffer | URL; 21 | type WriteFileOptions = { encoding?: string | null; mode?: number | string; flag?: string; } | string | null; 22 | interface MakeDirectoryOptions { 23 | recursive?: boolean; 24 | mode?: number; 25 | } 26 | 27 | export const readFile: (path: PathLike | number, options?: { encoding?: null; flag?: string; }) => Promise = util.promisify(fs.readFile).bind(fs) 28 | export const writeFile: (path: PathLike | number, data: any, options?: WriteFileOptions) => Promise = util.promisify(fs.writeFile).bind(fs) 29 | export const mkdir: (path: PathLike | number, options?: number | string | MakeDirectoryOptions) => Promise = util.promisify(fs.mkdir).bind(fs) 30 | export const readdir: (path: PathLike, options?: { encoding: BufferEncoding | null; withFileTypes?: false } | BufferEncoding) => Promise<(string|Buffer)[]> = util.promisify(fs.readdir).bind(fs) 31 | export const access: (path: PathLike, mode?: number) => Promise = util.promisify(fs.access).bind(fs) 32 | export const isAccess = async (path: PathLike, mode?: number) => { 33 | try { 34 | await access(path) 35 | return true 36 | } catch (e) { 37 | return false 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/utils/joi.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi' 2 | import * as validate from './validate' 3 | import * as constants from '../constants' 4 | 5 | export const isId = Joi.string().custom(value => { 6 | if (!validate.isId(value)) { 7 | throw new TypeError('not id') 8 | } 9 | return value 10 | }).description('ID') 11 | 12 | export const isPath = Joi.string().custom(value => { 13 | if (!validate.isPath(value)) { 14 | throw new TypeError('not path') 15 | } 16 | return value 17 | }).description('path') 18 | 19 | export const joiSchema = { 20 | // common - base 21 | id: isId, 22 | page: Joi.number().optional().default(1).description('页码'), 23 | pageSize: Joi.number().optional().default(10).description('每页数量'), 24 | email: Joi.string().email().optional().description('邮箱'), 25 | account: Joi.string().optional().description('账号'), 26 | phone: Joi.string().custom(value => { 27 | if (!validate.isPhone(value)) { 28 | throw new TypeError('no a phone number') 29 | } 30 | return value 31 | }).description('手机号'), 32 | password: Joi.string().optional().description('密码'), 33 | address: Joi.string().optional().description('地址'), 34 | nickname: Joi.string().optional().description('昵称'), 35 | avatar: Joi.string().optional().description('头像'), 36 | roleType: Joi.string().valid(...constants.RoleTypes).optional().description('角色'), 37 | codeType: Joi.string().valid(...constants.CodeTypes).optional().description('验证码类型'), 38 | 39 | category: { 40 | name: Joi.string().optional().description('名称'), 41 | sort: Joi.number().optional().default(0).description('排序'), 42 | path: isPath, 43 | display: Joi.boolean().optional().default(false).description('是否在导航中显示'), 44 | keywords: Joi.array().items(Joi.string()).optional().description('关键词'), 45 | description: Joi.string().optional().description('描述'), 46 | }, 47 | 48 | article: { 49 | title: Joi.string().optional().description('标题'), 50 | overview: Joi.string().optional().description('简介'), 51 | cover: Joi.string().optional().description('封面'), 52 | tags: Joi.array().items(Joi.string()).optional().description('标签'), 53 | content: Joi.string().optional().description('内容'), 54 | from: Joi.string().valid(...constants.ArticleFromTypes).optional().description('文章来源'), 55 | originalAuthor: Joi.string().optional().description('原作者'), 56 | originalUrl: Joi.string().optional().description('转载地址'), 57 | isTop: Joi.boolean().optional().description('是否置顶'), 58 | status: Joi.string().valid(...constants.ArticleStatusTypes).optional().description('状态'), 59 | }, 60 | } 61 | 62 | export { Joi } 63 | -------------------------------------------------------------------------------- /server/src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import jsonwebtoken from 'jsonwebtoken' 2 | import { JWT } from '../config' 3 | 4 | const { secret, expiresIn } = JWT 5 | 6 | interface User { [key:string]: any } 7 | 8 | export class Jwt { 9 | /** 10 | * 生成jwt 11 | * @param {Object} user 用户信息 12 | * @return {String} jwt str 13 | */ 14 | static sign(user: User = {}): string { 15 | const { id } = user 16 | return jsonwebtoken.sign({ id }, secret, { expiresIn }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/src/utils/lbs.ts: -------------------------------------------------------------------------------- 1 | import request from 'request' 2 | import { md5 } from './crypto' 3 | import { LBS } from '../config' 4 | 5 | const { key, secret, url } = LBS 6 | 7 | export class Lbs { 8 | // get_poi 是否返回周边POI列表:1.返回;0不返回(默认) 9 | static async geocoder (lat, lng) { 10 | return new Promise((resolve, reject) => { 11 | const query = { key, location: `${lat},${lng}` } 12 | const qs = Object.keys(query).map(k => `${k}=${query[k]}`).sort().join('&') 13 | const sign = md5(`/ws/geocoder/v1?${qs}${secret}`) 14 | 15 | request(`${url}/ws/geocoder/v1?${qs}&sig=${sign}`, (err, { body }) => { 16 | if (err) return reject(err) 17 | const { status, message, result } = JSON.parse(body) 18 | if (status === 0) { 19 | resolve(result) 20 | } else { 21 | reject(message) 22 | } 23 | }) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import tracer from 'tracer' 3 | 4 | // 日志 5 | const dailyfile = tracer.dailyfile({ 6 | root: path.join(__dirname, '../../logs'), 7 | maxLogFiles: 10, 8 | format: '{{timestamp}} {{message}}', 9 | dateformat: 'mm-dd HH:MM:ss', 10 | }) 11 | 12 | // 打印 13 | const colorConsole = tracer.colorConsole() 14 | 15 | // 绑定 16 | export const logger = { 17 | log: colorConsole.log, 18 | trace: colorConsole.trace, 19 | debug: colorConsole.debug, 20 | info(...args: any[]) { 21 | colorConsole.info.apply(null, args) 22 | dailyfile.info.apply(null, args) 23 | }, 24 | warn(...args: any[]) { 25 | colorConsole.warn.apply(null, args) 26 | dailyfile.warn.apply(null, args) 27 | }, 28 | error(...args: any[]) { 29 | colorConsole.error.apply(null, args) 30 | dailyfile.error.apply(null, args) 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /server/src/utils/oss.ts: -------------------------------------------------------------------------------- 1 | import { base64 } from './crypto' 2 | import { OSS } from '../config' 3 | 4 | export interface SignInfoType { 5 | id: string 6 | host: string 7 | policy: string 8 | signature: string 9 | dir: string 10 | } 11 | 12 | export class Oss { 13 | /** 14 | * 获取签名信息 15 | * @return {Object} 16 | */ 17 | static getSignInfo(): SignInfoType { 18 | const expiration = new Date(Date.now() + 1000 * 60).toISOString() // 1分钟 19 | 20 | const conditions = [ 21 | ['content-length-range', 0, 104857600], // 100M 22 | ] 23 | const policyStr = JSON.stringify({ expiration, conditions }) 24 | const policy = Buffer.from(policyStr).toString('base64') 25 | 26 | const signature = base64(policy, OSS.secret) 27 | 28 | return { id: OSS.id, host: OSS.host, policy, signature, dir: OSS.dir, } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 随机数 3 | * @param {Number} length [长度] 4 | * @return {String} 5 | */ 6 | export function random(length: number = 1): string { 7 | const base = Math.floor(Math.random() * Math.pow(10, length)) 8 | return (`0000000000000000${base}`).substr(-length) 9 | } 10 | -------------------------------------------------------------------------------- /server/src/utils/regx.ts: -------------------------------------------------------------------------------- 1 | 2 | export const email = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/ 3 | 4 | export const phone = /^1[3|4|5|7|8]\d{9}$/ 5 | 6 | export function isEmail(value: any): boolean { 7 | return email.test(value) 8 | } 9 | 10 | export function isPhone(value: any): boolean { 11 | return phone.test(value) 12 | } 13 | -------------------------------------------------------------------------------- /server/src/utils/sms.ts: -------------------------------------------------------------------------------- 1 | import QcloudSms from 'qcloudsms_js' 2 | import { promisify } from 'util' 3 | import * as Redis from '../db/redis' 4 | import { SMS } from '../config' 5 | 6 | const { appid, appkey, templateId, sign, expiresIn } = SMS 7 | 8 | interface OptionsType { 9 | phone?: string | number 10 | code?: string | number 11 | type?: string 12 | } 13 | 14 | class Sms { 15 | sender: any 16 | codeSender: any 17 | 18 | constructor() { 19 | this.sender = QcloudSms(appid, appkey).SmsSingleSender() // 发送单条 20 | /** 21 | * 指定模板ID单发短信 promise util 22 | */ 23 | this.codeSender = promisify(this.sender.sendWithParam).bind(this.sender) 24 | } 25 | 26 | // 发送 27 | async sendCode(options: OptionsType) { 28 | const { phone, code, type } = options 29 | await this.codeSender(86, phone, templateId, [code], sign, '', '') 30 | await Redis.set(`${phone}-${type}`, code, 'EX', expiresIn) 31 | } 32 | 33 | // 缓存值 34 | async getCode(options: OptionsType) { 35 | const { phone, type } = options 36 | return await Redis.get(`${phone}-${type}`) 37 | } 38 | 39 | // 验证 40 | async validCode(options: OptionsType): Promise { 41 | const { phone, code, type } = options 42 | 43 | const isEq = code === (await this.getCode(options)) 44 | 45 | if (isEq) { 46 | await Redis.del(`${phone}-${type}`) 47 | return true 48 | } else { 49 | return false 50 | } 51 | } 52 | } 53 | 54 | export const sms = new Sms() 55 | 56 | -------------------------------------------------------------------------------- /server/src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | import shortid from 'shortid' 2 | 3 | export const regx = { 4 | email: /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/, 5 | phone: /^1[3|4|5|7|8]\d{9}$/ 6 | } 7 | 8 | export const isJson = (value: any): boolean => Object.prototype.toString.call(value).toLowerCase() === '[object object]' 9 | 10 | export const isArray = (value: any): boolean => Array.isArray(value) 11 | 12 | export const isString = (value: any): boolean => typeof value === 'string' 13 | 14 | export const isNumber = (value: any): boolean => !Number.isNaN(Number(value)) 15 | 16 | export const isId = (value: any): boolean => shortid.isValid(value) 17 | 18 | export const isEmail = (value: any): boolean => regx.email.test(value) 19 | 20 | export const isPhone = (value: any): boolean => regx.phone.test(value) 21 | 22 | export const isPath = (value: any): boolean => /^[A-z0-9\-\_\/]+$/.test(value) 23 | -------------------------------------------------------------------------------- /server/src/utils/wechat.ts: -------------------------------------------------------------------------------- 1 | import request from 'request' 2 | import { WECHAT } from '../config' 3 | 4 | const { appid, secret, url } = WECHAT.APPLETS 5 | 6 | export class Wechat { 7 | /** 8 | * 换取 opendid 9 | * @param {String} code 10 | */ 11 | static async getOpenId (code: string): Promise { 12 | return new Promise((resolve, reject) => { 13 | const qs = { 14 | appid, 15 | secret, 16 | js_code: code, 17 | grant_type: 'authorization_code', 18 | } 19 | request({ url, qs }, (err, { body }) => { 20 | if (err) return reject(err) 21 | const { openid } = JSON.parse(body) 22 | openid ? resolve(openid) : reject() 23 | }) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/tests/0.beforeTest.js: -------------------------------------------------------------------------------- 1 | describe('0. env tests', () => {}) 2 | -------------------------------------------------------------------------------- /server/tests/1.installTest.js: -------------------------------------------------------------------------------- 1 | const request = require('./helpers') 2 | 3 | describe('1. install tests', () => { 4 | describe('a. Hotchcms install status', () => { 5 | it('HTTP status should be 200', done => { 6 | request 7 | .get('/api/install') 8 | .expect(200) 9 | .end((err, res) => { 10 | done() 11 | }) 12 | }) 13 | }) 14 | 15 | describe('b. mongodb connect status', () => { 16 | it('HTTP status should be 200', done => { 17 | request 18 | .put('/api/install/test-database') 19 | .expect(200) 20 | .end((err, res) => { 21 | done() 22 | }) 23 | }) 24 | }) 25 | 26 | describe('c. redis connect status', () => { 27 | it('HTTP status should be 200', done => { 28 | request 29 | .put('/api/install/test-redis') 30 | .expect(200) 31 | .end((err, res) => { 32 | done() 33 | }) 34 | }) 35 | }) 36 | 37 | describe('d. install test', () => { 38 | it('HTTP status should be 200', done => { 39 | request 40 | .post('/api/install') 41 | .expect(200) 42 | .end((err, res) => { 43 | done() 44 | }) 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /server/tests/helpers.js: -------------------------------------------------------------------------------- 1 | const supertest = require('supertest') 2 | const chai = require('chai') 3 | const portLib = require('../lib/port.lib') 4 | 5 | const expect = chai.expect 6 | const port = portLib() 7 | 8 | const request = supertest(`http://localhost:${port}`) 9 | 10 | exports.expect = expect 11 | module.exports = request 12 | -------------------------------------------------------------------------------- /server/tmp/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/server/tmp/.gitkeep -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "lib": ["es6"], 6 | "sourceMap": true, 7 | "outDir": "dist", 8 | "noImplicitAny": false, 9 | "moduleResolution": "node", 10 | "baseUrl": ".", 11 | "paths": { 12 | "*": [ 13 | "node_modules/*" 14 | ] 15 | }, 16 | "allowSyntheticDefaultImports": true, 17 | "esModuleInterop": true, 18 | "experimentalDecorators": true, 19 | "emitDecoratorMetadata": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true 22 | }, 23 | "include": [ 24 | "src/**/*" 25 | ], 26 | "exclude": [ 27 | "tests" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------