├── .dockerignore ├── src ├── router │ ├── links.js │ ├── index.js │ └── routes.js ├── views │ ├── Home │ │ ├── store │ │ │ ├── actionTypes.js │ │ │ ├── index.js │ │ │ ├── reducer.js │ │ │ └── actionCreators.js │ │ ├── components │ │ │ └── Card │ │ │ │ └── index.js │ │ ├── index.less │ │ └── index.js │ ├── Summary │ │ ├── store │ │ │ ├── actionTypes.js │ │ │ ├── index.js │ │ │ ├── reducer.js │ │ │ └── actionCreators.js │ │ ├── index.less │ │ └── index.js │ └── Detail │ │ ├── store │ │ ├── actionTypes.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── actionCreators.js │ │ ├── index.less │ │ └── index.js ├── favicon.ico ├── components │ ├── index.js │ ├── WithStyles │ │ └── index.js │ ├── Header │ │ ├── index.js │ │ └── index.less │ └── Footer │ │ ├── index.less │ │ └── index.js ├── utils │ ├── utils.js │ └── request.js ├── app.js ├── layouts │ ├── index.js │ └── basicLayout │ │ ├── index.js │ │ └── index.less ├── .editorConfig ├── common │ └── style │ │ ├── common.css │ │ └── reset.css ├── store │ ├── reducers.js │ └── index.js ├── server-entry.js ├── client-entry.js └── index.ejs ├── config ├── dev.env.js ├── prod.env.js ├── httpsConfig.js ├── devServer.js ├── webpack.config.server.js ├── util.js ├── webpack.config.base.js └── webpack.config.client.js ├── static └── images │ ├── 1.png │ ├── 10.png │ ├── 11.png │ ├── 12.png │ ├── 13.png │ ├── 14.png │ ├── 15.png │ ├── 16.png │ ├── 17.png │ ├── 18.png │ ├── 19.png │ ├── 2.png │ ├── 20.png │ ├── 21.png │ ├── 22.png │ ├── 23.png │ ├── 24.png │ ├── 25.png │ ├── 26.png │ ├── 27.png │ ├── 28.png │ ├── 29.png │ ├── 3.png │ ├── 30.png │ ├── 31.png │ ├── 32.png │ ├── 33.png │ ├── 34.png │ ├── 35.png │ ├── 36.png │ ├── 37.png │ ├── 38.png │ ├── 39.png │ ├── 4.png │ ├── 40.png │ ├── 41.png │ ├── 42.png │ ├── 43.png │ ├── 44.png │ ├── 45.png │ ├── 46.png │ ├── 47.png │ ├── 48.png │ ├── 49.png │ ├── 5.png │ ├── 50.png │ ├── 51.png │ ├── 52.png │ ├── 6.png │ ├── 7.png │ ├── 8.png │ ├── 9.png │ ├── rss.png │ └── zoo-team.png ├── bash.sh ├── .babelrc ├── .gitignore ├── server ├── ignore.js ├── process.json ├── index.js └── render.js ├── Dockerfile ├── nginx.conf ├── LICENSE ├── README.md ├── .eslintrc └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | npm-debug.log 4 | dist -------------------------------------------------------------------------------- /src/router/links.js: -------------------------------------------------------------------------------- 1 | export default [{ title: 'zoo weekly', path: '/' }]; 2 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"development"', 3 | }; 4 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"', 3 | }; 4 | -------------------------------------------------------------------------------- /src/views/Home/store/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const HOME_LIST = 'home/getList'; 2 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/views/Summary/store/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SUMMARY = 'summary/overview'; 2 | -------------------------------------------------------------------------------- /static/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/1.png -------------------------------------------------------------------------------- /static/images/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/10.png -------------------------------------------------------------------------------- /static/images/11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/11.png -------------------------------------------------------------------------------- /static/images/12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/12.png -------------------------------------------------------------------------------- /static/images/13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/13.png -------------------------------------------------------------------------------- /static/images/14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/14.png -------------------------------------------------------------------------------- /static/images/15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/15.png -------------------------------------------------------------------------------- /static/images/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/16.png -------------------------------------------------------------------------------- /static/images/17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/17.png -------------------------------------------------------------------------------- /static/images/18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/18.png -------------------------------------------------------------------------------- /static/images/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/19.png -------------------------------------------------------------------------------- /static/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/2.png -------------------------------------------------------------------------------- /static/images/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/20.png -------------------------------------------------------------------------------- /static/images/21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/21.png -------------------------------------------------------------------------------- /static/images/22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/22.png -------------------------------------------------------------------------------- /static/images/23.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/23.png -------------------------------------------------------------------------------- /static/images/24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/24.png -------------------------------------------------------------------------------- /static/images/25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/25.png -------------------------------------------------------------------------------- /static/images/26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/26.png -------------------------------------------------------------------------------- /static/images/27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/27.png -------------------------------------------------------------------------------- /static/images/28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/28.png -------------------------------------------------------------------------------- /static/images/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/29.png -------------------------------------------------------------------------------- /static/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/3.png -------------------------------------------------------------------------------- /static/images/30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/30.png -------------------------------------------------------------------------------- /static/images/31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/31.png -------------------------------------------------------------------------------- /static/images/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/32.png -------------------------------------------------------------------------------- /static/images/33.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/33.png -------------------------------------------------------------------------------- /static/images/34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/34.png -------------------------------------------------------------------------------- /static/images/35.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/35.png -------------------------------------------------------------------------------- /static/images/36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/36.png -------------------------------------------------------------------------------- /static/images/37.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/37.png -------------------------------------------------------------------------------- /static/images/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/38.png -------------------------------------------------------------------------------- /static/images/39.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/39.png -------------------------------------------------------------------------------- /static/images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/4.png -------------------------------------------------------------------------------- /static/images/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/40.png -------------------------------------------------------------------------------- /static/images/41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/41.png -------------------------------------------------------------------------------- /static/images/42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/42.png -------------------------------------------------------------------------------- /static/images/43.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/43.png -------------------------------------------------------------------------------- /static/images/44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/44.png -------------------------------------------------------------------------------- /static/images/45.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/45.png -------------------------------------------------------------------------------- /static/images/46.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/46.png -------------------------------------------------------------------------------- /static/images/47.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/47.png -------------------------------------------------------------------------------- /static/images/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/48.png -------------------------------------------------------------------------------- /static/images/49.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/49.png -------------------------------------------------------------------------------- /static/images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/5.png -------------------------------------------------------------------------------- /static/images/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/50.png -------------------------------------------------------------------------------- /static/images/51.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/51.png -------------------------------------------------------------------------------- /static/images/52.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/52.png -------------------------------------------------------------------------------- /static/images/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/6.png -------------------------------------------------------------------------------- /static/images/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/7.png -------------------------------------------------------------------------------- /static/images/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/8.png -------------------------------------------------------------------------------- /static/images/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/9.png -------------------------------------------------------------------------------- /static/images/rss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/rss.png -------------------------------------------------------------------------------- /static/images/zoo-team.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcy-inc/weekly-front/HEAD/static/images/zoo-team.png -------------------------------------------------------------------------------- /bash.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rsync -av --delete -e ssh --exclude={'./node_modules','.*'} ./dist/* 用户@服务器ip:/root/weekly/weekly-client/ 3 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import routesConfig from './routes'; 2 | import links from './links'; 3 | 4 | export { routesConfig, links }; 5 | -------------------------------------------------------------------------------- /src/views/Detail/store/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const WEEKLY_DETAIL = 'detail/getDetail'; 2 | 3 | export const RESET_DETAIL = 'detail/resetDetail'; 4 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | import Header from './Header'; 2 | import Footer from './Footer'; 3 | import WithStyles from './WithStyles'; 4 | 5 | export { Header, Footer, WithStyles }; 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react", "stage-0"], 3 | "plugins": [ 4 | "syntax-dynamic-import", 5 | "transform-decorators-legacy", 6 | "add-module-exports" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | export const isNumeric = (value) => { 2 | if (typeof value === 'object') { 3 | return false; 4 | } else { 5 | return !Number.isNaN(Number(value)); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /config/httpsConfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isHttps: false, // 是否开启https 3 | // https key 4 | httpsOption: { 5 | key: 'weekly.zoo.team.key', 6 | cert: 'weekly.zoo.team_bundle.crt', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/views/Detail/store/index.js: -------------------------------------------------------------------------------- 1 | import reducer from './reducer'; 2 | import * as actionCreators from './actionCreators'; 3 | import * as actionTypes from './actionTypes'; 4 | 5 | export { reducer, actionCreators, actionTypes }; 6 | -------------------------------------------------------------------------------- /src/views/Home/store/index.js: -------------------------------------------------------------------------------- 1 | import reducer from './reducer'; 2 | import * as actionCreators from './actionCreators'; 3 | import * as actionTypes from './actionTypes'; 4 | 5 | export { reducer, actionCreators, actionTypes }; 6 | -------------------------------------------------------------------------------- /src/views/Summary/store/index.js: -------------------------------------------------------------------------------- 1 | import reducer from './reducer'; 2 | import * as actionCreators from './actionCreators'; 3 | import * as actionTypes from './actionTypes'; 4 | 5 | export { reducer, actionCreators, actionTypes }; 6 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | // 后端服务的ip和端口。3030在后端项目中写死,可以到后端项目中修改 3 | const BASE_URL = 'http://localhost:3030'; 4 | const instance = axios.create({ 5 | baseURL: BASE_URL, 6 | }); 7 | 8 | export default instance; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | router-config.js* 5 | npm-debug.log* 6 | package-lock.json* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Editor directories and files 11 | .idea 12 | .vscode 13 | .history 14 | jsconfig.json 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | -------------------------------------------------------------------------------- /src/views/Summary/index.less: -------------------------------------------------------------------------------- 1 | .info-wrapper { 2 | padding: 20px; 3 | text-align: center; 4 | font-size: 20px; 5 | line-height: 24px; 6 | color: #796e6e; 7 | } 8 | 9 | .summary-title { 10 | font-size: 30px; 11 | line-height: 36px; 12 | text-align: center; 13 | padding: 20px; 14 | color: brown; 15 | } 16 | -------------------------------------------------------------------------------- /server/ignore.js: -------------------------------------------------------------------------------- 1 | const ignore = () => { 2 | const extensions = ['.css', '.scss', '.less', '.png', '.jpg', '.gif']; // 服务端渲染不加载的文件类型 3 | for (let i = 0, len = extensions.length; i < len; i++) { 4 | require.extensions[extensions[i]] = () => { 5 | return false; 6 | }; 7 | } 8 | }; 9 | module.exports = ignore; 10 | -------------------------------------------------------------------------------- /src/views/Home/store/reducer.js: -------------------------------------------------------------------------------- 1 | import { HOME_LIST } from './actionTypes'; 2 | 3 | const initState = { 4 | cardList: [], 5 | }; 6 | 7 | export default (state = initState, action) => { 8 | switch (action.type) { 9 | case HOME_LIST: 10 | return { 11 | ...state, 12 | cardList: [...action.payload.list], 13 | }; 14 | default: 15 | return state; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/views/Summary/store/reducer.js: -------------------------------------------------------------------------------- 1 | import { SUMMARY } from './actionTypes'; 2 | 3 | const initState = { 4 | overview: {}, 5 | }; 6 | 7 | export default (state = initState, action) => { 8 | switch (action.type) { 9 | case SUMMARY: 10 | return { 11 | ...state, 12 | overview: action.payload.overview, 13 | }; 14 | default: 15 | return state; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import Layout from './layouts'; 5 | 6 | const createApp = ({ store }) => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default createApp; 17 | -------------------------------------------------------------------------------- /src/layouts/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | import BasicLayout from './basicLayout'; 4 | 5 | class Layout extends Component { 6 | render() { 7 | return ( 8 | 9 | } /> 10 | 11 | ); 12 | } 13 | } 14 | 15 | export default Layout; 16 | -------------------------------------------------------------------------------- /src/.editorConfig: -------------------------------------------------------------------------------- 1 | root = true # 表示当前是项目根目录 2 | 3 | [*] # 所有文件都使用配置 4 | charset = utf-8 # 编码格式 5 | indent_style = space # Tab键缩进的样式,由space和table两种 一般代码中是space 6 | indent_size = 2 # 缩进size为2 7 | end_of_line = lf # 以lf换行 默认win为crlf mac和linux为lf 8 | insert_final_newline = true # 末尾加一行空行 9 | trim_trailing_whitespace = true # 去掉行尾多余空格 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine AS builder 2 | COPY . /code 3 | WORKDIR /code 4 | RUN npm install && npm run build 5 | 6 | FROM nginx:1.21.0-alpine 7 | COPY --from=builder /code/dist/ /root/weekly/weekly-client/ 8 | 9 | COPY ./nginx.conf /etc/nginx 10 | 11 | EXPOSE 80 12 | 13 | CMD sed -i "s/localhost/$SERVER_IP/g" `grep http://localhost:3030 -rl /root/weekly/weekly-client/`; nginx -g "daemon off;" 14 | # CMD nginx -g "daemon off;" 15 | -------------------------------------------------------------------------------- /src/common/style/common.css: -------------------------------------------------------------------------------- 1 | p.cnzz_p_wrapper { 2 | background: #2e3032; 3 | color: #cccccc; 4 | font-size: 14px; 5 | position: absolute; 6 | width: 100%; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | bottom: 3rem; 11 | } 12 | 13 | #cnzz_stat_icon_1275915533 a { 14 | text-decoration: none; 15 | color: #cccccc; 16 | } 17 | 18 | .record-filling { 19 | display: block; 20 | white-space: pre; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/WithStyles/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | export default styles => (DecoratedComponent) => { 4 | return class NewComponent extends Component { 5 | componentWillMount() { 6 | if (this.props.staticContext) { 7 | this.props.staticContext.css.push(styles._getCss()); 8 | } 9 | } 10 | render() { 11 | return ; 12 | } 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/store/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { reducer as homeReducer } from '../views/Home/store'; 3 | import { reducer as detailReducer } from '../views/Detail/store'; 4 | import { reducer as summaryReducer } from '../views/Summary/store'; 5 | 6 | const reducer = combineReducers({ 7 | homeStore: homeReducer, 8 | detailStore: detailReducer, 9 | summaryStore: summaryReducer, 10 | }); 11 | 12 | export default reducer; 13 | -------------------------------------------------------------------------------- /src/views/Detail/store/reducer.js: -------------------------------------------------------------------------------- 1 | import { WEEKLY_DETAIL, RESET_DETAIL } from './actionTypes'; 2 | 3 | const initState = { 4 | weeklyDetail: [], 5 | }; 6 | 7 | export default (state = initState, action) => { 8 | switch (action.type) { 9 | case WEEKLY_DETAIL: 10 | return { 11 | ...state, 12 | weeklyDetail: [...action.payload.detail], 13 | }; 14 | case RESET_DETAIL: 15 | return { 16 | ...state, 17 | weeklyDetail: [], 18 | }; 19 | default: 20 | return state; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /server/process.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "weekly-ssr", 5 | "script": "./index.js", 6 | "watch": false, 7 | "args": "--web --port 4092", 8 | "interpreter": "node", 9 | "env": { 10 | "NODE_ENV": "development" 11 | }, 12 | "env_production": { 13 | "NODE_ENV": "production" 14 | }, 15 | "log_date_format": "YYYY-MM-DD HH:mm Z", 16 | "error_file": "./logs/process/error_file.log", 17 | "out_file": "./logs/process/out_file.log" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/views/Home/store/actionCreators.js: -------------------------------------------------------------------------------- 1 | import request from '../../../utils/request'; 2 | import { HOME_LIST } from './actionTypes'; 3 | 4 | const weeklyListUrl = '/api/weeks/list'; 5 | 6 | export const getList = list => ({ 7 | type: HOME_LIST, 8 | payload: { list }, 9 | }); 10 | 11 | export const getListEffect = () => (dispatch) => { 12 | return request.get(weeklyListUrl).then((res) => { 13 | dispatch(getList(res.data)); 14 | }); 15 | }; 16 | 17 | /* 服务端请求数据 */ 18 | export const homeLoadData = (store) => { 19 | return store.dispatch(getListEffect()); 20 | }; 21 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | import rootReducer from './reducers.js'; 4 | 5 | const composeEnhancers = 6 | process.env.NODE_ENV === 'development' 7 | ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 8 | : compose; 9 | 10 | const middleware = [thunkMiddleware]; 11 | 12 | const configureStore = initialState => 13 | createStore( 14 | rootReducer, 15 | initialState, 16 | composeEnhancers(applyMiddleware(...middleware)) 17 | ); 18 | 19 | export default configureStore; 20 | -------------------------------------------------------------------------------- /src/views/Summary/store/actionCreators.js: -------------------------------------------------------------------------------- 1 | import request from '../../../utils/request'; 2 | import { SUMMARY } from './actionTypes'; 3 | 4 | const weeklySummaryUrl = '/api/summary/overview'; 5 | 6 | export const getSummary = overview => ({ 7 | type: SUMMARY, 8 | payload: { overview }, 9 | }); 10 | 11 | export const getSummaryEffect = () => (dispatch) => { 12 | return request.get(weeklySummaryUrl).then((res) => { 13 | dispatch(getSummary(res.data)); 14 | }); 15 | }; 16 | 17 | /* 服务端请求数据 */ 18 | export const SummaryLoadData = (store) => { 19 | return store.dispatch(getSummaryEffect()); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './index.less'; 3 | 4 | export default class Header extends Component { 5 | render() { 6 | return ( 7 |
8 |

9 | Zoo Weekly! 政采云前端小报 10 | 11 | 首页 12 | 数据看板 13 | rss 14 | 15 |

16 |
17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/server-entry.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { StaticRouter } from 'react-router'; 4 | import Loadable from 'react-loadable'; 5 | import Layout from './layouts'; 6 | import configureStore from './store'; 7 | import { routesConfig } from './router'; 8 | 9 | const createApp = ({ modules, store, context, url }) => { 10 | return ( 11 | modules.push(moduleName)}> 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export { createApp, configureStore, routesConfig }; 22 | -------------------------------------------------------------------------------- /src/views/Home/components/Card/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export default class Card extends Component { 5 | render() { 6 | const { 7 | data: { count = '' }, 8 | } = this.props; 9 | return ( 10 |
  • 11 | 16 | {`第${count}期封面图`} 21 |

    前端小报总第 {count} 期

    22 | 23 |
  • 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/layouts/basicLayout/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { NavLink, Route } from 'react-router-dom'; 3 | import { Header, Footer } from '@components'; 4 | import { links, routesConfig } from '../../router/'; 5 | import './index.less'; 6 | 7 | export default class BasicLayout extends Component { 8 | render() { 9 | return ( 10 |
    11 |
    12 | {links.map(l => ( 13 | 14 | {l.title} 15 | 16 | ))} 17 |
    18 |
    19 | {routesConfig.map(r => ( 20 | 21 | ))} 22 |
    23 |
    24 |
    25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /config/devServer.js: -------------------------------------------------------------------------------- 1 | /* webpack-dev-server配置 */ 2 | const path = require('path'); 3 | 4 | const isDev = process.env.NODE_ENV === 'development'; 5 | 6 | module.exports = { 7 | /* 服务根目录 默认指向项目根目录 */ 8 | contentBase: isDev 9 | ? path.join(__dirname, '../') 10 | : path.join(__dirname, '../dist'), 11 | /* 所有404 定位到根路径 */ 12 | historyApiFallback: true, 13 | /* 服务端口 */ 14 | port: 3000, 15 | /* 热更新 */ 16 | hot: true, 17 | /* 自动打开页面 */ 18 | open: true, 19 | /* 数据Mock */ 20 | before() { 21 | // apiMocker(app, path.resolve(__dirname, mockDataPath), {}) 22 | }, 23 | proxy: { 24 | // '/api': { 25 | // target: 'http://119.29.241.71:3000/', 26 | // pathRewrite: { 27 | // '^/api': '' 28 | // }, 29 | // /* 支持https */ 30 | // secure: false 31 | // } 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/Header/index.less: -------------------------------------------------------------------------------- 1 | .hidden-title { 2 | display: none; 3 | } 4 | 5 | .zoo-header, 6 | .zoo-footer, 7 | .about { 8 | padding: 0 6%; 9 | } 10 | 11 | /* site head */ 12 | .zoo-header { 13 | height: 70px; 14 | line-height: 70px; 15 | background-color: #2b303b; 16 | } 17 | 18 | .logo a { 19 | display: inline-block; 20 | color: #fff; 21 | text-decoration: none; 22 | font-size: 24px; 23 | font-family: Pacifico, cursive; 24 | 25 | &:hover { 26 | color: gainsboro; 27 | } 28 | } 29 | 30 | .logo .router-links { 31 | float: right; 32 | height: 65px; 33 | 34 | a { 35 | font-size: 18px; 36 | margin-left: 20px; 37 | } 38 | 39 | a:hover { 40 | color: gainsboro; 41 | } 42 | .rss { 43 | width: 32px; 44 | position: relative; 45 | top: 10px; 46 | &:hover{ 47 | -webkit-transform:rotate(360deg); 48 | transform:rotate(360deg); 49 | -webkit-transition:-webkit-transform 0.5s linear; 50 | transition:transform 0.5s linear; 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | user root; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log notice; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | http { 13 | include /etc/nginx/mime.types; 14 | default_type application/octet-stream; 15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 16 | '$status $body_bytes_sent "$http_referer" ' 17 | '"$http_user_agent" "$http_x_forwarded_for"'; 18 | access_log /var/log/nginx/access.log main; 19 | sendfile on; 20 | keepalive_timeout 65; 21 | server { 22 | listen 80; 23 | listen [::]:80; 24 | server_name localhost; 25 | root /root/weekly/weekly-client/app; 26 | location / { 27 | try_files $uri /index.html; 28 | } 29 | location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ 30 | { 31 | expires 30d; 32 | } 33 | location ~ .*\.(js|css)?$ 34 | { 35 | expires 1h; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/views/Home/index.less: -------------------------------------------------------------------------------- 1 | .card-wrapper { 2 | padding: 0 5%; 3 | background-color: #f4f4f4; 4 | .weekly__list { 5 | padding: 2rem 0; 6 | text-align: center; 7 | } 8 | .weekly__item { 9 | display: inline-block; 10 | margin: 1.5rem; 11 | padding: 1rem; 12 | background: #fff; 13 | border-radius: 4px; 14 | transition: transform 0.2s linear; 15 | box-shadow: 0 2px 0 rgba(170, 170, 170, 0.1); 16 | } 17 | .weekly__item__url { 18 | display: block; 19 | text-decoration: none; 20 | } 21 | .weekly__item:hover { 22 | transform: translate3d(0, -3px, 0); 23 | box-shadow: 0 0 6px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.1); 24 | } 25 | .weekly__item__cover { 26 | display: block; 27 | width: 240px; 28 | height: 180px; 29 | border-top-right-radius: 4px; 30 | border-top-left-radius: 4px; 31 | } 32 | .weekly__item__title { 33 | margin-top: 1rem; 34 | font-size: 1rem; 35 | font-family: Pacifico, cursive; 36 | color: #666; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/client-entry.js: -------------------------------------------------------------------------------- 1 | import { hydrate } from 'react-dom'; 2 | import Loadable from 'react-loadable'; 3 | import createApp from './app'; 4 | import configureStore from './store'; 5 | import './common/style/reset.css'; 6 | import './common/style/common.css'; 7 | 8 | const initialState = window && window.__INITIAL_STATE__; 9 | 10 | const store = configureStore(initialState); 11 | 12 | Loadable.preloadReady().then(() => { 13 | const App = createApp({ store }); 14 | hydrate(App, document.getElementById('root')); 15 | }); 16 | 17 | if (process.env.NODE_ENV === 'development') { 18 | if (module.hot) { 19 | module.hot.accept('./store/reducers.js', () => { 20 | const newReducer = require('./store/reducers.js'); 21 | store.replaceReducer(newReducer); 22 | }); 23 | module.hot.accept('./app.js', () => { 24 | const newReducer = require('./store/reducers.js'); 25 | store.replaceReducer(newReducer); 26 | const App = require('./app.js')({ store }); 27 | hydrate(App, document.getElementById('root')); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/views/Detail/store/actionCreators.js: -------------------------------------------------------------------------------- 1 | import request from '@utils/request'; 2 | import { isNumeric } from '@utils/utils'; 3 | import { WEEKLY_DETAIL, RESET_DETAIL } from './actionTypes'; 4 | 5 | const weeklyListUrl = '/api/list'; 6 | const tagListUrl = '/api/articles/category'; 7 | export const getDetail = detail => ({ 8 | type: WEEKLY_DETAIL, 9 | payload: { detail }, 10 | }); 11 | 12 | export const resetDetail = () => ({ 13 | type: RESET_DETAIL, 14 | }); 15 | 16 | export const getDetailEffect = week => (dispatch) => { 17 | let url = ''; 18 | let params = null; 19 | if (isNumeric(week)) { 20 | url = weeklyListUrl; 21 | params = { 22 | week, 23 | }; 24 | } else { 25 | url = tagListUrl; 26 | params = { 27 | category: week, 28 | }; 29 | } 30 | return request 31 | .get(url, { 32 | params, 33 | }) 34 | .then((res) => { 35 | dispatch(getDetail(res.data)); 36 | }); 37 | }; 38 | 39 | /* 服务端请求数据 */ 40 | export const detailLoadData = (store, match) => { 41 | const { week } = match.params; 42 | return store.dispatch(getDetailEffect(week)); 43 | }; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 政采云有限公司 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 | -------------------------------------------------------------------------------- /src/components/Footer/index.less: -------------------------------------------------------------------------------- 1 | /* site about */ 2 | 3 | .about { 4 | display: flex; 5 | flex-wrap: wrap; 6 | justify-content: space-between; 7 | padding-top: 3rem; 8 | padding-bottom: 3rem; 9 | border-top: 1px solid #d6d6d6; 10 | background: #e8e8e8; 11 | } 12 | 13 | .about dl { 14 | width: 24%; 15 | } 16 | 17 | .about h3 { 18 | font-size: 1rem; 19 | line-height: 1.6; 20 | color: #555; 21 | text-shadow: 1px 1px 1px #fff; 22 | } 23 | 24 | .about dd { 25 | margin: 1.2rem 0; 26 | font-size: 0.9rem; 27 | line-height: 1.4; 28 | color: #555; 29 | } 30 | 31 | /* site foot */ 32 | 33 | .zoo-footer { 34 | padding: 3rem; 35 | padding-bottom: 5rem; 36 | background: #2e3033; 37 | color: #ccc; 38 | font-size: 0.9rem; 39 | text-align: center; 40 | } 41 | 42 | .footer__logo { 43 | margin-left: 0.3rem; 44 | } 45 | 46 | .zoo-team__site { 47 | display: inline-block; 48 | width: 120px; 49 | height: 16px; 50 | overflow: hidden; 51 | color: transparent; 52 | background: url("/static/images/zoo-team.png") no-repeat 0 0; 53 | background-size: contain; 54 | vertical-align: bottom; 55 | } 56 | 57 | /* mobile */ 58 | 59 | @media screen and (max-width: 414px) { 60 | header { 61 | text-align: center; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/router/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loadable from 'react-loadable'; 3 | import { homeLoadData } from '../views/Home/store/actionCreators'; 4 | import { detailLoadData } from '../views/Detail/store/actionCreators'; 5 | import { summaryLoadData } from '../views/Summary/store/actionCreators'; 6 | 7 | const Loading = () => { 8 | return
    ; 9 | }; 10 | 11 | const LoadableHome = Loadable({ 12 | loader: () => import(/* webpackChunkName: 'Home' */ '../views/Home'), 13 | loading: Loading, 14 | }); 15 | 16 | const LoadableDetail = Loadable({ 17 | loader: () => import(/* webpackChunkName: 'Detail' */ '../views/Detail'), 18 | loading: Loading, 19 | }); 20 | 21 | const LoadableSummary = Loadable({ 22 | loader: () => import(/* webpackChunkName: 'Summary' */ '../views/Summary'), 23 | loading: Loading, 24 | }); 25 | const routesConfig = [ 26 | { 27 | path: '/', 28 | exact: true, 29 | component: LoadableHome, 30 | loadData: homeLoadData, 31 | }, 32 | { 33 | path: '/detail/:week', 34 | component: LoadableDetail, 35 | loadData: detailLoadData, 36 | }, 37 | { 38 | path: '/summary', 39 | component: LoadableSummary, 40 | loadData: summaryLoadData, 41 | }, 42 | ]; 43 | 44 | export default routesConfig; 45 | -------------------------------------------------------------------------------- /src/views/Home/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { WithStyles } from '@components'; 4 | import Card from './components/Card'; 5 | import styles from './index.less'; 6 | import { actionCreators } from './store'; 7 | 8 | @WithStyles(styles) 9 | @connect( 10 | state => ({ homeStore: state.homeStore }), 11 | dispatch => ({ 12 | getHomeList: () => dispatch(actionCreators.getListEffect()), 13 | }) 14 | ) 15 | export default class Home extends Component { 16 | componentDidMount() { 17 | const { cardList } = this.props.homeStore; 18 | if (!cardList.length) { 19 | this.props.getHomeList(); 20 | } 21 | } 22 | 23 | render() { 24 | const { cardList } = this.props.homeStore; 25 | if (!cardList.length) return null; 26 | const reverseList = cardList.slice().reverse(); 27 | return ( 28 |
    29 |

    Weekly List

    30 |
      31 | {reverseList.map(card => ( 32 | 37 | ))} 38 |
    39 |
    40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Footer/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import './index.less'; 3 | 4 | export const whatIsThisData = [ 5 | { 6 | title: '关于前端小报', 7 | info: 'Zoo Weekly! 前端小报,是政采云前端团队的周度前端文章推荐汇总。', 8 | }, 9 | { 10 | title: '文章来源构成', 11 | info: ' 前端小报的所有文章来源,都是由政采云的前端同学自发推荐汇总而得。', 12 | }, 13 | { 14 | title: '为什么是Zoo', 15 | info: 16 | 'Z 是政采云拼音首字母,oo 是无穷的符号,结合 Zoo有生物圈的含义,希望后续政采云的前端团队,不论是人才梯队,还是技术体系,都能各面兼备,逐渐成长为一个生态。', 17 | }, 18 | ]; 19 | 20 | export default class Footer extends Component { 21 | render() { 22 | return ( 23 | 24 |
    25 |

    About Zoo Weekly

    26 | {whatIsThisData.map(d => ( 27 |
    28 |
    29 |

    {d.title}

    30 |
    31 |
    {d.info}
    32 |
    33 | ))} 34 |
    35 | 50 |
    51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | require('./ignore.js')(); 2 | require('babel-polyfill'); 3 | 4 | const isProd = true; 5 | 6 | if (isProd) { 7 | require('babel-register')(); 8 | } else { 9 | require('babel-register')({ 10 | presets: ['react', 'stage-0', ['env', { targets: { node: 'current' } }]], 11 | plugins: [ 12 | 'dynamic-import-node', 13 | 'react-loadable/babel', 14 | 'transform-decorators-legacy', 15 | ], 16 | }); 17 | } 18 | const Koa = require('koa'); 19 | 20 | const app = new Koa(); 21 | 22 | const fs = require('fs'); 23 | const path = require('path'); 24 | const https = require('https'); 25 | const http = require('http'); 26 | const { 27 | isHttps, 28 | httpsOption: { key, cert }, 29 | } = require('./httpsConfig'); 30 | 31 | const options = {}; 32 | isHttps && 33 | Object.assign(options, { 34 | key: fs.readFileSync(key), 35 | cert: fs.readFileSync(cert), 36 | }); 37 | 38 | const render = require('./render.js'); 39 | 40 | const port = process.env.port || 3002; 41 | const staticCache = require('koa-static-cache'); 42 | const cors = require('koa2-cors'); 43 | const Loadable = require('react-loadable'); 44 | 45 | app.use(cors()); 46 | app.use(render); 47 | app.use( 48 | staticCache(path.resolve(__dirname, '../app'), { 49 | maxAge: 365 * 24 * 60 * 60, 50 | gzip: true, 51 | }) 52 | ); 53 | 54 | console.log( 55 | `\n==> 🌎 Listening on port ${port}. Open up ${ 56 | isHttps ? 'https' : 'http' 57 | }://localhost:${port}/ in your browser.\n` 58 | ); 59 | Loadable.preloadAll().then(() => { 60 | // app.listen(port); 61 | (isHttps ? https : http).createServer(options, app.callback()).listen(port); 62 | }); 63 | -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 19 | <% if(htmlWebpackPlugin.options.isHttps) { %> 20 | 24 | <% } %> 25 | <%= htmlWebpackPlugin.options.title %> 26 | 27 | 28 |
    29 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 项目描述 2 | 3 | 政采云小报前端项目,收录插件投稿的前端小报内容,便于查看,并进行相关数据统计。 4 | 5 | ## 包含的页面 6 | 7 | 前端小报首页:启动页 8 | ![DC3807F3-E9D5-4BD0-9C94-1DC430F98BD1.png](https://sitecdn.zcycdn.com/f2e-assets/47666360-76be-4270-bb8c-6cb3a83e746a.png?x-oss-process=image/quality,Q_75/format,jpg) 9 | 10 | 前端小报详情页:/detail/${id} 11 | ![3EA087CA-585E-4EC7-80ED-32635B36247C.png](https://sitecdn.zcycdn.com/f2e-assets/daeb4961-4d09-4ae9-89c9-aa3bc53ebe7b.png?x-oss-process=image/quality,Q_75/format,jpg) 12 | 13 | 前端小报的数据统计页:/summary 14 | ![D73FB9BD-5F3F-423E-9980-34BF5DC463C3.png](https://sitecdn.zcycdn.com/f2e-assets/74b323f1-9c88-4a85-a3fd-af0d8efd92ff.png?x-oss-process=image/quality,Q_75/format,jpg) 15 | 16 | 17 | # 启动 18 | 19 | 本地启动需要安装 Chrome 扩展 [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=zh-CN) (点击链接安装) 20 | 执行 `npm i` 安装依赖 21 | 在 `src/utils/request.js` 下的 `BASE_URL` 中配置您的 `ip` 22 | 执行 `npm run dev` 启动项目,该命令可以同时启动本地服务端和客户端。 23 | 24 | 25 | ## 配置您的信息 26 | 27 | 修改 `/src/components/Footer/index.js` 的组件代码、`/src/index.ejs` 的 `script` 内容,替换成您的网站信息。 28 | 29 | ![](https://zoo.team/images/upload/upload_e713076d880b8ff778bab67b39f4a81c.png) 30 | 31 | # 技术选型 32 | 33 | 34 | ## 前端同构渲染 35 | 36 | 为了便于 SEO 爬取信息以及减少白屏时间的考量,我们选择前端同构渲染,使用一套代码,用户首次请求的页面在服务端渲染好,然后再是客户端渲染,也就是前端同构渲染原理。 37 | 38 | 一个填充好的 html => html string => 数据 store + 模板 route 39 | 40 | ### [ReactDOMServer](https://react.docschina.org/docs/react-dom-server.html#___gatsby) 41 | 42 | ReactDOMServer 类可以让你在服务端渲染你的组件 43 | 44 | ### [react-router](https://reacttraining.com/react-router/web/guides/server-rendering) 45 | 46 | 服务端 react 代码需要根据请求的 path 知晓渲染哪一个页面 47 | 48 | ### [redux](http://cn.redux.js.org/docs/recipes/ServerRendering.html) 49 | 50 | 服务端需要提前将不同页面对应的数据请求操作执行,提供应用所需的初始 state,并且由于在服务端渲染时已经请求过对应数据,需要避免客户端再次请求。 51 | 52 | ## 其他依赖项 53 | 54 | [React Helmet](https://github.com/nfl/react-helmet) 一个HTML文档head管理工具,管理对文档头的所有更改。 55 | 56 | 57 | -------------------------------------------------------------------------------- /config/webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const merge = require('webpack-merge'); 4 | const nodeExternals = require('webpack-node-externals'); 5 | const baseWebpackConfig = require('./webpack.config.base'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | const webpackConfig = merge(baseWebpackConfig, { 9 | entry: { 10 | app: path.resolve(__dirname, '../src/server-entry'), 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, '../dist/server'), 14 | filename: 'server-entry.js', 15 | libraryTarget: 'commonjs2', // 打包成commonjs2规范 16 | }, 17 | target: 'node', // 指定node运行环境 18 | externals: [nodeExternals()], 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.jsx?$/, 23 | exclude: /node_modules/, 24 | include: path.resolve(__dirname, '../src'), 25 | use: { 26 | loader: 'babel-loader', 27 | options: { 28 | presets: [ 29 | 'react', 30 | 'stage-0', 31 | ['env', { targets: { node: 'current' } }], 32 | ], 33 | plugins: [ 34 | 'dynamic-import-node', 35 | 'react-loadable/babel', 36 | 'transform-decorators-legacy', 37 | ], 38 | cacheDirectory: true, 39 | }, 40 | }, 41 | }, 42 | { 43 | test: /\.(le|c)ss$/, 44 | use: ['isomorphic-style-loader', 'css-loader', 'less-loader'], 45 | }, 46 | ], 47 | }, 48 | plugins: [ 49 | new webpack.DefinePlugin({ 50 | 'process.env.REACT_ENV': JSON.stringify('server'), // 指定React环境为服务端 51 | }), 52 | new CopyWebpackPlugin([ 53 | { 54 | from: path.resolve(__dirname, '../server'), 55 | }, 56 | { 57 | from: path.resolve(__dirname, '../package.json'), 58 | }, 59 | { 60 | from: path.resolve(__dirname, '../.babelrc'), 61 | }, 62 | { 63 | from: path.resolve(__dirname, '../config/httpsConfig.js'), 64 | }, 65 | ]), 66 | ], 67 | }); 68 | 69 | module.exports = webpackConfig; 70 | -------------------------------------------------------------------------------- /config/util.js: -------------------------------------------------------------------------------- 1 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 2 | const autoprefixer = require('autoprefixer'); 3 | 4 | const cssLoaders = (options) => { 5 | options = options || {}; 6 | 7 | const cssLoader = { 8 | loader: 'css-loader', 9 | options: { 10 | sourceMap: options.sourceMap, 11 | minimize: process.env.NODE_ENV === 'production', 12 | }, 13 | }; 14 | 15 | const postcssLoader = { 16 | loader: 'postcss-loader', 17 | options: { 18 | sourceMap: options.sourceMap, 19 | ident: 'postcss', 20 | plugins: () => [ 21 | require('postcss-flexbugs-fixes'), 22 | autoprefixer({ 23 | browsers: [ 24 | '>1%', 25 | 'last 4 versions', 26 | 'Firefox ESR', 27 | 'not ie < 9', // Doesn"t support IE8 anyway 28 | ], 29 | flexbox: 'no-2009', 30 | }), 31 | ], 32 | }, 33 | }; 34 | 35 | function generateLoaders(loader, loaderOptions) { 36 | const loaders = options.usePostCSS 37 | ? [cssLoader, postcssLoader] 38 | : [cssLoader]; 39 | 40 | if (loader) { 41 | loaders.push({ 42 | loader: `${loader}-loader`, 43 | options: Object.assign({}, loaderOptions, { 44 | sourceMap: options.sourceMap, 45 | }), 46 | }); 47 | } 48 | 49 | // Extract CSS when that option is specified 50 | // (which is the case during production build) 51 | if (options.extract) { 52 | return ExtractTextPlugin.extract({ 53 | use: loaders, 54 | fallback: 'style-loader', 55 | }); 56 | } else { 57 | return ['style-loader'].concat(loaders); 58 | } 59 | } 60 | 61 | return { 62 | css: generateLoaders(), 63 | postcss: generateLoaders(), 64 | less: generateLoaders('less'), 65 | sass: generateLoaders('sass', { indentedSyntax: true }), 66 | scss: generateLoaders('sass'), 67 | stylus: generateLoaders('stylus'), 68 | styl: generateLoaders('stylus'), 69 | }; 70 | }; 71 | 72 | module.exports.styleLoaders = (options) => { 73 | const output = []; 74 | const loaders = cssLoaders(options); 75 | 76 | for (const extension in loaders) { 77 | if (Object.prototype.hasOwnProperty.call(loaders, extension)) { 78 | const loader = loaders[extension]; 79 | output.push({ 80 | test: new RegExp(`\\.${extension}$`), 81 | use: loader, 82 | }); 83 | } 84 | } 85 | return output; 86 | }; 87 | -------------------------------------------------------------------------------- /config/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | /** 5 | * webpack-manifest-plugin 6 | * 该插件将生成一个manifest.json文件在输出文件夹下 7 | * 其中包含所有源文件名到其对应输出文件的映射 8 | */ 9 | const ManifestPlugin = require('webpack-manifest-plugin'); 10 | 11 | /** 12 | * progress-bar-webpack-plugin 13 | * 以百分比显示打包进度 14 | */ 15 | const ProgressBarPlugin = require('progress-bar-webpack-plugin'); 16 | 17 | /** 18 | * uglifyjs-webpack-plugin 19 | * 压缩js文件 20 | */ 21 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 22 | 23 | /** 24 | * extract-text-webpack-plugin 25 | * 抽取css文件 26 | */ 27 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 28 | 29 | let env = 'dev'; 30 | let isProd = false; 31 | let prodPlugins = []; 32 | 33 | if (process.env.NODE_ENV === 'production') { 34 | env = 'prod'; 35 | isProd = true; 36 | prodPlugins = [ 37 | new ManifestPlugin(), 38 | /** 39 | * 在编译出现错误时,使用 NoEmitOnErrorsPlugin 来跳过输出阶段。这样可以确保输出资源不会包含错误。 40 | */ 41 | new webpack.NoEmitOnErrorsPlugin(), 42 | new UglifyJsPlugin({ sourceMap: true }), 43 | new ExtractTextPlugin({ 44 | filename: 'static/css/[name].[contenthash:8].css', 45 | }), 46 | ]; 47 | } 48 | 49 | module.exports = { 50 | devtool: isProd ? 'source-map' : 'cheap-module-source-map', 51 | resolve: { 52 | extensions: ['.js', '.jsx'], 53 | alias: { 54 | '@components': path.join(__dirname, '../src/components'), 55 | '@utils': path.join(__dirname, '../src/utils'), 56 | }, 57 | }, 58 | module: { 59 | rules: [ 60 | { 61 | test: /\.(js|jsx|mjs)?$/, 62 | loader: 'babel-loader', 63 | options: { 64 | /* cacheDirectory是用来缓存编译结果,下次编译加速 */ 65 | cacheDirectory: true, 66 | }, 67 | /* 指定src文件下的内容 */ 68 | include: path.join(__dirname, '../src'), 69 | }, 70 | { 71 | test: /\.(png|jpe?g|gif|svg)$/, 72 | loader: 'url-loader', 73 | options: { 74 | limit: 10000, 75 | name: 'static/images/[name].[ext]', 76 | }, 77 | }, 78 | { 79 | test: /\.(woff2?|eot|ttf|otf)$/, 80 | loader: 'url-loader', 81 | options: { 82 | limit: 10000, 83 | name: 'static/fonts/[name].[hash:7].[ext]', 84 | }, 85 | }, 86 | ], 87 | }, 88 | plugins: [ 89 | new webpack.DefinePlugin({ 90 | 'process.env': require(`./${env}.env`), 91 | }), 92 | new ProgressBarPlugin(), 93 | ...prodPlugins, 94 | ], 95 | }; 96 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "plugins": ["compat"], 5 | "globals": { 6 | "__DEV__": true 7 | }, 8 | "env": { 9 | "browser": true, 10 | "node": true, 11 | "es6": true, 12 | "mocha": true, 13 | "jest": true, 14 | "jasmine": true 15 | }, 16 | "rules": { 17 | "no-shadow": [0], 18 | "no-underscore-dangle": [0], 19 | "array-callback-return": [0], 20 | "react/sort-comp": [0], 21 | "prefer-destructuring": [0], 22 | "generator-star-spacing": [0], 23 | "no-param-reassign": [0], 24 | "consistent-return": [0], 25 | "indent": [2, 2], 26 | "react/forbid-prop-types": [0], 27 | "react/jsx-filename-extension": [1, { "extensions": [".js"] }], 28 | "global-require": [0], 29 | "import/prefer-default-export": [0], 30 | "react/jsx-no-bind": [0], 31 | "react/prop-types": [0], 32 | "react/prefer-stateless-function": [0], 33 | "react/jsx-first-prop-new-line": [0], 34 | "react/jsx-wrap-multilines": [ 35 | "error", 36 | { 37 | "declaration": "parens-new-line", 38 | "assignment": "parens-new-line", 39 | "return": "parens-new-line", 40 | "arrow": "parens-new-line", 41 | "condition": "parens-new-line", 42 | "logical": "parens-new-line", 43 | "prop": "ignore" 44 | } 45 | ], 46 | "no-unused-expressions": [0], 47 | "no-else-return": [0], 48 | "no-restricted-syntax": [0], 49 | "import/no-extraneous-dependencies": [0], 50 | "import/no-dynamic-require": [0], 51 | "no-use-before-define": [0], 52 | "jsx-a11y/no-static-element-interactions": [0], 53 | "jsx-a11y/no-noninteractive-element-interactions": [0], 54 | "jsx-a11y/click-events-have-key-events": [0], 55 | "jsx-a11y/anchor-is-valid": [0], 56 | "no-nested-ternary": [0], 57 | "arrow-body-style": [0], 58 | "import/extensions": [0], 59 | "no-bitwise": [0], 60 | "no-cond-assign": [0], 61 | "import/no-unresolved": [0], 62 | "comma-dangle": [ 63 | "error", 64 | { 65 | "arrays": "always-multiline", 66 | "objects": "always-multiline", 67 | "imports": "always-multiline", 68 | "exports": "always-multiline", 69 | "functions": "ignore" 70 | } 71 | ], 72 | "object-curly-newline": [0], 73 | "function-paren-newline": [0], 74 | "no-restricted-globals": [0], 75 | "require-yield": [1], 76 | "compat/compat": "error", 77 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 78 | "no-console": [0] 79 | }, 80 | "parserOptions": { 81 | "ecmaFeatures": { 82 | "experimentalObjectRestSpread": true, 83 | "legacyDecorators": true 84 | } 85 | }, 86 | "settings": { 87 | "polyfills": ["fetch", "promises"] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /config/webpack.config.client.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const merge = require('webpack-merge'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | const baseWebpackConfig = require('./webpack.config.base'); 7 | /** 8 | * react-loadable 提供的 webpack 插件 9 | * 该插件将生成源文件与打包后的chunk文件的映射关系 10 | * 结合服务端渲染用于预加载 11 | */ 12 | const { ReactLoadablePlugin } = require('react-loadable/webpack'); 13 | const util = require('./util'); 14 | const { isHttps } = require('../config/httpsConfig'); 15 | 16 | const isProd = process.env.NODE_ENV === 'production'; 17 | 18 | const webpackConfig = merge(baseWebpackConfig, { 19 | entry: { 20 | app: path.resolve(__dirname, '../src/client-entry.js'), 21 | }, 22 | output: { 23 | publicPath: '/', 24 | path: path.resolve(__dirname, '../dist/app'), 25 | filename: `static/js/[name].[${isProd ? 'chunkhash' : 'hash'}:8].js`, 26 | chunkFilename: 'static/js/[name].[chunkhash:8].js', 27 | }, 28 | module: { 29 | rules: util.styleLoaders({ 30 | sourceMap: isProd, 31 | usePostCSS: true, 32 | extract: isProd, 33 | }), 34 | }, 35 | plugins: [ 36 | new webpack.optimize.CommonsChunkPlugin({ 37 | name: 'vendor', 38 | minChunks: ({ resource }) => 39 | resource && 40 | resource.indexOf('node_modules') >= 0 && 41 | resource.match(/\.js$/), 42 | }), 43 | new webpack.optimize.CommonsChunkPlugin({ 44 | async: 'common-in-lazy', 45 | minChunks: ({ resource } = {}) => 46 | resource && resource.includes('node_modules') && /axios/.test(resource), 47 | }), 48 | new webpack.optimize.CommonsChunkPlugin({ 49 | async: 'used-twice', 50 | minChunks: (module, count) => count >= 2, 51 | }), 52 | new CopyWebpackPlugin([ 53 | { from: path.resolve(__dirname, '../src/favicon.ico') }, 54 | { 55 | from: path.resolve(__dirname, '../static/images'), 56 | to: 'static/images', 57 | }, 58 | ]), 59 | new HtmlWebpackPlugin({ 60 | title: 'Zoo Weekly - 政采云前端小报', 61 | filename: 'index.html', 62 | template: path.resolve(__dirname, '../src/index.ejs'), 63 | isHttps, // 是否开启https 64 | }), 65 | ], 66 | }); 67 | 68 | if (!isProd) { 69 | webpackConfig.module.rules.unshift({ 70 | enforce: 'pre', 71 | test: /\.js$/, 72 | exclude: /node_modules/, 73 | loader: 'eslint-loader', 74 | options: { 75 | // eslint options (if necessary) 76 | }, 77 | }); 78 | webpackConfig.devServer = require('./devServer'); 79 | webpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); 80 | } 81 | 82 | if (isProd) { 83 | webpackConfig.plugins.push( 84 | new ReactLoadablePlugin({ 85 | filename: path.join(__dirname, '../dist/react-loadable.json'), 86 | }) 87 | ); 88 | } 89 | 90 | module.exports = webpackConfig; 91 | -------------------------------------------------------------------------------- /src/views/Detail/index.less: -------------------------------------------------------------------------------- 1 | .detail-container { 2 | padding: 3rem 6%; 3 | display: flex; 4 | background-color: #f4f4f4; 5 | 6 | .detail { 7 | width: 100%; 8 | } 9 | 10 | .title { 11 | font-size: 24px; 12 | margin-bottom: 2rem; 13 | } 14 | 15 | .title time { 16 | margin-left: 0.3rem; 17 | font-size: 0.9rem; 18 | font-weight: normal; 19 | color: #333; 20 | } 21 | 22 | .list { 23 | padding-right: 12rem; 24 | } 25 | 26 | .item { 27 | list-style: circle inside; 28 | margin-bottom: 1.6rem; 29 | } 30 | 31 | .item h4 { 32 | display: inline-block; 33 | line-height: 1.6; 34 | font-weight: normal; 35 | max-width: 90%; 36 | vertical-align: middle; 37 | } 38 | 39 | .item a { 40 | margin-right: 0.5rem; 41 | text-decoration: none; 42 | color: #2466dc; 43 | vertical-align: middle; 44 | } 45 | 46 | .item a:hover { 47 | color: #1d25dc; 48 | } 49 | 50 | .item__tag { 51 | display: inline-block; 52 | font-size: 12px; 53 | padding: 5px 9px; 54 | line-height: 1; 55 | background: #eaeaea; 56 | border-radius: 3px; 57 | color: #555; 58 | vertical-align: top; 59 | margin-left: 0.4rem; 60 | } 61 | 62 | .item blockquote { 63 | font-size: 0.9rem; 64 | line-height: 1.4; 65 | color: #444; 66 | } 67 | 68 | /* aside */ 69 | aside { 70 | width: 400px; 71 | } 72 | 73 | .aside__title { 74 | font-size: 24px; 75 | margin-bottom: 2rem; 76 | } 77 | 78 | .weekly_list { 79 | margin-bottom: 3rem; 80 | } 81 | 82 | .weekly_list li { 83 | list-style: circle inside; 84 | line-height: 2; 85 | } 86 | 87 | .weekly_list a { 88 | text-decoration: none; 89 | color: #2484a0; 90 | } 91 | 92 | .weekly_list a:hover { 93 | color: #1359a0; 94 | } 95 | 96 | .weekly_list h4 { 97 | display: inline-block; 98 | font-weight: normal; 99 | } 100 | 101 | .tags { 102 | padding-right: 2rem; 103 | } 104 | 105 | .tags li { 106 | margin: 5px; 107 | display: inline-block; 108 | } 109 | 110 | .tags a { 111 | display: inline-block; 112 | padding: 0 10px; 113 | height: 28px; 114 | line-height: 26px; 115 | font-size: 12px; 116 | border-radius: 4px; 117 | box-sizing: border-box; 118 | background-color: cadetblue; 119 | border: 1px solid rgba(64, 158, 255, 0.2); 120 | white-space: nowrap; 121 | color: #fff; 122 | cursor: pointer; 123 | text-decoration: none; 124 | transition: transform 0.2s linear; 125 | } 126 | 127 | .tags a:hover { 128 | transform: translate3d(0, -2px, 0); 129 | } 130 | } 131 | 132 | /* mobile */ 133 | @media screen and (max-width: 414px) { 134 | .detail-container { 135 | display: block; 136 | 137 | .list { 138 | padding-right: 0; 139 | margin-bottom: 3rem; 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/layouts/basicLayout/index.less: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Chinese Quote", -apple-system, BlinkMacSystemFont, "Segoe UI", 3 | "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", 4 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 5 | "Segoe UI Symbol"; 6 | -webkit-font-smoothing: antialiased; 7 | } 8 | 9 | main { 10 | background-color: #f4f4f4; 11 | overflow: hidden; 12 | } 13 | 14 | .lmask { 15 | position: absolute; 16 | height: 100%; 17 | width: 100%; 18 | background-color: #000; 19 | bottom: 0; 20 | left: 0; 21 | right: 0; 22 | top: 0; 23 | z-index: 9999; 24 | opacity: 0.4; 25 | &.fixed { 26 | position: fixed; 27 | } 28 | &:before { 29 | content: ""; 30 | background-color: rgba(0, 0, 0, 0); 31 | border: 5px solid rgba(0, 183, 229, 0.9); 32 | opacity: 0.9; 33 | border-right: 5px solid rgba(0, 0, 0, 0); 34 | border-left: 5px solid rgba(0, 0, 0, 0); 35 | border-radius: 50px; 36 | box-shadow: 0 0 35px #2187e7; 37 | width: 50px; 38 | height: 50px; 39 | -moz-animation: spinPulse 1s infinite ease-in-out; 40 | -webkit-animation: spinPulse 1s infinite linear; 41 | margin: -25px 0 0 -25px; 42 | position: absolute; 43 | top: 50%; 44 | left: 50%; 45 | } 46 | &:after { 47 | content: ""; 48 | background-color: rgba(0, 0, 0, 0); 49 | border: 5px solid rgba(0, 183, 229, 0.9); 50 | opacity: 0.9; 51 | border-left: 5px solid rgba(0, 0, 0, 0); 52 | border-right: 5px solid rgba(0, 0, 0, 0); 53 | border-radius: 50px; 54 | box-shadow: 0 0 15px #2187e7; 55 | width: 30px; 56 | height: 30px; 57 | -moz-animation: spinoffPulse 1s infinite linear; 58 | -webkit-animation: spinoffPulse 1s infinite linear; 59 | margin: -15px 0 0 -15px; 60 | position: absolute; 61 | top: 50%; 62 | left: 50%; 63 | } 64 | } 65 | 66 | @-moz-keyframes spinPulse { 67 | 0% { 68 | -moz-transform: rotate(160deg); 69 | opacity: 0; 70 | box-shadow: 0 0 1px #2187e7; 71 | } 72 | 50% { 73 | -moz-transform: rotate(145deg); 74 | opacity: 1; 75 | } 76 | 100% { 77 | -moz-transform: rotate(-320deg); 78 | opacity: 0; 79 | } 80 | } 81 | 82 | @-moz-keyframes spinoffPulse { 83 | 0% { 84 | -moz-transform: rotate(0deg); 85 | } 86 | 100% { 87 | -moz-transform: rotate(360deg); 88 | } 89 | } 90 | 91 | @-webkit-keyframes spinPulse { 92 | 0% { 93 | -webkit-transform: rotate(160deg); 94 | opacity: 0; 95 | box-shadow: 0 0 1px #2187e7; 96 | } 97 | 50% { 98 | -webkit-transform: rotate(145deg); 99 | opacity: 1; 100 | } 101 | 100% { 102 | -webkit-transform: rotate(-320deg); 103 | opacity: 0; 104 | } 105 | } 106 | 107 | @-webkit-keyframes spinoffPulse { 108 | 0% { 109 | -webkit-transform: rotate(0deg); 110 | } 111 | 100% { 112 | -webkit-transform: rotate(360deg); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/common/style/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | line-height: 1.15; 3 | -webkit-text-size-adjust: 100%; 4 | } 5 | body { 6 | margin: 0; 7 | position: relative; 8 | } 9 | main { 10 | display: block; 11 | } 12 | h1 { 13 | font-size: 2em; 14 | } 15 | h1, 16 | h2, 17 | h3, 18 | h4, 19 | h5, 20 | h6 { 21 | margin: 0; 22 | } 23 | hr { 24 | box-sizing: content-box; 25 | height: 0; 26 | overflow: visible; 27 | } 28 | pre { 29 | font-family: monospace, monospace; 30 | font-size: 1em; 31 | } 32 | a { 33 | background-color: transparent; 34 | } 35 | abbr[title] { 36 | border-bottom: none; 37 | text-decoration: underline; 38 | text-decoration: underline dotted; 39 | } 40 | b, 41 | strong { 42 | font-weight: bolder; 43 | } 44 | ol, 45 | ul, 46 | dl, 47 | dd, 48 | p { 49 | margin: 0; 50 | padding: 0; 51 | } 52 | ol, 53 | ul { 54 | list-style: none; 55 | } 56 | code, 57 | kbd, 58 | samp { 59 | font-family: monospace, monospace; 60 | font-size: 1em; 61 | } 62 | small { 63 | font-size: 80%; 64 | } 65 | sub, 66 | sup { 67 | font-size: 75%; 68 | line-height: 0; 69 | position: relative; 70 | vertical-align: baseline; 71 | } 72 | sub { 73 | bottom: -0.25em; 74 | } 75 | sup { 76 | top: -0.5em; 77 | } 78 | img { 79 | border-style: none; 80 | } 81 | button, 82 | input, 83 | optgroup, 84 | select, 85 | textarea { 86 | font-family: inherit; 87 | font-size: 100%; 88 | line-height: 1.15; 89 | margin: 0; 90 | } 91 | button, 92 | input { 93 | overflow: visible; 94 | } 95 | button, 96 | select { 97 | text-transform: none; 98 | } 99 | button, 100 | [type="button"], 101 | [type="reset"], 102 | [type="submit"] { 103 | -webkit-appearance: button; 104 | } 105 | button::-moz-focus-inner, 106 | [type="button"]::-moz-focus-inner, 107 | [type="reset"]::-moz-focus-inner, 108 | [type="submit"]::-moz-focus-inner { 109 | border-style: none; 110 | padding: 0; 111 | } 112 | button:-moz-focusring, 113 | [type="button"]:-moz-focusring, 114 | [type="reset"]:-moz-focusring, 115 | [type="submit"]:-moz-focusring { 116 | outline: 1px dotted ButtonText; 117 | } 118 | fieldset { 119 | padding: 0.35em 0.75em 0.625em; 120 | } 121 | legend { 122 | box-sizing: border-box; 123 | color: inherit; 124 | display: table; 125 | max-width: 100%; 126 | padding: 0; 127 | white-space: normal; 128 | } 129 | progress { 130 | vertical-align: baseline; 131 | } 132 | textarea { 133 | overflow: auto; 134 | } 135 | [type="checkbox"], 136 | [type="radio"] { 137 | box-sizing: border-box; 138 | padding: 0; 139 | } 140 | [type="number"]::-webkit-inner-spin-button, 141 | [type="number"]::-webkit-outer-spin-button { 142 | height: auto; 143 | } 144 | [type="search"] { 145 | -webkit-appearance: textfield; 146 | outline-offset: -2px; 147 | } 148 | [type="search"]::-webkit-search-decoration { 149 | -webkit-appearance: none; 150 | } 151 | ::-webkit-file-upload-button { 152 | -webkit-appearance: button; 153 | font: inherit; 154 | } 155 | details { 156 | display: block; 157 | } 158 | summary { 159 | display: list-item; 160 | } 161 | template { 162 | display: none; 163 | } 164 | [hidden] { 165 | display: none; 166 | } 167 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-universal-ssr", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev:client": "cross-env NODE_ENV=development webpack-dev-server --config ./config/webpack.config.client.js ", 8 | "dev:server": "nodemon ./server/index.js --watch config --watch server", 9 | "dev": "npm run dev:client && npm run dev:server", 10 | "build:client": "cross-env NODE_ENV=production webpack --config ./config/webpack.config.client.js", 11 | "build:server": "cross-env NODE_ENV=production webpack --config ./config/webpack.config.server.js", 12 | "build": "npm run clear && npm run build:client && npm run build:server", 13 | "clear": "rimraf dist", 14 | "start": "cross-env NODE_ENV=production node dist/server/index.js", 15 | "lint": "eslint --ext .js --ext .jsx src/", 16 | "ssr": "pm2 start process.json --env production" 17 | }, 18 | "author": "", 19 | "license": "ISC", 20 | "devDependencies": { 21 | "babel-core": "^6.26.0", 22 | "babel-eslint": "^8.2.2", 23 | "babel-loader": "^7.1.2", 24 | "babel-plugin-add-module-exports": "^0.2.1", 25 | "babel-plugin-dynamic-import-node": "^1.2.0", 26 | "babel-plugin-import": "^1.11.0", 27 | "babel-plugin-remove-webpack": "^1.1.0", 28 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 29 | "babel-plugin-transform-decorators-legacy": "^1.3.5", 30 | "babel-plugin-transform-runtime": "^6.23.0", 31 | "babel-polyfill": "^6.26.0", 32 | "babel-preset-env": "^1.6.1", 33 | "babel-preset-react": "^6.24.1", 34 | "babel-preset-stage-0": "^6.24.1", 35 | "babel-register": "^6.26.0", 36 | "copy-webpack-plugin": "^4.6.0", 37 | "cross-env": "^5.1.1", 38 | "css-loader": "^0.28.7", 39 | "eslint": "^4.18.2", 40 | "eslint-config-airbnb": "^16.1.0", 41 | "eslint-loader": "^2.0.0", 42 | "eslint-plugin-babel": "^4.1.2", 43 | "eslint-plugin-compat": "^2.4.0", 44 | "eslint-plugin-import": "^2.12.0", 45 | "eslint-plugin-jsx-a11y": "^6.0.3", 46 | "eslint-plugin-react": "^7.9.1", 47 | "extract-text-webpack-plugin": "^3.0.2", 48 | "file-loader": "^1.1.6", 49 | "html-webpack-plugin": "^2.30.1", 50 | "husky": "^1.3.0", 51 | "isomorphic-style-loader": "^4.0.0", 52 | "less": "^2.7.3", 53 | "less-loader": "^4.1.0", 54 | "nodemon": "^1.12.5", 55 | "npm-run-all": "^4.1.2", 56 | "pm2": "^4.5.6", 57 | "postcss-flexbugs-fixes": "^4.1.0", 58 | "postcss-loader": "^2.0.9", 59 | "progress-bar-webpack-plugin": "^1.10.0", 60 | "rimraf": "^2.6.2", 61 | "style-loader": "^0.19.1", 62 | "uglifyjs-webpack-plugin": "^1.3.0", 63 | "url-loader": "^0.6.2", 64 | "webpack": "^3.12.0", 65 | "webpack-dev-server": "^2.11.3", 66 | "webpack-manifest-plugin": "^1.3.2", 67 | "webpack-merge": "^4.1.4", 68 | "webpack-node-externals": "^1.7.2" 69 | }, 70 | "dependencies": { 71 | "axios": "^0.18.0", 72 | "echarts": "^4.2.1", 73 | "history": "^4.7.2", 74 | "koa": "^2.4.1", 75 | "koa-static": "^5.0.0", 76 | "koa-static-cache": "^5.1.1", 77 | "koa2-cors": "^2.0.5", 78 | "prop-types": "^15.6.2", 79 | "qs": "^6.6.0", 80 | "react": "^16.7.0-alpha.2", 81 | "react-dom": "^16.7.0-alpha.2", 82 | "react-helmet": "^5.2.0", 83 | "react-loadable": "^5.5.0", 84 | "react-redux": "^5.0.6", 85 | "react-router": "^4.3.1", 86 | "react-router-config": "^1.0.0-beta.4", 87 | "react-router-dom": "^4.3.1", 88 | "redux": "^3.7.2", 89 | "redux-thunk": "^2.2.0" 90 | }, 91 | "husky": { 92 | "hooks": { 93 | "pre-commit": "npm run lint" 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /server/render.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { renderToString } = require('react-dom/server'); 4 | const { Helmet } = require('react-helmet'); 5 | const { getBundles } = require('react-loadable/webpack'); 6 | const { matchRoutes } = require('react-router-config'); 7 | const { matchPath } = require('react-router-dom'); 8 | const stats = require('../react-loadable.json'); 9 | const { configureStore, createApp, routesConfig } = require('./server-entry'); 10 | 11 | const createStore = (configureStore) => { 12 | const store = configureStore(); 13 | return store; 14 | }; 15 | 16 | const createTags = (modules) => { 17 | /** 18 | * 根据生产环境下 webpack 打包生成的 react-loadable.json 数据 19 | * 挑选要加载的chunks 20 | */ 21 | const bundles = getBundles(stats, modules); 22 | const scriptfiles = bundles.filter(bundle => bundle.file.endsWith('.js')); 23 | console.log('n\n\n\n\n\\n***************', scriptfiles); 24 | 25 | const stylefiles = bundles.filter(bundle => bundle.file.endsWith('.css')); 26 | const scripts = scriptfiles 27 | .map(script => ``) 28 | .join('\n'); 29 | const styles = stylefiles 30 | .map(style => ``) 31 | .join('\n'); 32 | return { 33 | scripts, 34 | styles, 35 | }; 36 | }; 37 | 38 | const prepHTML = ( 39 | data, 40 | { html, head, rootString, scripts, styles, initState, cssStr } 41 | ) => { 42 | console.log(data, html, head); 43 | data = data.replace('', 46 | `${head} \n ${styles}` 47 | ); 48 | data = data.replace( 49 | '
    ', 50 | `
    ${rootString}
    ` 51 | ); 52 | data = data.replace( 53 | '', 54 | ` \n ` 57 | ); 58 | data = data.replace('', `${scripts}`); 59 | return data; 60 | }; 61 | 62 | const getMatch = (routesArray, url) => { 63 | return routesArray.some(router => 64 | matchPath(url, { 65 | path: router.path, 66 | exact: router.exact, 67 | }) 68 | ); 69 | }; 70 | 71 | const makeup = (ctx, store, createApp, html) => { 72 | /* 获取初始state */ 73 | const initState = store.getState(); 74 | const context = { 75 | css: [], 76 | }; 77 | 78 | /* 当前页面要加载的chunk[App 中的 Loadable.Capture 会告诉你的] */ 79 | const modules = []; 80 | 81 | const rootString = renderToString( 82 | createApp({ 83 | modules, 84 | store, 85 | context, 86 | url: ctx.url, 87 | }) 88 | ); 89 | 90 | const { scripts, styles } = createTags(modules); 91 | 92 | /* SEO优化 */ 93 | const helmet = Helmet.renderStatic(); 94 | 95 | // const cssStr = context.css ? context.css : ''; 96 | const cssStr = context.css ? context.css.join('') : ''; 97 | 98 | /* 填充好html */ 99 | const renderedHtml = prepHTML(html, { 100 | html: helmet.htmlAttributes.toString(), 101 | head: helmet.meta.toString() + helmet.link.toString(), 102 | rootString, 103 | scripts, 104 | styles, 105 | initState, 106 | cssStr, 107 | }); 108 | 109 | return renderedHtml; 110 | }; 111 | 112 | module.exports = async (ctx, next) => { 113 | /* 读取打包后的html文件 */ 114 | const html = fs.readFileSync( 115 | path.join(path.resolve(__dirname, '../app'), 'index.html'), 116 | 'utf-8' 117 | ); 118 | 119 | /* 创建store */ 120 | const store = createStore(configureStore); 121 | 122 | /* 匹配上的路径 */ 123 | const branch = matchRoutes(routesConfig, ctx.url); 124 | 125 | /* 在服务端拉取数据 */ 126 | const promises = branch.map(({ route }) => { 127 | /* 拿到url params query等 */ 128 | const matchInfo = matchPath(ctx.url, route); 129 | return route.loadData 130 | ? route.loadData(store, matchInfo) 131 | : Promise.resolve(null); 132 | }); 133 | 134 | /* 等待拉取数据结束 */ 135 | await Promise.all(promises).catch(err => console.log('err:---', err)); 136 | 137 | /* 是否匹配上 */ 138 | const isMatch = getMatch(routesConfig, ctx.url); 139 | 140 | /* 匹配上 */ 141 | if (isMatch) { 142 | /* 服务端react渲染 */ 143 | const renderedHtml = await makeup(ctx, store, createApp, html); 144 | ctx.body = renderedHtml; 145 | } 146 | 147 | await next(); 148 | }; 149 | -------------------------------------------------------------------------------- /src/views/Detail/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router-dom'; 4 | import { WithStyles } from '@components'; 5 | import request from '@utils/request'; 6 | import { isNumeric } from '@utils/utils'; 7 | import { Helmet } from 'react-helmet'; 8 | import { actionCreators } from './store'; 9 | import styles from './index.less'; 10 | 11 | @WithStyles(styles) 12 | @connect( 13 | state => ({ detailStore: state.detailStore }), 14 | dispatch => ({ 15 | getDetail: week => dispatch(actionCreators.getDetailEffect(week)), 16 | resetDetail: () => dispatch(actionCreators.resetDetail()), 17 | }) 18 | ) 19 | export default class Detail extends Component { 20 | constructor(props) { 21 | super(props); 22 | this.state = { 23 | date: null, 24 | homeList: [], 25 | tags: [], 26 | }; 27 | } 28 | 29 | componentDidMount = () => { 30 | this.getDate(); 31 | this.getHomeList(); 32 | this.getTags(); 33 | if (!this.props.detailStore.weeklyDetail.length) { 34 | const { week } = this.props.match.params; 35 | this.props.getDetail(week); 36 | } 37 | }; 38 | 39 | componentDidUpdate = (prevProps) => { 40 | const { week } = this.props.match.params; 41 | if (week !== prevProps.match.params.week) { 42 | this.props.getDetail(week); 43 | this.getDate(); 44 | } 45 | }; 46 | 47 | componentWillUnmount = () => { 48 | this.props.resetDetail(); 49 | }; 50 | 51 | getDate = () => { 52 | const week = this.props.match.params.week; 53 | if (!isNumeric(week)) return; 54 | request 55 | .get(`/api/weeks/list?count=${this.props.match.params.week}`) 56 | .then((res) => { 57 | if (res.data && res.data[0]) { 58 | this.setState({ 59 | date: new Date(res.data[0].datetime).toLocaleDateString(), 60 | }); 61 | } 62 | }); 63 | }; 64 | 65 | getHomeList = () => { 66 | request.get('/api/weeks/list').then((res) => { 67 | this.setState({ 68 | homeList: res.data, 69 | }); 70 | }); 71 | }; 72 | 73 | getTags = () => { 74 | request.get('/api/categories/list').then((res) => { 75 | this.setState({ 76 | tags: res.data, 77 | }); 78 | }); 79 | }; 80 | 81 | renderDetail = (weeklyDetail) => { 82 | if (!weeklyDetail || !weeklyDetail.length) return '暂无数据'; 83 | return weeklyDetail.map(card => ( 84 |
  • 85 | 86 |

    87 | {card.title} {card.category} 88 |

    89 |
    90 |
    91 | {card.description} 92 |
    93 |
  • 94 | )); 95 | }; 96 | 97 | renderHistoryIndex = (homeList) => { 98 | if (!homeList || !homeList.length) return '暂无数据'; 99 | const reverseList = homeList.slice().reverse().slice(0, 10); 100 | return reverseList.map(card => ( 101 |
  • 102 | 107 |

    {card.title}

    108 | 109 |
  • 110 | )); 111 | }; 112 | 113 | renderTags = (tags) => { 114 | if (!tags || !tags.length) return '暂无数据'; 115 | return tags.map(tag => ( 116 |
  • 117 | 122 |

    {tag.name}

    123 | 124 |
  • 125 | )); 126 | }; 127 | 128 | render() { 129 | const { date, homeList, tags } = this.state; 130 | const { weeklyDetail } = this.props.detailStore; 131 | const { week } = this.props.match.params; 132 | return ( 133 |
    134 | 135 | {isNumeric(week) ? ( 136 | 政采云前端小报第{week}期 - Zoo Weekly! 137 | ) : ( 138 | {week} - Zoo Weekly! 政采云前端小报 139 | )} 140 | 141 |
    142 | {isNumeric(week) ? ( 143 |

    144 | 政采云前端小报第{week}期 145 |

    146 | ) : ( 147 |

    #{week}

    148 | )} 149 |
      {this.renderDetail(weeklyDetail)}
    150 |
    151 | 152 | 159 |
    160 | ); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/views/Summary/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import echarts from 'echarts/lib/echarts'; 4 | import 'echarts/lib/chart/bar'; 5 | import 'echarts/lib/chart/pie'; 6 | import 'echarts/lib/component/tooltip'; 7 | import 'echarts/lib/component/title'; 8 | import { actionCreators } from './store'; 9 | 10 | import './index.less'; 11 | 12 | @connect( 13 | state => ({ summaryStore: state.summaryStore }), 14 | dispatch => ({ 15 | getSummary: () => dispatch(actionCreators.getSummaryEffect()), 16 | }) 17 | ) 18 | export default class Summary extends Component { 19 | componentDidMount() { 20 | const { overview } = this.props.summaryStore; 21 | if (!overview.articleTotal) { 22 | this.props.getSummary().then(() => { 23 | this.initBarEcharts(this.props.summaryStore.overview); 24 | this.initCategoryChart(this.props.summaryStore.overview); 25 | }); 26 | } else { 27 | this.initBarEcharts(overview); 28 | this.initCategoryChart(overview); 29 | } 30 | } 31 | 32 | initBarEcharts = (overview) => { 33 | const { weekMap = [], categoryMap = [], monthMap = [] } = overview; 34 | const xAxis = []; 35 | const data = []; 36 | const CategoryAxis = []; 37 | const CategoryData = []; 38 | const monthAxis = []; 39 | const monthData = []; 40 | 41 | weekMap.map((item) => { 42 | xAxis.push(`第 ${item.week} 周`); 43 | data.push(item.count); 44 | }); 45 | categoryMap.map((item) => { 46 | CategoryAxis.push(`${item.category}`); 47 | CategoryData.push(item.count); 48 | }); 49 | monthMap.map((item) => { 50 | monthAxis.push(`${item.month} 月`); 51 | monthData.push(item.count); 52 | }); 53 | 54 | const categoryOption = { 55 | title: { 56 | text: '小报分类数据', 57 | }, 58 | tooltip: {}, 59 | xAxis: { 60 | data: CategoryAxis, 61 | }, 62 | yAxis: {}, 63 | series: [ 64 | { 65 | name: '分类统计', 66 | type: 'bar', 67 | data: CategoryData, 68 | }, 69 | ], 70 | }; 71 | 72 | const monthOption = { 73 | title: { 74 | text: '小报月度数据', 75 | }, 76 | tooltip: {}, 77 | xAxis: { 78 | data: monthAxis, 79 | }, 80 | yAxis: {}, 81 | series: [ 82 | { 83 | name: '分类统计', 84 | type: 'bar', 85 | data: monthData, 86 | }, 87 | ], 88 | }; 89 | 90 | const option = { 91 | title: { 92 | text: '小报周度数据', 93 | }, 94 | legend: { 95 | data: ['bar'], 96 | align: 'left', 97 | }, 98 | toolbox: { 99 | feature: { 100 | magicType: { 101 | type: ['stack', 'tiled'], 102 | }, 103 | dataView: {}, 104 | saveAsImage: { 105 | pixelRatio: 2, 106 | }, 107 | }, 108 | }, 109 | tooltip: {}, 110 | xAxis: { 111 | data: xAxis, 112 | silent: false, 113 | splitLine: { 114 | show: false, 115 | }, 116 | }, 117 | yAxis: {}, 118 | series: [ 119 | { 120 | name: '周数:发布文章数', 121 | type: 'bar', 122 | data, 123 | animationDelay(idx) { 124 | return idx * 10; 125 | }, 126 | }, 127 | ], 128 | animationEasing: 'elasticOut', 129 | animationDelayUpdate(idx) { 130 | return idx * 5; 131 | }, 132 | }; 133 | 134 | const WChart = echarts.init(document.getElementById('bar-chart')); 135 | WChart.setOption(option); 136 | 137 | const CChart = echarts.init(document.getElementById('category-chart-all')); 138 | CChart.setOption(categoryOption); 139 | 140 | const MChart = echarts.init(document.getElementById('month-chart')); 141 | MChart.setOption(monthOption); 142 | }; 143 | 144 | initCategoryChart = (overview) => { 145 | const { categoryMap = [] } = overview; 146 | const xAxis = []; 147 | const data = []; 148 | 149 | categoryMap.slice(0, 6).map((item) => { 150 | xAxis.push(item.category); 151 | data.push({ 152 | value: item.count, 153 | name: item.category, 154 | }); 155 | }); 156 | const option = { 157 | title: { 158 | text: '小报分类排名前六数据', 159 | }, 160 | tooltip: { 161 | trigger: 'item', 162 | formatter: '{a}
    {b}: {c} ({d}%)', 163 | }, 164 | legend: { 165 | orient: 'vertical', 166 | x: 'left', 167 | data: xAxis, 168 | }, 169 | series: [ 170 | { 171 | name: '分类', 172 | type: 'pie', 173 | radius: ['40%', '55%'], 174 | label: { 175 | normal: { 176 | formatter: '{a|{a}}{abg|}\n{hr|}\n {b|{b}:}{c} {per|{d}%} ', 177 | backgroundColor: '#eee', 178 | borderColor: '#aaa', 179 | borderWidth: 1, 180 | borderRadius: 4, 181 | rich: { 182 | a: { 183 | color: '#999', 184 | lineHeight: 22, 185 | align: 'center', 186 | }, 187 | hr: { 188 | borderColor: '#aaa', 189 | width: '100%', 190 | borderWidth: 0.5, 191 | height: 0, 192 | }, 193 | b: { 194 | fontSize: 16, 195 | lineHeight: 33, 196 | }, 197 | per: { 198 | color: '#eee', 199 | backgroundColor: '#334455', 200 | padding: [2, 4], 201 | borderRadius: 2, 202 | }, 203 | }, 204 | }, 205 | }, 206 | data, 207 | }, 208 | ], 209 | }; 210 | 211 | const myChart = echarts.init(document.getElementById('category-chart')); 212 | myChart.setOption(option); 213 | }; 214 | 215 | render() { 216 | const { overview } = this.props.summaryStore; 217 | return ( 218 | 219 |

    政采云前端小报半年期数据

    220 |
    221 | 222 | 累计文章: 223 | 224 | {overview ? ((overview.articleTotal || [])[0] || {}).count : null} 225 | 226 | 篇 227 | 228 | 229 | 累计分类: 230 | 231 | {overview 232 | ? ((overview.categoryTotal || [])[0] || {}).count 233 | : null} 234 | 235 | 类 236 | 237 |
    238 | 239 |
    243 |
    247 |
    251 |
    255 | 256 | ); 257 | } 258 | } 259 | --------------------------------------------------------------------------------