├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .idea ├── codeStyles │ └── codeStyleConfig.xml ├── dictionaries │ └── dsying.xml ├── eslintPlugin.xml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml ├── react-cNode.iml ├── reactTemplatesPlugin.xml ├── vcs.xml └── workspace.xml ├── README.md ├── build ├── qiNiu.config.js ├── qiNiuUpload.js ├── webpack.config.client.js └── webpack.config.server.js ├── client ├── .eslintrc ├── app.js ├── config │ └── router.jsx ├── server-entry.js ├── store │ ├── app-state.js │ ├── index.js │ └── topic-store.js ├── template.html ├── util │ ├── constant.js │ └── http.js └── views │ ├── App.jsx │ ├── layout │ ├── app-bar.jsx │ ├── container.jsx │ └── pagination.jsx │ ├── topic-create │ ├── index.js │ └── style.js │ ├── topic-detail │ ├── index.js │ ├── reply-item.jsx │ └── style.js │ ├── topic-list │ ├── index.js │ ├── list-item.jsx │ └── style.js │ └── user │ ├── info.jsx │ ├── login.jsx │ ├── styles │ ├── bg.jpg │ ├── login-style.js │ ├── user-info-style.js │ └── user-style.js │ └── user.jsx ├── favicon.ico ├── l1.jpg ├── nodemon.json ├── package.json ├── pm2.yml └── server ├── server.js └── util ├── dev-static.js ├── handle-login.js └── proxy.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | // babel 支持的语法 3 | "presets": [ "@babel/preset-env", "@babel/preset-react", ], 4 | "plugins": [ 5 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 6 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 7 | "react-hot-loader/babel", 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_lint = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/* 2 | /dist/* 3 | yarn.lock 4 | .idea/encodings.xml 5 | .idea/jsLibraryMappings.xml 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/dictionaries/dsying.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/eslintPlugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/react-cNode.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/reactTemplatesPlugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Avatar 61 | user 62 | dateFormat 63 | getFrom 64 | /user/login 65 | from 66 | shouldComponentUpdate 67 | MenuIcon 68 | appState 69 | console.log 70 | Button 71 | topic 72 | /user/info 73 | SimpleMD 74 | /list 75 | serverEntry 76 | putExtra 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | true 130 | 131 | true 132 | true 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 1547261343512 223 | 224 | 225 | 1547261343512 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | ## 目录结构 4 | 5 | + views: 存放项目功能模块的页面,需要根据路由配置情况分割子级目录 6 | 7 | + config: 存放一些配置文件,比如第三方类库引用,路由配置等 8 | 9 | + store: 存放项目store相关的文件,包括数据获取的封装等 10 | 11 | + components: 存放非业务组件,或者在多个业务间都需要用到的功能组件 12 | 13 | ## 路由 14 | 15 | ### 什么是路由? 16 | 17 | > 路由是用来区分一个网站不同功能模块的地址,浏览器通过访问同一站点下的不同路由,来访问网站的不同功能。同样路由也能让开发者区分返回的内容 18 | 19 | ### 如何做前端路由 20 | 21 | > HTML5 API中的 history 能够让我们控制url跳转之后并不刷新页面,而是交给js代码进行相应的操作,在history api 出现之前,我们可以使用hash跳转来实现 22 | 23 | ### [react-router-dom](https://reacttraining.com/react-router/web/guides/quick-start) 24 | 25 | ```sh 26 | yarn add react-router-dom 27 | ``` 28 | ### BrowserRouter 29 | 用 组件包裹整个App系统后,就是通过html5的history来实现无刷新条件下的前端路由 30 | ```js 31 | // app.js 32 | import { BrowserRouter } from 'react-router-dom' 33 | import App from './views/App' 34 | 35 | 36 | 37 | ``` 38 | 39 | ### Route 40 | Route 做的事情就是匹配相应的location中的地址,匹配成功后渲染对应的组件 41 | ```js 42 | //router.jsx 43 | export default () => [ 44 | } exact />, 45 | , 46 | , 47 | ] 48 | ``` 49 | + path:当location中的url改变后,会与Route中的path属性做匹配,path决定了与路由或者url相关的渲染效果。 50 | 51 | + exact: 如果有exact,只有url地址完全与path相同,才会匹配。如果没有exact属性,url的地址不完全相同,也会匹配 52 | 53 | 当Route组件与某一url匹配成功后,就会继续去渲染。那么什么属性决定去渲染哪个组件或者样式呢,Route的component、render、children决定渲染的内容。 54 | 55 | + component:该属性接受一个React组件,当url匹配成功,就会渲染该组件 56 | + render:func 该属性接受一个返回React Element的函数,当url匹配成功,渲染覆该返回的元素 57 | + children:与render相似,接受一个返回React Element的函数,但是不同点是,无论url与当前的Route的path匹配与否,children的内容始终会被渲染出来。 58 | 59 | 并且这3个属性所接受的方法或者组件,都会有3个参数 60 | + location 61 | + match 62 | + history 63 | 64 | 65 | 如果是组件,那么组件的props中会存在从Link传递过来的location,match以及history。 66 | ### Link 67 | Link 决定的是如何在页面内改变url 68 | ```js 69 | //app.jsx 70 | render() { 71 | return [ 72 | 73 | 首页 74 | 详情页 75 | , 76 | , 77 | ] 78 | } 79 | ``` 80 | 81 | 82 | ### Link 标签之外如何切换路由 83 | ```js 84 | // 通过context 获取 router 85 | static contextTypes = { 86 | router: PropTypes.object, 87 | } 88 | const { router } = this.context 89 | // router.history.replace('/user/info') 90 | // 通过html5新增API history.pushState() 更改路由 91 | router.history.push({ 92 | pathname: '/user/login' 93 | }) 94 | ``` 95 | 96 | 97 | ## [Mobx](https://cn.mobx.js.org/) 98 | 99 | Mobx 是flux的后起之秀,其以更简单的使用和更少的概念,让flux使用起来更加简单, 100 | 相比于Redux有mutation,action,reducer,dispatch等概念,Mobx更符合对一个store的增删改查。 101 | 102 | 下面我们来比较一下 redux和mobx 103 | 104 | ### redux 105 | 106 | ```js 107 | // actionType 108 | const ADD_ACTION = 'ADD' 109 | // actionCreator 110 | const add = (num) => { 111 | return { 112 | type: ADD_ACTION, 113 | num, 114 | } 115 | } 116 | 117 | const initState = { 118 | count: 0, 119 | } 120 | // reducer 121 | const reducer = (state = initState, action) => { 122 | switch(action.type){ 123 | case ADD_ACTION: 124 | return Object.assign({}, state, { 125 | count: state.count + action.num 126 | }) 127 | default: 128 | return state 129 | } 130 | } 131 | // store 132 | const reduxStore = createStore(reducer) 133 | // dispatch 134 | reduxStore.dispatch(add(1)) 135 | ``` 136 | 137 | ### mobx 138 | 139 | ```js 140 | import { objservable, action } from 'mobx' 141 | 142 | const mobxStore = observable({ 143 | count: 0, 144 | add: action((num) => { 145 | this.count += num 146 | }) 147 | }) 148 | 149 | mobxStore.add(1) 150 | ``` 151 | 152 | ### mobx会用到 ES6的 Decorator(装饰器)4 153 | 154 | #### 什么是装饰器 155 | 156 | > 如果想了解decorator可以看[阮一峰老师的文章](http://es6.ruanyifeng.com/#docs/decorator) 157 | 158 | #### babel如何配置才能支持装饰器 159 | 160 | > [babel 如何启用装饰器语法](https://cn.mobx.js.org/best/decorators.html#%E5%90%AF%E7%94%A8%E8%A3%85%E9%A5%B0%E5%99%A8%E8%AF%AD%E6%B3%95) 161 | 162 | > 对于 babel 7, 参见 [issue 1352](https://github.com/mobxjs/mobx/issues/1352) 来查看设置示例 163 | 164 | ```sh 165 | yarn add @babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties --dev 166 | ``` 167 | 168 | ```sh 169 | { 170 | "presets": ["@babel/preset-env"], 171 | "plugins": [ 172 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 173 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 174 | ] 175 | } 176 | ``` 177 | 178 | ### mobx 快速入门 179 | 180 | #### [@observable](https://cn.mobx.js.org/refguide/observable-decorator.html#observable) 将 state 标记为 可观察状态 181 | 182 | 183 | 184 | ```js 185 | import { observable, computed } from "mobx"; 186 | 187 | class OrderLine { 188 | @observable price = 0; 189 | @observable amount = 1; 190 | 191 | @computed get total() { 192 | return this.price * this.amount; 193 | } 194 | } 195 | ``` 196 | 197 | #### [@computed](https://cn.mobx.js.org/refguide/computed-decorator.html#computed) 计算值 198 | 199 | 根据现有的状态或其它计算值衍生出的值 200 | 201 | 如果已经[启用 decorators](https://cn.mobx.js.org/best/decorators.html) 的话,可以在任意类属性的 [getter](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/get) 上使用 @computed 装饰器来声明式的创建计算属性 202 | 203 | ```js 204 | import {observable, computed} from "mobx"; 205 | 206 | class OrderLine { 207 | @observable price = 0; 208 | @observable amount = 1; 209 | 210 | constructor(price) { 211 | this.price = price; 212 | } 213 | 214 | @computed get total() { 215 | return this.price * this.amount; 216 | } 217 | } 218 | ``` 219 | 220 | #### [@observer](https://cn.mobx.js.org/refguide/observer-component.html#observer) 观察者 && [@inject(stores)](https://cn.mobx.js.org/refguide/observer-component.html#%E4%BD%BF%E7%94%A8-inject-%E5%B0%86%E7%BB%84%E4%BB%B6%E8%BF%9E%E6%8E%A5%E5%88%B0%E6%8F%90%E4%BE%9B%E7%9A%84-stores) 注入store到当前组件 221 | 222 | > observer 函数/装饰器可以用来将 React 组件转变成响应式组件 223 | > 它用 mobx.autorun 包装了组件的 render 函数以确保任何组件渲染中使用的 **数据变化时** 都可以 **强制刷新组件** 224 | 225 | > mobx-react 包还提供了 Provider 组件,它使用了 React 的上下文(context)机制,可以用来向下传递 stores 226 | > 要连接到这些 stores,需要传递一个 stores 名称的列表给 inject,这使得 stores 可以作为组件的 props 使用 227 | 228 | ```js 229 | 230 | 231 | 232 | 233 | @inject('appState') @observer 234 | class TopicList extends React.Component { 235 | 236 | handleChange = (e) => { 237 | const { appState } = this.props 238 | appState.changeName(e.target.value) 239 | } 240 | 241 | render() { 242 | const { appState } = this.props 243 | return ( 244 | 245 | 246 | { appState.msg } 247 | 248 | ) 249 | } 250 | } 251 | ``` 252 | 253 | #### [autorun](https://cn.mobx.js.org/refguide/autorun.html#autorun) 254 | 255 | 经验法则:如果你有一个函数应该自动运行,但不会产生一个新的值,请使用autorun。 其余情况都应该使用 computed 256 | 257 | ```js 258 | autorun(() => { 259 | console.log(appState.msg) 260 | }) 261 | ``` 262 | 263 | ## cnode API代理实现 264 | 265 | + handle-login.js 代理 longin 请求 266 | 267 | + proxy.js 代理剩余请求 268 | 269 |  270 | 271 | 272 | 273 | 274 | 275 | -------------------------------------------------------------------------------- /build/qiNiu.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cdn: { 3 | ak: 'oncKoImBIVRckKrIB0b3wS-6bknIozP4VtYAXDZT', 4 | sk: 'IIPYWlaUijIm-ro5Bj3dCGvkUojLIOF-IHDDiDFh', 5 | bucket: 'cnode', 6 | host: 'http://pn0tnrsa8.bkt.clouddn.com/', 7 | } 8 | } 9 | 10 | /* 11 | * 七牛云CDN 配置文件 12 | * bucket: 空间名 13 | * host:CDN域名 14 | * */ 15 | -------------------------------------------------------------------------------- /build/qiNiuUpload.js: -------------------------------------------------------------------------------- 1 | const qiniu = require('qiniu') 2 | const fs = require('fs'); 3 | const path = require('path') 4 | 5 | // 上传静态资源所需的配置 6 | const cdnConfig = require('./qiNiu.config').cdn 7 | 8 | // 不需要上传的文件 9 | const noNeedUploadFileList = ['index.html', 'server.ejs', 'server-entry.js'] 10 | 11 | const { 12 | ak, sk, bucket, 13 | } = cdnConfig 14 | 15 | // 创建各种上传凭证之前,我们需要定义好其中鉴权对象mac 16 | const mac = new qiniu.auth.digest.Mac(ak, sk) 17 | 18 | const doUpload = (key, file) => { 19 | 20 | // 创建上传凭证token 21 | const options = { 22 | scope: bucket + ':' + key, 23 | } 24 | const putPolicy = new qiniu.rs.PutPolicy(options) 25 | const uploadToken = putPolicy.uploadToken(mac) 26 | 27 | // 服务端直传 28 | /* 29 | * 七牛存储支持空间创建在不同的机房, 30 | * 在使用七牛的 Node.js SDK 中的FormUploader和ResumeUploader上传文件之前, 31 | * 必须要构建一个上传用的config对象,在该对象中,可以指定空间对应的zone以及其他的一些影响上传的参数 32 | * */ 33 | const config = new qiniu.conf.Config() 34 | config.zone = qiniu.zone.Zone_z0 //z0代表 华东机房 35 | const formUploader = new qiniu.form_up.FormUploader(config) 36 | const putExtra = new qiniu.form_up.PutExtra() 37 | 38 | return new Promise((resolve, reject) => { 39 | formUploader.putFile(uploadToken, key, file, putExtra, (err, body, info) => { 40 | if (err) { 41 | return reject(err) 42 | } 43 | if (info.statusCode === 200) { 44 | resolve(body) 45 | } else { 46 | reject(body) 47 | } 48 | }) 49 | }) 50 | } 51 | 52 | // webpack打包后生成的 dist 目录下的文件 53 | const files = fs.readdirSync(path.join(__dirname, '../dist')) 54 | 55 | // 上传需要上传的文件至 七牛云 CDN 56 | const uploads = files.map(file => { 57 | if (noNeedUploadFileList.indexOf(file) === -1) { 58 | return doUpload( 59 | file, 60 | path.join(__dirname, '../dist', file) 61 | ) 62 | } else { 63 | return Promise.resolve('no need upload file ' + file) 64 | } 65 | }) 66 | 67 | 68 | Promise.all(uploads).then(resps => { 69 | console.log('upload success:', resps) 70 | }).catch(errs => { 71 | console.log('upload fail:', errs) 72 | // process.exit(0) 73 | }) 74 | -------------------------------------------------------------------------------- /build/webpack.config.client.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const HTMLPlugin = require('html-webpack-plugin') 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | 6 | //判断是否是开发环境 7 | const isDev = process.env.NODE_ENV === 'development' 8 | 9 | const config = { 10 | //入口 11 | entry: { 12 | app: path.join(__dirname, '../client/app.js') 13 | }, 14 | //出口 15 | output: { 16 | filename: '[name].[hash].js', // 文件更改后重新打包,hash值变化,从而刷新缓存 17 | path: path.join(__dirname, '../dist'), 18 | //很重要 19 | publicPath: '/public/', 20 | }, 21 | //解析 22 | resolve: { 23 | extensions: ['.js', '.jsx'], // 自动解析确定的扩展 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | //前置(在执行编译之前去执行eslint-loader检查代码规范,有报错就不执行编译) 29 | enforce: 'pre', 30 | test: /.(js|jsx)$/, 31 | loader: 'eslint-loader', 32 | exclude: [ 33 | path.join(__dirname,'../node_modules') 34 | ] 35 | }, 36 | { //将jsx转换成 js 37 | test: /.jsx$/, 38 | loader: 'babel-loader' 39 | }, 40 | { //将ES6语法转成 低版本语法 41 | test: /.js$/, 42 | loader: 'babel-loader', 43 | exclude: [//排除node_modules 下的js 44 | path.join(__dirname,'../node_modules') 45 | ] 46 | }, 47 | { // 解析 图片 48 | test: /\.(png|jpg|gif|svg)$/, 49 | loader: 'file-loader', 50 | options: { 51 | name: '[name].[ext]?[hash]' 52 | } 53 | } 54 | ] 55 | }, 56 | plugins: [ 57 | // 生成一个html页面,同时把所有 entry打包后的 output 文件全部注入到这个html页面 58 | new HTMLPlugin({ 59 | template: path.join(__dirname, '../client/template.html') 60 | }) 61 | ], 62 | //开发模式 63 | mode: 'development' 64 | } 65 | 66 | if(isDev){ 67 | config.entry = [ 68 | 'react-hot-loader/patch', //设置这里 69 | path.join(__dirname, '../client/app.js') 70 | ] 71 | config.devtool = '#cheap-module-eval-source-map' 72 | config.devServer = { 73 | host: '0.0.0.0', 74 | port: '8887', 75 | contentBase: path.join(__dirname, '../dist'), //告诉服务器从哪个目录中提供内容 76 | hot: true,//启用 webpack 的模块热替换特性 77 | overlay: {//当出现编译器错误或警告时,就在网页上显示一层黑色的背景层和错误信息 78 | errors: true 79 | }, 80 | publicPath: '/public/',//webpack-dev-server打包的内容是放在内存中的,这些打包后的资源对外的的根目录就是publicPath,换句话说,这里我们设置的是打包后资源存放的位置 81 | historyApiFallback: { 82 | index: '/public/index.html' 83 | }, 84 | proxy: { // client端 port为 8887, server端接口为 3333, 所以我们这里要设置 proxy代理 85 | '/api' : 'http://localhost:3333' 86 | } 87 | } 88 | config.plugins = [...config.plugins, new webpack.HotModuleReplacementPlugin() ] 89 | }else { 90 | config.mode = 'production' 91 | config.entry = { 92 | app: path.join(__dirname, '../client/app.js'), 93 | vendor: [ 94 | 'react', 95 | 'react-dom', 96 | 'react-router-dom', 97 | 'mobx', 98 | 'mobx-react', 99 | 'axios', 100 | 'query-string', 101 | 'dateformat', 102 | 'marked' 103 | ] 104 | } 105 | config.output.filename = '[name].[chunkhash].js' 106 | 107 | config.optimization = { 108 | minimize: true, 109 | runtimeChunk: { 110 | name: "manifest" 111 | }, 112 | splitChunks: { 113 | cacheGroups: { 114 | commons: { 115 | test: /[\\/]node_modules[\\/]/, 116 | name: "vendor", 117 | chunks: "all" 118 | } 119 | } 120 | } 121 | } 122 | 123 | config.performance = { 124 | // false | "error" | "warning" // 不显示性能提示 | 以错误形式提示 | 以警告... 125 | hints: "warning", 126 | // 开发环境设置较大防止警告 127 | // 根据入口起点的最大体积,控制webpack何时生成性能提示,整数类型,以字节为单位 128 | maxEntrypointSize: 5000000, 129 | // 最大单个资源体积,默认250000 (bytes) 130 | maxAssetSize: 3000000 131 | } 132 | 133 | const cdnConfig = require('./qiNiu.config').cdn 134 | // 让 打包生成的静态文件 前缀为 七牛CDN的域名 135 | config.output.publicPath = cdnConfig.host 136 | } 137 | module.exports = config 138 | -------------------------------------------------------------------------------- /build/webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | // 服务端渲染 webpack 配置 4 | module.exports = { 5 | //打包后的文件 在哪个环境下执行 默认为 web 浏览器环境 6 | target: 'node', 7 | entry: { 8 | app: path.join(__dirname, '../client/server-entry.js') 9 | }, 10 | output: { 11 | filename: 'server-entry.js', 12 | path: path.join(__dirname, '../dist'), 13 | publicPath: '/public', 14 | //nodejs的模块机制 是 commonjs 15 | libraryTarget: 'commonjs2' 16 | }, 17 | //解析 18 | resolve: { 19 | extensions: ['.js', '.jsx'], // 自动解析确定的扩展 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | //前置(在执行编译之前去执行eslint-loader检查代码规范,有报错就不执行编译) 25 | enforce: 'pre', 26 | test: /.(js|jsx)$/, 27 | loader: 'eslint-loader', 28 | exclude: [ 29 | path.join(__dirname,'../node_modules') 30 | ] 31 | }, 32 | { //将jsx转换成 js 33 | test: /.jsx$/, 34 | loader: 'babel-loader' 35 | }, 36 | { //将ES6语法转成 低版本语法 37 | test: /.js$/, 38 | loader: 'babel-loader', 39 | exclude: [//排除node_modules 下的js 40 | path.join(__dirname,'../node_modules') 41 | ] 42 | }, 43 | { // 解析 图片 44 | test: /\.(png|jpg|gif|svg)$/, 45 | loader: 'file-loader', 46 | options: { 47 | name: '[name].[ext]?[hash]' 48 | } 49 | } 50 | ] 51 | }, 52 | //开发模式 53 | mode: 'development', 54 | } 55 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 6, 10 | "sourceType": "module" 11 | }, 12 | "extends": "airbnb", 13 | "rules": { 14 | "semi": [0], 15 | "react/jsx-filename-extension": [0], 16 | "no-console": [0], 17 | "react/require-default-props": [0], 18 | "react/forbid-prop-types": [0], 19 | "no-param-reassign": [0], 20 | "import/prefer-default-export": [0], 21 | "class-methods-use-this": [0], 22 | "react/no-danger": [0], 23 | "no-nested-ternary": [0], 24 | "jsx-a11y/anchor-is-valid": [0], 25 | "jsx-a11y/no-static-element-interactions": [0], 26 | "jsx-a11y/click-events-have-key-events": [0] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React from 'react' 3 | import { AppContainer } from 'react-hot-loader'; // eslint-disable-line 4 | import { BrowserRouter } from 'react-router-dom' 5 | import { Provider } from 'mobx-react' 6 | import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'; 7 | import { lightBlue, pink } from '@material-ui/core/colors'; 8 | import App from './views/App' 9 | import { topicStore, AppState } from './store' 10 | 11 | const theme = createMuiTheme({ 12 | palette: { 13 | primary: lightBlue, 14 | secondary: pink, 15 | }, 16 | typography: { 17 | useNextVariants: true, 18 | }, 19 | }); 20 | 21 | const appState = new AppState() 22 | 23 | const root = document.getElementById('root') 24 | const render = (Component) => { 25 | ReactDOM.render( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | , 35 | root, 36 | ) 37 | } 38 | render(App) 39 | 40 | // react-hot-loader 热更新 41 | if (module.hot) { 42 | module.hot.accept('./views/App.jsx', () => { 43 | const NextApp = require('./views/App.jsx').default //eslint-disable-line 44 | render(NextApp) 45 | }) 46 | } 47 | 48 | /* 49 | * AppContainer: 实现热更替 50 | * BrowserRouter: 使用HTML5的 history API 实现路由 51 | * Provider: Mobx提供数据 52 | * MuiThemeProvider: 提供material-ui的全局主题 53 | * */ 54 | -------------------------------------------------------------------------------- /client/config/router.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | Route, Redirect, Switch, 4 | } from 'react-router-dom' 5 | import TopicList from '../views/topic-list' 6 | import TopicDetail from '../views/topic-detail' 7 | import UserLogin from '../views/user/login' 8 | import UserInfo from '../views/user/info' 9 | import TopicCreate from '../views/topic-create' 10 | 11 | export default () => ( 12 | 13 | } exact key="index" /> 14 | } exact key="index" /> 15 | } exact key="index" /> 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | -------------------------------------------------------------------------------- /client/server-entry.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import App from './views/App' 3 | 4 | export default 5 | -------------------------------------------------------------------------------- /client/store/app-state.js: -------------------------------------------------------------------------------- 1 | import { 2 | observable, 3 | toJS, 4 | action, 5 | } from 'mobx' 6 | import { post, get } from '../util/http' 7 | 8 | 9 | export default class AppState { 10 | @observable user = { 11 | isLogin: false, 12 | info: {}, 13 | detail: { 14 | loginname: '', 15 | avatar_url: '', 16 | recent_topics: [], 17 | recent_replies: [], 18 | }, 19 | collections: { 20 | syncing: false, 21 | list: [], 22 | }, 23 | } 24 | 25 | // 登录 26 | @action login(accesstoken) { 27 | return new Promise((resolve, reject) => { 28 | post('user/login', {}, { 29 | accesstoken, 30 | }).then((resp) => { 31 | if (resp.success) { 32 | this.user.isLogin = true 33 | this.user.info = resp.data 34 | resolve(this.user.info) 35 | } else { 36 | reject(resp) 37 | } 38 | }).catch(reject) 39 | }) 40 | } 41 | 42 | // 获取用户详情 43 | @action 44 | getUserDetail = username => new Promise((resolve, reject) => { 45 | get(`user/${username}`) 46 | .then((resp) => { 47 | if (resp.success) { 48 | this.user.detail.loginname = resp.data.loginname 49 | this.user.detail.avatar_url = resp.data.avatar_url 50 | this.user.detail.recent_replies = resp.data.recent_replies 51 | this.user.detail.recent_topics = resp.data.recent_topics 52 | resolve() 53 | } else { 54 | reject(resp.data.msg) 55 | } 56 | }).catch((err) => { 57 | reject(err) 58 | }) 59 | }) 60 | 61 | 62 | // 获取收藏列表 63 | @action 64 | getCollections = (username) => { 65 | this.user.collections.syncing = true 66 | return new Promise((resolve, reject) => { 67 | get(`topic_collect/${username}`) 68 | .then((resp) => { 69 | if (resp.success) { 70 | this.user.collections.list = resp.data 71 | resolve() 72 | } else { 73 | reject(resp.data.msg) 74 | } 75 | this.user.collections.syncing = false 76 | }).catch((err) => { 77 | reject(err) 78 | this.user.collections.syncing = false 79 | }) 80 | }) 81 | } 82 | 83 | // 收藏 84 | @action 85 | collectTopic = id => new Promise((resolve, reject) => { 86 | post('topic_collect/collect', { needAccessToken: true }, { 87 | topic_id: id, 88 | }).then((resp) => { 89 | if (resp.success) { 90 | resolve() 91 | } else { 92 | reject() 93 | } 94 | }).catch((err) => { 95 | reject(err) 96 | }) 97 | }) 98 | 99 | // 取消收藏 100 | @action 101 | unCollectTopic = id => new Promise((resolve, reject) => { 102 | post('topic_collect/de_collect', { needAccessToken: true }, { 103 | topic_id: id, 104 | }).then((resp) => { 105 | if (resp.success) { 106 | resolve() 107 | } else { 108 | reject() 109 | } 110 | }).catch((err) => { 111 | reject(err) 112 | }) 113 | }) 114 | 115 | toJson() { 116 | return { 117 | user: toJS(this.user), 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /client/store/index.js: -------------------------------------------------------------------------------- 1 | import topicStore from './topic-store' 2 | import AppState from './app-state' 3 | 4 | export { 5 | topicStore, 6 | AppState, 7 | } 8 | 9 | /* 10 | * store/index.js 作为 Mobx store 的暴露接口 11 | * */ 12 | -------------------------------------------------------------------------------- /client/store/topic-store.js: -------------------------------------------------------------------------------- 1 | import { 2 | observable, 3 | action, 4 | extendObservable, 5 | computed, 6 | } from 'mobx' 7 | import { topicSchema } from '../util/constant' 8 | import { get, post } from '../util/http' 9 | 10 | const createTopic = topic => ( 11 | Object.assign({}, topicSchema, topic) 12 | ) 13 | 14 | class Topic { 15 | constructor(data) { 16 | extendObservable(this, data) 17 | } 18 | 19 | @observable syncing 20 | } 21 | 22 | class TopicStore { 23 | @observable topics 24 | 25 | @observable details 26 | 27 | @observable syncing 28 | 29 | constructor({ topics = [], syncing = false, details = [] } = {}) { 30 | this.topics = topics.map(topic => new Topic(createTopic(topic))) 31 | this.details = details.map(detail => new Topic(createTopic(detail))) 32 | this.syncing = syncing 33 | } 34 | 35 | addTopic(topic) { 36 | this.topics = [...this.topics, new Topic(createTopic(topic))] 37 | } 38 | 39 | addDetail(detail) { 40 | this.details.push(new Topic(createTopic(detail))) 41 | } 42 | 43 | @computed get detailMap() { 44 | return this.details.reduce((result, detail) => { 45 | result[detail.id] = detail 46 | return result 47 | }, {}) 48 | } 49 | 50 | // 话题列表 51 | @action fetchTopics(tab = 'all', page = 0, limit = 20) { 52 | this.topics = [] 53 | return new Promise((resolve, reject) => { 54 | this.syncing = true 55 | get('topics', { 56 | mdrender: false, 57 | tab, 58 | page, 59 | limit, 60 | }).then((resp) => { 61 | if (resp.success) { 62 | resp.data.forEach((topic) => { 63 | this.addTopic(topic) 64 | }) 65 | resolve() 66 | } else { 67 | reject() 68 | } 69 | this.syncing = false 70 | }).catch((err) => { 71 | reject(err) 72 | this.syncing = false 73 | }) 74 | }) 75 | } 76 | 77 | // 话题详情 78 | @action getTopicDetail(id) { 79 | return new Promise((resolve, reject) => { 80 | // if (this.detailMap[id]) { 81 | // // // 添加到缓存 82 | // // resolve(this.detailMap[id]) 83 | // // } else { 84 | get(`topic/${id}`, { 85 | mdrender: false, 86 | }).then((resp) => { 87 | if (resp.success) { 88 | this.addDetail(resp.data) 89 | resolve() 90 | } else { 91 | reject() 92 | } 93 | }).catch((err) => { 94 | reject(err) 95 | }) 96 | // } 97 | }) 98 | } 99 | 100 | // 话题回复 101 | @action doReply(id, content, replyId) { 102 | // 是否是回复 其它的回复 103 | const postData = !replyId ? { content } : { content, reply_id: replyId } 104 | return new Promise((resolve, reject) => { 105 | post(`topic/${id}/replies`, { needAccessToken: true }, postData).then((resp) => { 106 | if (resp.success) { 107 | resolve() 108 | } else { 109 | reject() 110 | } 111 | }).catch((err) => { 112 | reject(err) 113 | }) 114 | }) 115 | } 116 | 117 | @action createTopic(title, tab, content) { 118 | return new Promise((resolve, reject) => { 119 | post('topics', { needAccessToken: true }, { 120 | title, tab, content, 121 | }) 122 | .then((data) => { 123 | if (data.success) { 124 | const topic = { 125 | title, 126 | tab, 127 | content, 128 | id: data.topic_id, 129 | create_at: Date.now(), 130 | } 131 | // this.createdTopics.push(new Topic(createTopic(topic))) 132 | resolve(topic) 133 | } else { 134 | reject(new Error(data.error_msg || '未知错误')) 135 | } 136 | }) 137 | .catch((err) => { 138 | if (err.response) { 139 | reject(new Error(err.response.data.error_msg || '未知错误')) 140 | } else { 141 | reject(new Error('未知错误')) 142 | } 143 | }) 144 | }) 145 | } 146 | } 147 | 148 | 149 | const topicStore = new TopicStore() 150 | 151 | export default topicStore 152 | -------------------------------------------------------------------------------- /client/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/util/constant.js: -------------------------------------------------------------------------------- 1 | export const tabs = { 2 | all: '全部', 3 | good: '精华', 4 | share: '分享', 5 | ask: '问答', 6 | job: '招聘', 7 | dev: '客户端测试', 8 | } 9 | export const topicSchema = { 10 | id: '', 11 | author_id: '', 12 | tab: '', 13 | content: '', 14 | title: '', 15 | last_reply_at: '', 16 | good: false, 17 | top: false, 18 | reply_count: 0, 19 | visit_count: 0, 20 | create_at: '', 21 | is_collect: '', 22 | author: { 23 | loginname: '', 24 | avatar_url: '', 25 | }, 26 | replies: [], 27 | } 28 | 29 | export const baseUrl = 'http://47.105.144.204/' 30 | /* 31 | * util/constant.js 定义全局 常量 32 | * */ 33 | -------------------------------------------------------------------------------- /client/util/http.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 封装axios 3 | * 用于 客户端发送 API 接口请求的统一方法 4 | */ 5 | 6 | import axios from 'axios' 7 | import { baseUrl } from './constant' 8 | 9 | // const baseUrl = 'http://localhost:3333/' 10 | const parseUrl = (url, params = {}) => { 11 | const str = Object.keys(params).reduce((result, key) => { 12 | result += `${key}=${params[key]}&` 13 | return result 14 | }, '') 15 | const finalUrl = str === '' ? `api/${url}` : `api/${url}?${str.substr(0, str.length - 1)}` 16 | return `${baseUrl}${finalUrl}` 17 | } 18 | export const get = (url, param) => ( 19 | new Promise((resolve, reject) => { 20 | axios.get(parseUrl(url, param)) 21 | .then((resp) => { 22 | const { data } = resp 23 | if (data && data.success === true) { 24 | resolve(data) 25 | } else { 26 | reject(data) 27 | } 28 | }).catch((err) => { 29 | if (err.response) { 30 | reject(err.response.data) 31 | } else { 32 | reject(err) 33 | } 34 | }) 35 | }) 36 | ) 37 | 38 | export const post = (url, param, postData) => ( 39 | new Promise((resolve, reject) => { 40 | axios.post(`${baseUrl}api/${url}`, postData) 41 | .then((resp) => { 42 | const { data } = resp 43 | if (data && data.success === true) { 44 | resolve(data) 45 | } else { 46 | reject(data) 47 | } 48 | }).catch((err) => { 49 | if (err.response) { 50 | reject(err.response.data) 51 | } else { 52 | reject(err) 53 | } 54 | }) 55 | }) 56 | ) 57 | -------------------------------------------------------------------------------- /client/views/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Routes from '../config/router' 3 | import AppBar from './layout/app-bar' 4 | 5 | 6 | const App = () => ( 7 | 8 | 9 | 10 | 11 | ) 12 | export default App 13 | 14 | // Routes 提供全局路由 15 | -------------------------------------------------------------------------------- /client/views/layout/app-bar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles'; 4 | // 按需加载 5 | import AppBar from '@material-ui/core/AppBar'; 6 | import Toolbar from '@material-ui/core/Toolbar'; 7 | import Typography from '@material-ui/core/Typography'; 8 | import Button from '@material-ui/core/Button'; 9 | import IconButton from '@material-ui/core/IconButton'; 10 | import MenuIcon from '@material-ui/icons/Menu'; 11 | import Avatar from '@material-ui/core/Avatar'; 12 | import { inject, observer } from 'mobx-react'; 13 | 14 | const styles = { 15 | root: { 16 | flexGrow: 1, 17 | }, 18 | grow: { 19 | flexGrow: 1, 20 | }, 21 | menuButton: { 22 | marginLeft: -12, 23 | marginRight: 20, 24 | }, 25 | avatar: { 26 | width: '30px', 27 | height: '30px', 28 | '&:hover': { 29 | cursor: 'pointer', 30 | }, 31 | }, 32 | } 33 | 34 | @inject(stores => ({ 35 | appState: stores.appState, 36 | })) @observer 37 | class MainAppBar extends React.Component { 38 | static contextTypes = { 39 | router: PropTypes.object, 40 | } 41 | 42 | constructor(props) { 43 | super(props) 44 | this.iconBtnClick = this.iconBtnClick.bind(this) 45 | this.createBtnClick = this.createBtnClick.bind(this) 46 | this.loginBtnClick = this.loginBtnClick.bind(this) 47 | } 48 | 49 | iconBtnClick() { 50 | const { router } = this.context 51 | router.history.push({ 52 | pathname: '/', 53 | }) 54 | } 55 | 56 | createBtnClick() { 57 | const { router } = this.context 58 | const { appState } = this.props; 59 | const { isLogin } = appState.user 60 | const pathname = isLogin ? '/topic/create' : '/user/login' 61 | router.history.push({ 62 | pathname, 63 | }) 64 | } 65 | 66 | loginBtnClick() { 67 | const { router } = this.context 68 | const { appState } = this.props; 69 | const { isLogin, info } = appState.user 70 | const pathname = isLogin ? `/user/${info.loginname}` : '/user/login' 71 | router.history.push({ 72 | pathname, 73 | }) 74 | } 75 | 76 | render() { 77 | const { classes, appState } = this.props; 78 | const { isLogin, info } = appState.user 79 | const { loginname, avatar_url: avatarUrl } = info 80 | let userBtn = null 81 | // 是否登录 82 | if (isLogin) { 83 | userBtn = ( 84 | 91 | ) 92 | } else { 93 | userBtn = 登录 94 | } 95 | return ( 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | cNode 104 | 105 | 106 | 新建话题 107 | 108 | {userBtn} 109 | 110 | 111 | 112 | ) 113 | } 114 | } 115 | 116 | MainAppBar.wrappedComponent.propTypes = { 117 | appState: PropTypes.object.isRequired, 118 | } 119 | MainAppBar.propTypes = { 120 | classes: PropTypes.object.isRequired, 121 | } 122 | 123 | export default withStyles(styles)(MainAppBar) 124 | -------------------------------------------------------------------------------- /client/views/layout/container.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | import Paper from '@material-ui/core/Paper'; 5 | 6 | const styles = { 7 | root: { 8 | margin: 24, 9 | marginTop: 80, 10 | }, 11 | } 12 | 13 | const Container = ({ classes, children }) => ( 14 | 15 | {children} 16 | 17 | ) 18 | 19 | Container.propTypes = { 20 | classes: PropTypes.object.isRequired, 21 | children: PropTypes.oneOfType([ 22 | PropTypes.arrayOf(PropTypes.element), 23 | PropTypes.element, 24 | ]), 25 | } 26 | 27 | export default withStyles(styles)(Container) 28 | -------------------------------------------------------------------------------- /client/views/layout/pagination.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Table from '@material-ui/core/Table'; 4 | import TablePagination from '@material-ui/core/TablePagination'; 5 | import TableFooter from '@material-ui/core/TableFooter'; 6 | import TableRow from '@material-ui/core/TableRow'; 7 | // import PageAction from './pageAction' 8 | 9 | class Pagination extends React.Component { 10 | constructor(props) { 11 | super(props) 12 | this.state = { 13 | page: 0, 14 | rowsPerPage: 20, 15 | } 16 | } 17 | 18 | handleChangePage = (event, page) => { 19 | this.setState({ page }, this.changePage); 20 | } 21 | 22 | handleChangeRowsPerPage = (e) => { 23 | this.setState({ 24 | rowsPerPage: Number(e.target.value), 25 | }, this.changePage) 26 | } 27 | 28 | 29 | changePage() { 30 | const { changePage } = this.props 31 | const { page, rowsPerPage } = this.state 32 | changePage(page, rowsPerPage) 33 | } 34 | 35 | render() { 36 | const { page, rowsPerPage } = this.state 37 | const { rows } = this.props 38 | return ( 39 | 40 | 41 | 42 | `${from}-${to}`} 47 | count={rows} // 共有多少条 48 | page={page} // 当前页 49 | SelectProps={{ // 下拉框使用原生样式 50 | native: true, 51 | }} 52 | onChangePage={this.handleChangePage} 53 | onChangeRowsPerPage={this.handleChangeRowsPerPage}// 修改每页条数 54 | backIconButtonProps={{ 55 | 'aria-label': 'Previous Page', 56 | }} 57 | nextIconButtonProps={{ 58 | 'aria-label': 'Next Page', 59 | }} 60 | /> 61 | 62 | 63 | 64 | ) 65 | } 66 | } 67 | Pagination.propTypes = { 68 | rows: PropTypes.number.isRequired, 69 | changePage: PropTypes.func.isRequired, 70 | } 71 | export default Pagination 72 | -------------------------------------------------------------------------------- /client/views/topic-create/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | inject, 5 | observer, 6 | } from 'mobx-react' 7 | import SimpleMDE from 'react-simplemde-editor' 8 | import TextField from '@material-ui/core/TextField' 9 | import Radio from '@material-ui/core/Radio' 10 | import Button from '@material-ui/core/Button' 11 | import IconReply from '@material-ui/icons/Reply' 12 | import { withStyles } from '@material-ui/core/styles' 13 | 14 | import Container from '../layout/container' 15 | import createStyles from './style' 16 | import { tabs } from '../../util/constant' 17 | 18 | 19 | @inject(stores => ({ 20 | topicStore: stores.topicStore, 21 | appState: stores.appState, 22 | })) @observer 23 | class TopicCreate extends React.Component { 24 | static contextTypes = { 25 | router: PropTypes.object, 26 | } 27 | 28 | constructor() { 29 | super() 30 | this.state = { 31 | title: '', 32 | content: '', 33 | tab: 'dev', 34 | } 35 | // this.handleTitleChange = this.handleTitleChange.bind(this) 36 | // this.handleContentChange = this.handleContentChange.bind(this) 37 | // this.handleChangeTab = this.handleChangeTab.bind(this) 38 | // this.handleCreate = this.handleCreate.bind(this) 39 | } 40 | 41 | handleTitleChange = (e) => { 42 | const title = e.target.value 43 | this.setState({ 44 | title, 45 | }) 46 | } 47 | 48 | handleContentChange = (value) => { 49 | this.setState({ 50 | content: value, 51 | }) 52 | } 53 | 54 | handleChangeTab = (e) => { 55 | this.setState({ 56 | tab: e.currentTarget.value, 57 | }) 58 | } 59 | 60 | // 创建话题 61 | handleCreate = () => { 62 | console.log(this.state); 63 | const { 64 | tab, title, content, 65 | } = this.state 66 | const { appState, topicStore } = this.props 67 | const { router } = this.context 68 | if (!title) { 69 | console.log('title', title); 70 | return appState.notify({ 71 | message: '标题必须填写', 72 | }) 73 | } 74 | if (!content) { 75 | console.log(content); 76 | return appState.notify({ 77 | message: '内容不能为空', 78 | }) 79 | } 80 | return topicStore.createTopic(title, tab, content) 81 | .then(() => { 82 | router.history.push('/index') 83 | }) 84 | .catch((err) => { 85 | appState.notify({ 86 | message: err.message, 87 | }) 88 | }) 89 | } 90 | 91 | render() { 92 | const { classes } = this.props 93 | const { title, content, tab } = this.state 94 | return ( 95 | 96 | 97 | this.handleTitleChange(e)} 102 | fullWidth 103 | /> 104 | 111 | 112 | { 113 | Object.keys(tabs).map((currentTab) => { 114 | if (currentTab !== 'all' && currentTab !== 'good') { 115 | return ( 116 | 117 | this.handleChangeTab(e)} 121 | /> 122 | {tabs[currentTab]} 123 | 124 | ) 125 | } 126 | return null 127 | }) 128 | } 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | ) 137 | } 138 | } 139 | 140 | TopicCreate.wrappedComponent.propTypes = { 141 | topicStore: PropTypes.object.isRequired, 142 | appState: PropTypes.object.isRequired, 143 | } 144 | 145 | TopicCreate.propTypes = { 146 | classes: PropTypes.object.isRequired, 147 | } 148 | 149 | export default withStyles(createStyles)(TopicCreate) 150 | -------------------------------------------------------------------------------- /client/views/topic-create/style.js: -------------------------------------------------------------------------------- 1 | export default { 2 | root: { 3 | padding: 20, 4 | position: 'relative', 5 | }, 6 | title: { 7 | marginBottom: 20, 8 | }, 9 | selectItem: { 10 | display: 'inline-flex', 11 | alignItems: 'center', 12 | }, 13 | replyButton: { 14 | position: 'absolute', 15 | right: 30, 16 | bottom: 20, 17 | opacity: '.3', 18 | '&:hover': { 19 | opacity: '1', 20 | }, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /client/views/topic-detail/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | observer, 5 | inject, 6 | } from 'mobx-react' 7 | import { withStyles } from '@material-ui/core/styles'; 8 | import CircularProgress from '@material-ui/core/CircularProgress'; 9 | import Divider from '@material-ui/core/Divider'; 10 | import Paper from '@material-ui/core/Paper'; 11 | import Avatar from '@material-ui/core/Avatar'; 12 | import List from '@material-ui/core/List'; 13 | import Button from '@material-ui/core/Button'; 14 | 15 | import SimpleMD from 'react-simplemde-editor' 16 | import marked from 'marked' 17 | import highlight from 'highlightjs' 18 | import dateFormat from 'dateformat' 19 | 20 | import Container from '../layout/container' 21 | import styles from './style' 22 | import { tabs } from '../../util/constant' 23 | import ReplyItem from './reply-item' 24 | 25 | @inject(stores => ( 26 | { 27 | topicStore: stores.topicStore, 28 | appState: stores.appState, 29 | } 30 | )) @observer 31 | class TopicDetail extends React.Component { 32 | // 通过context 获取 router 33 | static contextTypes = { 34 | router: PropTypes.object, 35 | } 36 | 37 | constructor(props) { 38 | super(props) 39 | const { appState, match } = props 40 | const isCollected = appState.user.collections.list.find(item => item.id === match.params.id) 41 | this.state = { 42 | isCollected, 43 | newReply: '', 44 | } 45 | } 46 | 47 | componentDidMount() { 48 | this.getTopicDetail() 49 | 50 | marked.setOptions({ 51 | highlight(code) { 52 | return highlight.highlightAuto(code).value; 53 | }, 54 | }) 55 | } 56 | 57 | // 获取话题 ID 58 | getTopicId() { 59 | const { match } = this.props 60 | return match.params.id 61 | } 62 | 63 | // 根据id 查询话题详情 64 | getTopicDetail() { 65 | const { topicStore } = this.props 66 | const id = this.getTopicId() 67 | topicStore.getTopicDetail(id) 68 | } 69 | 70 | // simpleMD 回复内容 71 | handleNewReplyChange = (value) => { 72 | this.setState({ 73 | newReply: value, 74 | }) 75 | } 76 | 77 | // 登录并回复 78 | goToLogin = () => { 79 | const { router } = this.context 80 | router.history.push({ 81 | pathname: '/user/login', 82 | }) 83 | } 84 | 85 | // 回复按钮 86 | doReply = (id) => { 87 | const { topicStore } = this.props 88 | const { newReply } = this.state 89 | topicStore.doReply(id, newReply).then(() => { 90 | this.getTopicDetail() 91 | }).then(() => { 92 | this.setState({ 93 | newReply: '', 94 | }) 95 | }) 96 | } 97 | 98 | 99 | // 点击头像 进入用户信息页面 100 | goToUserInfo = (loginname, e) => { 101 | e.preventDefault() 102 | const { router } = this.context 103 | router.history.push({ 104 | pathname: `/user/${loginname}`, 105 | }) 106 | } 107 | 108 | // 取消收藏 109 | handleUnCollect(id) { 110 | const { appState } = this.props 111 | const { isCollected } = this.state 112 | appState.unCollectTopic(id) 113 | this.setState({ 114 | isCollected: !isCollected, 115 | }) 116 | } 117 | 118 | // 收藏 119 | handleCollect(id) { 120 | const { appState } = this.props 121 | const { isCollected } = this.state 122 | appState.collectTopic(id) 123 | this.setState({ 124 | isCollected: !isCollected, 125 | }) 126 | } 127 | 128 | 129 | render() { 130 | const { isCollected, newReply } = this.state 131 | const { classes, topicStore, appState } = this.props 132 | const id = this.getTopicId() 133 | const topic = topicStore.detailMap[id] 134 | const { user } = appState 135 | // 加载动画 136 | if (!topic) { 137 | return 138 | } 139 | return ( 140 | 141 | 142 | 143 | 144 | 145 | { 146 | topic.top === true || topic.good === true 147 | ? { topic.top === true ? '置顶' : tabs.good } 148 | : '' 149 | } 150 | {topic.title} 151 | 152 | 153 | this.goToUserInfo(topic.author.loginname, e)} 158 | title={topic.author.loginname} 159 | /> 160 | 161 | 发布于: 162 | {dateFormat(topic.create_at, 'yyyy-mm-dd HH:MM:ss')} 163 | 164 | 165 | 作者: 166 | {topic.author.loginname} 167 | 168 | 169 | {topic.visit_count} 170 | 次浏览 171 | 172 | 173 | 来自: 174 | {tabs[topic.tab]} 175 | 176 | { 177 | user.isLogin ? ( 178 | !isCollected 179 | ? ( 180 | this.handleCollect(id)}> 181 | 关注 182 | 183 | ) 184 | : ( 185 | this.handleUnCollect(id)}> 186 | 取消关注 187 | 188 | ) 189 | ) : null 190 | } 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | {topic.reply_count} 203 | 回复 204 | 205 | { 206 | user.isLogin ? ( 207 | 208 | 215 | this.doReply(id)}>回复 216 | 217 | ) : null 218 | } 219 | { 220 | !user.isLogin && ( 221 | 222 | 登录并回复 223 | 224 | ) 225 | } 226 | 227 | { topic.replies.map((n, i) => ( 228 | 234 | )) } 235 | 236 | 237 | 238 | ) 239 | } 240 | } 241 | 242 | TopicDetail.wrappedComponent.propTypes = { 243 | topicStore: PropTypes.object.isRequired, 244 | appState: PropTypes.object.isRequired, 245 | } 246 | 247 | TopicDetail.propTypes = { 248 | match: PropTypes.object.isRequired, 249 | classes: PropTypes.object.isRequired, 250 | } 251 | 252 | export default withStyles(styles)(TopicDetail) 253 | -------------------------------------------------------------------------------- /client/views/topic-detail/reply-item.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | import Avatar from '@material-ui/core/Avatar'; 5 | import Divider from '@material-ui/core/Divider'; 6 | import IconReply from '@material-ui/icons/Reply'; 7 | import Button from '@material-ui/core/Button'; 8 | import dateFormat from 'dateformat' 9 | import SimpleMD from 'react-simplemde-editor' 10 | import { inject, observer } from 'mobx-react'; 11 | 12 | 13 | import marked from 'marked' 14 | import styles from './style' 15 | 16 | @inject(stores => ( 17 | { 18 | topicStore: stores.topicStore, 19 | appState: stores.appState, 20 | } 21 | )) @observer 22 | class ReplyItem extends React.Component { 23 | static contextTypes = { 24 | router: PropTypes.object, 25 | } 26 | 27 | constructor() { 28 | super() 29 | this.state = { 30 | open: false, 31 | newReply: '', 32 | } 33 | } 34 | 35 | goToUserInfo = (loginname, e) => { 36 | e.preventDefault() 37 | const { router } = this.context 38 | router.history.push({ 39 | pathname: `/user/${loginname}`, 40 | }) 41 | } 42 | 43 | toggleDrawer = () => { 44 | const { open } = this.state 45 | this.setState({ 46 | open: !open, 47 | }) 48 | } 49 | 50 | // 回复按钮 51 | doReply = (topicId, reply) => { 52 | const { topicStore } = this.props 53 | const { newReply } = this.state 54 | const content = `@${reply.author.loginname} ${newReply}` 55 | topicStore.doReply(topicId, content, reply.id).then(() => { 56 | topicStore.getTopicDetail(topicId) 57 | }).then(() => { 58 | this.setState({ 59 | newReply: '', 60 | }) 61 | }) 62 | } 63 | 64 | // simpleMD 回复内容 65 | handleNewReplyChange = (value) => { 66 | this.setState({ 67 | newReply: value, 68 | }) 69 | } 70 | 71 | 72 | render() { 73 | const { 74 | reply, classes, index, topicId, appState, 75 | } = this.props 76 | const { user } = appState 77 | const { open, newReply } = this.state 78 | const { loginname, avatar_url: avatarUrl } = reply.author 79 | return ( 80 | 81 | 82 | this.goToUserInfo(loginname, e)} 86 | style={{ width: '30px', height: '30px', cursor: 'pointer' }} 87 | title={loginname} 88 | /> 89 | 90 | this.goToUserInfo(loginname, e)} 93 | style={{ cursor: 'pointer' }} 94 | > 95 | {loginname} 96 | 97 | 98 | {index} 99 | 楼· 100 | {dateFormat(reply.create_at, 'yyyy-mm-dd HH:MM:ss')} 101 | 102 | { 103 | user.isLogin ? ( 104 | this.toggleDrawer()} /> 105 | ) : null 106 | } 107 | 108 | 109 | 110 | 111 | 112 | 113 | { 114 | open ? ( 115 | 116 | 123 | this.doReply(topicId, reply)}>回复 124 | 125 | ) : null 126 | } 127 | 128 | 129 | ) 130 | } 131 | } 132 | 133 | ReplyItem.wrappedComponent.propTypes = { 134 | topicStore: PropTypes.object.isRequired, 135 | appState: PropTypes.object.isRequired, 136 | } 137 | 138 | ReplyItem.propTypes = { 139 | reply: PropTypes.object.isRequired, 140 | classes: PropTypes.object.isRequired, 141 | index: PropTypes.number.isRequired, 142 | topicId: PropTypes.string.isRequired, 143 | } 144 | export default withStyles(styles)(ReplyItem) 145 | -------------------------------------------------------------------------------- /client/views/topic-detail/style.js: -------------------------------------------------------------------------------- 1 | const detailStyles = theme => ({ 2 | loading: { 3 | display: 'flex', 4 | justifyContent: 'center', 5 | alignItems: 'center', 6 | padding: '40px 0', 7 | // marginTop: '40px', 8 | }, 9 | header: { 10 | display: 'flex', 11 | flexDirection: 'column', 12 | padding: '10px', 13 | }, 14 | title: { 15 | display: 'flex', 16 | justifyContent: 'flex-start', 17 | alignItems: 'center', 18 | margin: '8px 0', 19 | }, 20 | tab: { 21 | backgroundColor: theme.palette.secondary[500], 22 | textAlign: 'center', 23 | padding: '2px 4px', 24 | color: '#fff', 25 | borderRadius: 3, 26 | fontSize: '12px', 27 | minWidth: '40px', 28 | }, 29 | changes: { 30 | fontSize: '14px', 31 | color: '#838383', 32 | display: 'flex', 33 | alignItems: 'center', 34 | '& span': { 35 | margin: '0 20px', 36 | }, 37 | }, 38 | collectBtn: { 39 | color: '#fff', 40 | fontSize: '14px', 41 | }, 42 | content: { 43 | fontSize: '14px', 44 | padding: '10px', 45 | '& img': { 46 | maxWidth: '100%', 47 | display: 'block', 48 | }, 49 | '& ul, & ol': { 50 | paddingLeft: 30, 51 | '& li': { 52 | marginBottom: 7, 53 | }, 54 | }, 55 | }, 56 | paper: { 57 | margin: '24px', 58 | }, 59 | reply_header: { 60 | fontSize: '14px', 61 | color: '#444', 62 | padding: '10px', 63 | backgroundColor: '#f6f6f6', 64 | borderRadius: '3px 3px 0 0', 65 | }, 66 | reply_item: { 67 | padding: '10px 0 10px 10px', 68 | fontSize: '14px', 69 | }, 70 | reply_author_content: { 71 | display: 'flex', 72 | }, 73 | user_info: { 74 | marginLeft: '10px', 75 | }, 76 | reply_author: { 77 | color: '#666', 78 | textDecoration: 'none', 79 | }, 80 | reply_time: { 81 | color: '#08c', 82 | fontSize: '11px', 83 | textDecoration: 'none', 84 | marginLeft: '10px', 85 | }, 86 | replyEditor: { 87 | position: 'relative', 88 | padding: 24, 89 | borderBottom: '1px solid #dfdfdf', 90 | '& .CodeMirror': { 91 | height: 150, 92 | minHeight: 'auto', 93 | '& .CodeMirror-scroll': { 94 | minHeight: 'auto', 95 | }, 96 | }, 97 | }, 98 | notLoginButton: { 99 | textAlign: 'center', 100 | padding: '20px 0', 101 | }, 102 | replyButton: { 103 | position: 'absolute', 104 | fontSize: '14px', 105 | right: 40, 106 | bottom: 65, 107 | zIndex: 101, 108 | opacity: 0.1, 109 | transition: 'opacity .3s', 110 | '&:hover': { 111 | opacity: 1, 112 | }, 113 | }, 114 | replyIcon: { 115 | position: 'absolute', 116 | right: '10px', 117 | opacity: 0.1, 118 | transition: 'opacity .3s', 119 | '&:hover': { 120 | opacity: 1, 121 | cursor: 'pointer', 122 | }, 123 | }, 124 | }) 125 | 126 | export default detailStyles 127 | -------------------------------------------------------------------------------- /client/views/topic-list/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | observer, 5 | inject, 6 | } from 'mobx-react' 7 | import { withStyles } from '@material-ui/core/styles'; 8 | import Tabs from '@material-ui/core/Tabs'; 9 | import Tab from '@material-ui/core/Tab'; 10 | import List from '@material-ui/core/List'; 11 | import CircularProgress from '@material-ui/core/CircularProgress'; 12 | 13 | import queryString from 'query-string' 14 | import Container from '../layout/container' 15 | import TopicListItem from './list-item' 16 | import { tabs } from '../../util/constant' 17 | import Pagination from '../layout/pagination' 18 | 19 | const styles = theme => ({ 20 | root: { 21 | flexGrow: 1, 22 | width: '100%', 23 | backgroundColor: theme.palette.background.paper, 24 | }, 25 | loading: { 26 | display: 'flex', 27 | justifyContent: 'center', 28 | alignItems: 'center', 29 | padding: '40px 0', 30 | }, 31 | }); 32 | 33 | 34 | @inject(stores => ( 35 | { 36 | appState: stores.appState, 37 | topicStore: stores.topicStore, 38 | } 39 | )) @observer 40 | class TopicList extends React.Component { 41 | // 获取 父组件 的 router信息 42 | static contextTypes = { 43 | router: PropTypes.object, 44 | } 45 | 46 | constructor(props) { 47 | super(props) 48 | this.changeTab = this.changeTab.bind(this) 49 | this.handleClick = this.handleClick.bind(this) 50 | this.changePage = this.changePage.bind(this) 51 | } 52 | 53 | // 页面首次加载数据 54 | componentDidMount() { 55 | this.fetchTopics() 56 | } 57 | 58 | componentWillReceiveProps(nextProps) { 59 | const { topicStore, location } = this.props 60 | if (nextProps.location.search !== location.search) { 61 | const { tab, page, limit } = this.getSearch(nextProps.location.search) 62 | topicStore.fetchTopics(tab, page, limit) 63 | } 64 | } 65 | 66 | // 获取当前tab 67 | getSearch(search) { 68 | const { location } = this.props 69 | search = search || location.search 70 | const query = queryString.parse(search) 71 | const { tab = 'all', page, limit } = query 72 | return { 73 | tab, 74 | page, 75 | limit, 76 | } 77 | } 78 | 79 | // mobx 提供的 action 80 | fetchTopics(tab, page, limit) { 81 | const { topicStore } = this.props 82 | topicStore.fetchTopics(tab, page, limit) 83 | } 84 | 85 | // 切换tab 86 | changeTab(e, tab) { 87 | const { router } = this.context 88 | // 改变url 89 | router.history.push({ 90 | pathname: '/index', 91 | search: `?tab=${tab}`, 92 | }) 93 | } 94 | 95 | changePage(page, limit) { 96 | const { router } = this.context 97 | const { tab } = this.getSearch() 98 | router.history.push({ 99 | pathname: '/index', 100 | search: `?tab=${tab}&page=${page}&limit=${limit}`, 101 | }) 102 | } 103 | 104 | // 点击列表进入详情页 105 | handleClick(id) { 106 | const { router } = this.context 107 | router.history.push({ pathname: `/detail/${id}` }) 108 | } 109 | 110 | 111 | render() { 112 | const { classes, topicStore } = this.props 113 | const { topics, syncing } = topicStore 114 | const { tab } = this.getSearch() 115 | return ( 116 | 117 | 118 | 126 | { 127 | Object.keys(tabs).map(n => ) 128 | } 129 | 130 | 131 | { 132 | syncing ? 133 | : topics.map(topic => ( 134 | this.handleClick(topic.id)} 137 | key={topic.id} 138 | /> 139 | )) 140 | } 141 | 142 | { this.changePage(page, limit) }} 145 | /> 146 | 147 | 148 | ) 149 | } 150 | } 151 | 152 | TopicList.wrappedComponent.propTypes = { 153 | topicStore: PropTypes.object.isRequired, 154 | } 155 | TopicList.propTypes = { 156 | classes: PropTypes.object.isRequired, 157 | location: PropTypes.object.isRequired, 158 | } 159 | 160 | export default withStyles(styles)(TopicList) 161 | -------------------------------------------------------------------------------- /client/views/topic-list/list-item.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemText from '@material-ui/core/ListItemText'; 6 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 7 | import Avatar from '@material-ui/core/Avatar'; 8 | import Divider from '@material-ui/core/Divider'; 9 | 10 | import dateFormat from 'dateformat' 11 | import primaryStyles from './style' 12 | import { tabs } from '../../util/constant' 13 | 14 | const Primary = ({ classes, topic }) => ( 15 | 16 | 17 | 18 | {topic.reply_count} 19 | / 20 | {topic.visit_count} 21 | 22 | { 23 | topic.top === true || topic.good === true 24 | ? { topic.top === true ? '置顶' : tabs.good } 25 | : { tabs[topic.tab] } 26 | } 27 | 28 | {topic.title} 29 | 30 | 31 | {/* */} 32 | 33 | 更新时间: 34 | {dateFormat(topic.last_reply_at, 'yyyy-mm-dd')} 35 | 36 | 37 | 38 | ) 39 | Primary.propTypes = { 40 | topic: PropTypes.object.isRequired, 41 | classes: PropTypes.object.isRequired, 42 | } 43 | 44 | const StyledPrimary = withStyles(primaryStyles)(Primary) 45 | 46 | const TopicListItem = ({ onClick, topic }) => ( 47 | 48 | 49 | 50 | 56 | 57 | } /> 58 | 59 | 60 | 61 | ) 62 | TopicListItem.propTypes = { 63 | topic: PropTypes.object.isRequired, 64 | onClick: PropTypes.func.isRequired, 65 | } 66 | export default TopicListItem 67 | -------------------------------------------------------------------------------- /client/views/topic-list/style.js: -------------------------------------------------------------------------------- 1 | const primaryStyles = theme => ({ 2 | root1: { 3 | display: 'flex', 4 | alignItems: 'center', 5 | justifyContent: 'space-between', 6 | }, 7 | root2: { 8 | display: 'flex', 9 | alignItems: 'center', 10 | }, 11 | title: { 12 | color: '#555', 13 | fontSize: '16px', 14 | }, 15 | topTab: { 16 | backgroundColor: theme.palette.secondary[500], 17 | textAlign: 'center', 18 | padding: '2px 4px', 19 | color: '#fff', 20 | borderRadius: 3, 21 | fontSize: '12px', 22 | minWidth: '40px', 23 | margin: '0 10px 0 10px', 24 | }, 25 | normalTab: { 26 | backgroundColor: theme.palette.primary[500], 27 | textAlign: 'center', 28 | padding: '2px 4px', 29 | color: '#fff', 30 | borderRadius: 3, 31 | fontSize: '12px', 32 | minWidth: '40px', 33 | margin: '0 10px 0 10px', 34 | }, 35 | root3: { 36 | display: 'flex', 37 | alignItems: 'center', 38 | margin: '0 10px 0 10px', 39 | fontSize: '10px', 40 | minWidth: '70px', 41 | }, 42 | reply: { 43 | color: theme.palette.secondary.main, 44 | fontSize: '14px', 45 | }, 46 | read: { 47 | color: '#b4b4b4', 48 | fontSize: '10px', 49 | }, 50 | create: { 51 | fontSize: '11px', 52 | color: '#778087', 53 | }, 54 | }) 55 | 56 | 57 | export default primaryStyles 58 | -------------------------------------------------------------------------------- /client/views/user/info.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | inject, 5 | observer, 6 | } from 'mobx-react' 7 | 8 | import Grid from '@material-ui/core/Grid' 9 | import Paper from '@material-ui/core/Paper' 10 | import List from '@material-ui/core/List' 11 | import ListItem from '@material-ui/core/ListItem'; 12 | import ListItemText from '@material-ui/core/ListItemText'; 13 | import Avatar from '@material-ui/core/Avatar' 14 | import Typography from '@material-ui/core/Typography' 15 | import { withStyles } from '@material-ui/core/styles' 16 | import dateFormat from 'dateformat' 17 | 18 | 19 | import UserWrapper from './user' 20 | import infoStyles from './styles/user-info-style' 21 | 22 | const TopicItem = (({ topic, onClick }) => ( 23 | 24 | 25 | 36 | {topic.title} 37 | 38 | )} 39 | secondary={`最新回复:${dateFormat(topic.last_reply_at, 'yyyy-mm-dd HH:MM:ss')}`} 40 | /> 41 | 42 | )) 43 | 44 | TopicItem.propTypes = { 45 | topic: PropTypes.object.isRequired, 46 | onClick: PropTypes.func.isRequired, 47 | } 48 | 49 | @inject(stores => ({ 50 | user: stores.appState.user, 51 | appState: stores.appState, 52 | })) @observer 53 | class UserInfo extends React.Component { 54 | static contextTypes = { 55 | router: PropTypes.object, 56 | } 57 | 58 | // 获取数据 59 | componentWillMount() { 60 | const { appState, match } = this.props 61 | const { loginname: username } = match.params 62 | appState.getUserDetail(username) 63 | appState.getCollections(username) 64 | } 65 | 66 | // 话题点击 67 | handleTopicClick(id) { 68 | const { router } = this.context 69 | router.history.push({ 70 | pathname: `/detail/${id}`, 71 | }) 72 | } 73 | 74 | render() { 75 | const { classes, user } = this.props 76 | const { recent_topics: topics, recent_replies: replies } = user.detail 77 | const collections = user.collections.list 78 | 79 | return ( 80 | 81 | 82 | 83 | 84 | 85 | 86 | 最近发布的话题 87 | 88 | 89 | { 90 | topics.length > 0 91 | ? topics.map(topic => ( 92 | this.handleTopicClick(topic.id)} 96 | /> 97 | )) 98 | : ( 99 | 100 | 最近没有发布过话题 101 | 102 | ) 103 | } 104 | 105 | 106 | 107 | 108 | 109 | 110 | 新的回复 111 | 112 | 113 | { 114 | replies.length > 0 115 | ? replies.map(topic => ( 116 | this.handleTopicClick(topic.id)} 120 | /> 121 | )) 122 | : ( 123 | 124 | 最近没有新的回复 125 | 126 | ) 127 | } 128 | 129 | 130 | 131 | 132 | 133 | 134 | 收藏的话题 135 | 136 | 137 | { 138 | collections.length > 0 139 | ? collections.map(topic => ( 140 | this.handleTopicClick(topic.id)} 144 | /> 145 | )) 146 | : ( 147 | 148 | 还么有收藏话题哦 149 | 150 | ) 151 | } 152 | 153 | 154 | 155 | 156 | 157 | 158 | ) 159 | } 160 | } 161 | 162 | UserInfo.wrappedComponent.propTypes = { 163 | appState: PropTypes.object.isRequired, 164 | user: PropTypes.object.isRequired, 165 | } 166 | 167 | UserInfo.propTypes = { 168 | classes: PropTypes.object.isRequired, 169 | match: PropTypes.object, 170 | } 171 | 172 | export default withStyles(infoStyles)(UserInfo) 173 | -------------------------------------------------------------------------------- /client/views/user/login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | inject, 5 | observer, 6 | } from 'mobx-react' 7 | 8 | import TextField from '@material-ui/core/TextField' 9 | import Button from '@material-ui/core/Button' 10 | import { withStyles } from '@material-ui/core/styles' 11 | 12 | import UserWrapper from './user' 13 | import loginStyles from './styles/login-style' 14 | 15 | 16 | @inject(stores => ({ 17 | appState: stores.appState, 18 | })) @observer 19 | class UserLogin extends React.Component { 20 | // 通过context 获取 router 21 | static contextTypes = { 22 | router: PropTypes.object, 23 | } 24 | 25 | constructor() { 26 | super() 27 | this.state = { 28 | accesstoken: '', 29 | helpText: '', 30 | } 31 | this.handleLogin = this.handleLogin.bind(this) 32 | this.handleInput = this.handleInput.bind(this) 33 | } 34 | 35 | // 点击登录 36 | handleLogin() { 37 | const { accesstoken } = this.state 38 | const { appState } = this.props 39 | if (!accesstoken) { 40 | return this.setState({ 41 | helpText: '必须填写', 42 | }) 43 | } 44 | this.setState({ 45 | helpText: '', 46 | }) 47 | return appState.login(accesstoken) 48 | .then((resp) => { 49 | // 登录成功 跳转到 用户信息页面 50 | const { router } = this.context 51 | router.history.replace(`/user/${resp.loginname}`) 52 | }) 53 | .catch((msg) => { 54 | appState.notify({ message: msg }) 55 | }) 56 | } 57 | 58 | // 输入 accesstoken 59 | handleInput(event) { 60 | this.setState({ 61 | accesstoken: event.target.value.trim(), 62 | }) 63 | } 64 | 65 | render() { 66 | const { classes } = this.props 67 | const { helpText, accesstoken } = this.state 68 | return ( 69 | 70 | 71 | 80 | 86 | 登 录 87 | 88 | 89 | 90 | ) 91 | } 92 | } 93 | 94 | UserLogin.wrappedComponent.propTypes = { 95 | appState: PropTypes.object.isRequired, 96 | } 97 | 98 | UserLogin.propTypes = { 99 | classes: PropTypes.object.isRequired, 100 | } 101 | 102 | export default withStyles(loginStyles)(UserLogin) 103 | -------------------------------------------------------------------------------- /client/views/user/styles/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dingsheng1214/react-cNode/f3240456fdce601be0048a23f6681d669a8dafe3/client/views/user/styles/bg.jpg -------------------------------------------------------------------------------- /client/views/user/styles/login-style.js: -------------------------------------------------------------------------------- 1 | const inputWidth = 300 2 | 3 | export default () => ({ 4 | root: { 5 | padding: '60px 20px', 6 | display: 'flex', 7 | flexDirection: 'column', 8 | alignItems: 'center', 9 | }, 10 | input: { 11 | width: inputWidth, 12 | marginBottom: 20, 13 | fontSize: '12px', 14 | }, 15 | loginButton: { 16 | width: inputWidth, 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /client/views/user/styles/user-info-style.js: -------------------------------------------------------------------------------- 1 | export default theme => ( 2 | { 3 | root: { 4 | padding: 16, 5 | minHeight: 400, 6 | }, 7 | gridContainer: { 8 | height: '100%', 9 | }, 10 | paper: { 11 | height: '100%', 12 | }, 13 | partTitle: { 14 | lineHeight: '40px', 15 | paddingLeft: 20, 16 | backgroundColor: theme.palette.primary[700], 17 | color: '#fff', 18 | }, 19 | '@media screen and (max-width: 480px)': { 20 | root: { 21 | padding: 10, 22 | minHeight: 300, 23 | }, 24 | }, 25 | topic: { 26 | overflow: 'hidden', 27 | textOverflow: 'ellipsis', 28 | whiteSpace: 'nowrap', 29 | }, 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /client/views/user/styles/user-style.js: -------------------------------------------------------------------------------- 1 | import avatarBg from './bg.jpg' 2 | 3 | export default () => ({ 4 | root: {}, 5 | avatar: { 6 | position: 'relative', 7 | display: 'flex', 8 | flexDirection: 'column', 9 | alignItems: 'center', 10 | justifyContent: 'space-between', 11 | // backgroundImage: `url(${avatarBg})`, 12 | // backgroundSize: 'cover', 13 | padding: 20, 14 | paddingTop: 60, 15 | paddingBottom: 40, 16 | }, 17 | avatarImg: { 18 | width: 80, 19 | height: 80, 20 | marginBottom: 20, 21 | }, 22 | userName: { 23 | color: '#fff', 24 | zIndex: '1', 25 | }, 26 | bg: { 27 | backgroundImage: `url(${avatarBg})`, 28 | backgroundSize: 'cover', 29 | position: 'absolute', 30 | left: 0, 31 | right: 0, 32 | top: 0, 33 | bottom: 0, 34 | '&::after': { 35 | content: '\' \'', 36 | position: 'absolute', 37 | left: 0, 38 | right: 0, 39 | top: 0, 40 | bottom: 0, 41 | backgroundColor: 'rgba(0,0,0,.6)', 42 | }, 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /client/views/user/user.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | observer, 5 | inject, 6 | } from 'mobx-react' 7 | import { withStyles } from '@material-ui/core/styles' 8 | import Avatar from '@material-ui/core/Avatar'; 9 | import UserIcon from '@material-ui/icons/AccountCircle'; 10 | import Container from '../layout/container' 11 | import userStyles from './styles/user-style' 12 | 13 | @inject(stores => ({ 14 | user: stores.appState.user, 15 | })) @observer 16 | class User extends React.Component { 17 | componentDidMount() { 18 | // do someting here 19 | } 20 | 21 | render() { 22 | const { 23 | classes, user, children, isLoginPage, 24 | } = this.props 25 | const { avatar_url: avatarUrl, loginname } = user.detail 26 | return ( 27 | 28 | 29 | 30 | { 31 | avatarUrl && !isLoginPage 32 | ? 33 | : ( 34 | 35 | 36 | 37 | ) 38 | } 39 | {(loginname && !isLoginPage) ? loginname : '未登录'} 40 | 41 | {children} 42 | 43 | ) 44 | } 45 | } 46 | 47 | User.wrappedComponent.propTypes = { 48 | user: PropTypes.object.isRequired, 49 | } 50 | 51 | User.propTypes = { 52 | classes: PropTypes.object.isRequired, 53 | children: PropTypes.element.isRequired, 54 | isLoginPage: PropTypes.bool, 55 | } 56 | export default withStyles(userStyles)(User) 57 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dingsheng1214/react-cNode/f3240456fdce601be0048a23f6681d669a8dafe3/favicon.ico -------------------------------------------------------------------------------- /l1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dingsheng1214/react-cNode/f3240456fdce601be0048a23f6681d669a8dafe3/l1.jpg -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [ 4 | ".git", 5 | "node_modules/**/node_modules", 6 | ".eslintrc", 7 | "client", 8 | "build" 9 | ], 10 | "env": { 11 | "NODE_ENV": "development" 12 | }, 13 | "verbose": true, 14 | "ext": "js" 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cNode", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "clear": "rimraf dist", 8 | "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.config.client.js", 9 | "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.config.server.js", 10 | "build": "npm run clear && npm run build:client", 11 | "dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.config.client.js", 12 | "dev:server": "nodemon server/server.js", 13 | "eslint": "eslint --ext .js --ext .jsx client/", 14 | "precommit": "npm run eslint", 15 | "uploadCdn": "node build/qiNiuUpload.js", 16 | "start": "cross-env NODE_ENV=production node server/server.js", 17 | "deploy": "npm run build && npm run uploadCdn" 18 | }, 19 | "author": "dsying", 20 | "license": "ISC", 21 | "devDependencies": { 22 | "@babel/core": "^7.2.2", 23 | "@babel/plugin-proposal-class-properties": "^7.2.3", 24 | "@babel/plugin-proposal-decorators": "^7.2.3", 25 | "@babel/preset-env": "^7.2.3", 26 | "@babel/preset-react": "^7.0.0", 27 | "babel-eslint": "^10.0.1", 28 | "babel-loader": "^8.0.5", 29 | "babel-preset-mobx": "^2.0.0", 30 | "cross-env": "^5.2.0", 31 | "eslint": "^5.12.0", 32 | "eslint-config-airbnb": "^17.1.0", 33 | "eslint-config-standard": "^12.0.0", 34 | "eslint-loader": "^2.1.1", 35 | "eslint-plugin-import": "^2.14.0", 36 | "eslint-plugin-jsx-a11y": "^6.1.2", 37 | "eslint-plugin-node": "^8.0.1", 38 | "eslint-plugin-promise": "^4.0.1", 39 | "eslint-plugin-react": "^7.12.3", 40 | "eslint-plugin-standard": "^4.0.0", 41 | "file-loader": "^3.0.1", 42 | "html-webpack-plugin": "^3.2.0", 43 | "http-proxy-middleware": "^0.19.1", 44 | "husky": "^1.3.1", 45 | "memory-fs": "^0.4.1", 46 | "nodemon": "^1.18.9", 47 | "react-hot-loader": "^4.5.3", 48 | "rimraf": "^2.6.3", 49 | "serve-favicon": "^2.5.0", 50 | "terser-webpack-plugin": "^1.2.2", 51 | "uglifyjs-webpack-plugin": "^2.1.1", 52 | "webpack": "^4.28.1", 53 | "webpack-cli": "^3.2.1", 54 | "webpack-dev-server": "^3.1.14" 55 | }, 56 | "dependencies": { 57 | "@material-ui/core": "^3.9.0", 58 | "@material-ui/icons": "^3.0.2", 59 | "axios": "^0.18.0", 60 | "body-parser": "^1.18.3", 61 | "dateformat": "^3.0.3", 62 | "express": "^4.16.4", 63 | "express-session": "^1.15.6", 64 | "global": "^4.3.2", 65 | "highlightjs": "^9.12.0", 66 | "marked": "^0.6.0", 67 | "mobx": "^5.8.0", 68 | "mobx-react": "^5.4.3", 69 | "mobx-react-devtools": "^6.0.3", 70 | "morgan": "^1.9.1", 71 | "prop-types": "^15.6.2", 72 | "qiniu": "^7.2.1", 73 | "query-string": "^6.2.0", 74 | "react": "^16.7.0", 75 | "react-dom": "^16.7.0", 76 | "react-router-dom": "^4.3.1", 77 | "react-simplemde-editor": "^4.0.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pm2.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | - script: ./server/server.js 3 | name: cnode 4 | env_production: 5 | NODE_ENV: production 6 | HOST: localhost 7 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | // const ReactSSR = require('react-dom/server') 3 | // const fs = require('fs') 4 | const path = require('path') 5 | const favicon = require('serve-favicon') 6 | const bodyParser = require('body-parser') 7 | // const queryString = require('query-string') 8 | const session = require('express-session') 9 | 10 | const app = express() 11 | 12 | const morgan = require('morgan') 13 | 14 | app.use(morgan('dev')) 15 | // 把post提交的数据 放到 req.body上 16 | app.use(bodyParser.json()) 17 | app.use(bodyParser.urlencoded({ extended: false })) 18 | 19 | app.use(session({ 20 | maxAge: 10 * 60 * 1000, 21 | name: 'tid', 22 | resave: false, // 是否每次都重新保存会话,建议false 23 | saveUninitialized: false, // 是否自动保存未初始化的会话,建议false 24 | secret: 'react cnode class' // 用来对session id相关的cookie进行签名 25 | })) 26 | 27 | app.use(favicon(path.join(__dirname, '../favicon.ico'))) 28 | 29 | app.use('/api/user', require('./util/handle-login')) 30 | app.use('/api', require('./util/proxy')) 31 | /** 32 | * 只有在production 生产环境下 我们的项目目录下才会有打包生成的dist目录 33 | * 在development 开发环境下 由于使用了 webpack-dev-server, 打包后的文件 存放在内存中,所以要区分处理 34 | */ 35 | const isDev = process.env.NODE_ENV === 'development' 36 | if (!isDev) { 37 | // --->production 38 | // 所有 /public 的url 请求的都是静态文件, 这里用到的就是 webpack的 output中的 publicPath属性 39 | app.use('/public', express.static(path.join(__dirname, '../dist'))) 40 | app.get('*', function (request, response) { 41 | response.sendFile(path.resolve(__dirname, '../dist', 'index.html')) 42 | }) 43 | } else { 44 | // --->development 45 | const devStatic = require('./util/dev-static.js') 46 | devStatic(app) 47 | } 48 | 49 | const host = process.env.host || '0.0.0.0' 50 | const port = process.env.port || '3333' 51 | app.listen(port, host, () => console.log('server is starting on port 3333')) 52 | -------------------------------------------------------------------------------- /server/util/dev-static.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const webpack = require('webpack') 3 | const path = require('path') 4 | const MemoryFs = require('memory-fs') 5 | const ReactSSR = require('react-dom/server') 6 | const proxy = require('http-proxy-middleware') 7 | 8 | // 用于接收 bundle 9 | let serverBundle 10 | 11 | // server端的 webpack 配置文件 12 | const webpackServerConfig = require('../../build/webpack.config.server') 13 | 14 | // 如果你不向 webpack 执行函数传入回调函数,就会得到一个 webpack Compiler 实例。你可以通过它手动触发 webpack 执行器,或者是让它执行构建并监听变更 15 | const serverCompiler = webpack(webpackServerConfig) 16 | 17 | const mfs = new MemoryFs() 18 | // 使用 memory-fs 替换默认的 outputFileSystem,以将文件写入到内存中,而不是写入到磁盘 19 | serverCompiler.outputFileSystem = mfs 20 | 21 | // 调用 watch 方法会触发 webpack 执行器,但之后会监听变更 一旦 webpack 检测到文件变更,就会重新执行编译。该方法返回一个 Watching 实例(它会暴露一个 .close(callback) 方法。调用该方法将会结束监听:) 22 | serverCompiler.watch({ 23 | // watchOptions 示例 24 | aggregateTimeout: 300, 25 | poll: undefined 26 | }, (err, stats /* 以通过它获取到代码编译过程中的有用信息 */) => { 27 | if (err) throw err 28 | // 以 JSON 对象形式返回编译信息 29 | stats = stats.toJson() 30 | // 打印❌ 信息 31 | stats.errors.forEach(err => console.error(err)) 32 | // 打印⚠️ 信息 33 | stats.warnings.forEach(warn => console.warn(warn)) 34 | 35 | const output = webpackServerConfig.output 36 | // server-entry.js 打包后的 完整路径 37 | const bundlePath = path.join(output.path, output.filename) 38 | 39 | // 读取 打包后的文件,但是 返回的是 string 类型 40 | const bundle = mfs.readFileSync(bundlePath, 'utf8') 41 | 42 | // 将webpack打包后生成的字符串 转化成 模块 43 | const Module = module.constructor 44 | const m = new Module() 45 | m._compile(bundle, 'server-entry.js') 46 | serverBundle = m.exports.default 47 | }) 48 | 49 | const getTemplate = () => { 50 | return new Promise((resolve, reject) => { 51 | // webpack-dev-server 打包后的文件保存在 内存中 ,通过url去访问index.html 52 | axios.get('http://localhost:8887/public/index.html') 53 | .then(res => { 54 | resolve(res.data) 55 | }) 56 | .catch(err => { 57 | reject(err) 58 | }) 59 | }) 60 | } 61 | 62 | module.exports = function (app) { 63 | // 因为 开发环境下通过webpack-dev-server打包 不生成dist目录(保存在内存中) 所以只能将 静态文件的请求 做 代理 的 处理 64 | app.use('/public', proxy({ 65 | target: 'http://localhost:8887' 66 | })) 67 | 68 | app.get('*', (req, res) => { 69 | getTemplate().then(template => { 70 | const content = ReactSSR.renderToString(serverBundle) 71 | res.send(template.replace('', content)) 72 | }) 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /server/util/handle-login.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router() 2 | const axios = require('axios') 3 | 4 | const baseUrl = 'https://cnodejs.org/api/v1' 5 | // 设置 login 路由 6 | router.post('/login', (req, res, next) => { 7 | // 验证token的正确性 8 | axios.post(`${baseUrl}/accesstoken`, { 9 | accesstoken: req.body.accesstoken 10 | }).then(result => { 11 | if (result.status === 200 && result.data.success === true) { 12 | // 如果token验证通过,则把用户信息保存在session中 13 | req.session.user = { 14 | accesstoken: req.body.accesstoken, 15 | loginname: result.data.loginname, 16 | id: result.data.id, 17 | avatar_url: result.data.avatar_url 18 | } 19 | 20 | res.json({ 21 | success: true, 22 | data: result.data 23 | }) 24 | } 25 | }).catch(err => { 26 | if (err.response) { 27 | res.json({ 28 | success: false, 29 | data: err.response.data 30 | }) 31 | } else { 32 | // 把异常抛出去 33 | next(err) 34 | } 35 | }) 36 | }) 37 | 38 | module.exports = router 39 | -------------------------------------------------------------------------------- /server/util/proxy.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const queryString = require('querystring') 3 | 4 | const baseUrl = 'https://cnodejs.org/api/v1' 5 | 6 | module.exports = function (req, res, next) { 7 | const path = req.path 8 | const user = req.session.user || {} 9 | const needAccessToken = req.query.needAccessToken 10 | 11 | // 如果请求需要 token 但是 user信息里没有 token 则 提示 需要登录 12 | if (needAccessToken && !user.accesstoken) { 13 | res.status(401).send({ 14 | success: false, 15 | msg: 'need login' 16 | }) 17 | } 18 | const query = Object.assign({}, req.query) 19 | if (query.needAccessToken) delete query.needAccessToken 20 | 21 | // 转发请求 22 | axios(`${baseUrl}${path}`, { 23 | method: req.method, 24 | params: query, 25 | data: queryString.stringify(Object.assign({}, req.body, { // 将 {a:'b'} 转换成 a=b 26 | accesstoken: user.accesstoken 27 | })), 28 | headers: { // 因为我们设置了 这种Content-Type, 所以提交的数据要按照 key1=val1&key2=val2的方式进行编码 29 | 'Content-Type': 'application/x-www-form-urlencoded' 30 | } 31 | }).then(result => { 32 | if (result.status === 200) { 33 | res.header({ 34 | 'Access-Control-Allow-Origin': '*' 35 | }) 36 | res.send(result.data) 37 | } else { 38 | res.status(result.status).send(result.data) 39 | } 40 | }).catch(err => { 41 | if (err.response) { 42 | res.status(500).send(err.response.data) 43 | } else { 44 | res.status(500).send({ 45 | success: false, 46 | msg: '未知错误' 47 | }) 48 | } 49 | }) 50 | } 51 | --------------------------------------------------------------------------------