├── .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 | 
9 | * 后台展示
10 | 
11 | 
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 |
24 |
--------------------------------------------------------------------------------
/backstage/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luckcoding/hotchcms/67de7b448eacda46308b9a7c8fe3681213ea1bca/backstage/public/favicon.ico
--------------------------------------------------------------------------------
/backstage/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |

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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------