├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Introduction.md ├── LICENSE ├── README.md ├── __mocks__ ├── apollo-boost.ts └── db.ts ├── __tests__ ├── e2e │ ├── BasicAreaGraph.spec.tsx │ ├── BasicColumnGraph.spec.tsx │ ├── ChartPanel.spec.tsx │ ├── CricleGraph.spec.tsx │ ├── CurrentHouse.spec.tsx │ ├── DoubleAxisGraph.spec.tsx │ ├── GroupedColumnGraph.spec.tsx │ ├── Header.spec.tsx │ ├── HouseDetail.spec.tsx │ ├── Loading.spec.tsx │ ├── Notice.spec.tsx │ ├── Rank.spec.tsx │ ├── StatisticCard.spec.tsx │ └── WholeTable.spec.tsx ├── service │ └── api.spec.ts ├── setupTests.ts └── unit │ ├── client-util.test.ts │ └── server-util.test.ts ├── build ├── qiniu.config.js ├── template │ ├── 404.ejs │ ├── favicon.ico │ └── index.ejs ├── webpack.analysis.config.js ├── webpack.base.config.js ├── webpack.dev.config.js └── webpack.prod.config.js ├── docker ├── Dockerfile └── docker-compose.yml ├── gulpfile.js ├── lerna-debug.log ├── lerna.json ├── package-lock.json ├── package.json ├── packages └── qiniu-upload-plugin │ ├── .editorconfig │ ├── .gitignore │ ├── .npmignore │ ├── .travis.yml │ ├── LICENSE │ ├── README.md │ ├── __mocks__ │ └── qiniu.js │ ├── __tests__ │ ├── constructor.js │ └── handler.js │ ├── appveyor.yml │ ├── index.js │ ├── lib │ └── qiniuUploadPlugin.js │ ├── package-lock.json │ ├── package.json │ └── screenshots │ └── qiniu-upload.png ├── postcss.config.js ├── src ├── client │ ├── components │ │ ├── BasicAreaGraph │ │ │ └── index.tsx │ │ ├── BasicColumnGraph │ │ │ └── index.tsx │ │ ├── ChartPanel │ │ │ └── index.tsx │ │ ├── CricleGraph │ │ │ └── index.tsx │ │ ├── CurrentHouse │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ ├── DoubleAxisGraph │ │ │ └── index.tsx │ │ ├── GroupedColumnGraph │ │ │ └── index.tsx │ │ ├── HOC │ │ │ ├── RenderLoadingComponent.tsx │ │ │ └── RenderNoEmptyComponent.tsx │ │ ├── Header │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ ├── HouseDetail │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ ├── Loading │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ ├── Notice │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ ├── Rank │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ ├── StatisticCard │ │ │ ├── index.tsx │ │ │ ├── past.tsx │ │ │ └── styles.less │ │ └── WholeTable │ │ │ └── index.tsx │ ├── config │ │ └── index.ts │ ├── constants │ │ └── index.ts │ ├── containers │ │ ├── App │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ ├── CurrentYear │ │ │ └── index.tsx │ │ ├── Home │ │ │ ├── index.tsx │ │ │ └── styles.less │ │ └── PastYear │ │ │ └── index.tsx │ ├── context │ │ ├── appContext.ts │ │ └── appContextProvider.tsx │ ├── index.tsx │ ├── router.tsx │ ├── theme │ │ ├── const.less │ │ └── reset.less │ └── utils │ │ ├── index.ts │ │ └── request.ts └── nodeuii │ ├── app.ts │ ├── config │ └── index.ts │ ├── controllers │ ├── index.ts │ └── schedule.ts │ ├── graphql │ ├── index.ts │ ├── resolvers │ │ ├── Mutation.ts │ │ ├── Query.ts │ │ ├── Type.ts │ │ └── index.ts │ └── typeDefs.graphql │ ├── middleware │ ├── AnalysicsHander.ts │ └── ErrorHander.ts │ ├── models │ ├── analyticsModel.ts │ └── houseModel.ts │ └── utils │ ├── dbHelper.ts │ ├── index.ts │ └── spiderHelper.ts ├── tsconfig.json ├── types └── cdFang.d.ts └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-transform-runtime", 9 | "@babel/plugin-syntax-dynamic-import", 10 | "@babel/plugin-proposal-class-properties", 11 | [ 12 | "import", 13 | { 14 | "libraryName": "antd", 15 | "style": true 16 | } 17 | ] 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es6: true, 6 | node: true, 7 | jest: true, 8 | }, 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | project: './tsconfig.json', 12 | }, 13 | ignorePatterns: [ 14 | 'node_modules/', 15 | 'dist/', 16 | 'build/', 17 | 'coverage/', 18 | 'package-lock.json', 19 | ], 20 | extends: [ 21 | 'eslint-config-airbnb', 22 | 'plugin:@typescript-eslint/recommended', 23 | // 关闭可能与 prettier 有冲突的规则 24 | 'prettier', 25 | 'prettier/react', 26 | 'prettier/@typescript-eslint', 27 | ], 28 | plugins: ['@typescript-eslint', 'react', 'react-hooks', 'prettier'], 29 | rules: { 30 | 'react/jsx-filename-extension': ['error', { extensions: ['.tsx'] }], 31 | 'react/jsx-props-no-spreading': 0, 32 | '@typescript-eslint/ban-ts-ignore': 0, 33 | '@typescript-eslint/no-empty-function': 0, 34 | 'import/extensions': [ 35 | 'error', 36 | 'ignorePackages', 37 | { 38 | js: 'never', 39 | jsx: 'never', 40 | ts: 'never', 41 | tsx: 'never', 42 | }, 43 | ], 44 | "no-use-before-define": "off", 45 | "@typescript-eslint/no-use-before-define": ["error"] 46 | }, 47 | // 解决不能直接默认导入 ts 文件 的问题。import/no-unresolved 48 | settings: { 49 | 'import/resolver': { 50 | webpack: { 51 | config: 'build/webpack.base.config.js', 52 | }, 53 | }, 54 | }, 55 | overrides: [ 56 | { 57 | files: ['*.ts'], 58 | rules: { 59 | '@typescript-eslint/explicit-function-return-type': 0, 60 | }, 61 | }, 62 | { 63 | files: ['*.tsx'], 64 | rules: { 65 | '@typescript-eslint/explicit-function-return-type': 0, 66 | 'react/prop-types': 0, 67 | }, 68 | }, 69 | ], 70 | }; 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build-and-deploy: 8 | name: ${{ matrix.kind }} ${{ matrix.os }} 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [macOS-latest, ubuntu-latest, windows-latest] 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2.3.1 17 | with: 18 | persist-credentials: false 19 | 20 | - name: Install and Build 21 | env: 22 | BUILD_ENV: ci 23 | run: | 24 | npm install 25 | npm run build 26 | 27 | - name: Test 28 | run: | 29 | npm run test 30 | 31 | - name: Codecov 32 | if: ${{ matrix.os == 'ubuntu-latest' }} 33 | uses: codecov/codecov-action@v1.2.1 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | directory: ./coverage/ 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn.lock 4 | .vscode 5 | yarn-error.log 6 | .nyc_output 7 | coverage 8 | logs 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /Introduction.md: -------------------------------------------------------------------------------- 1 | # 项目选型与环境搭建 2 | 3 | ## 项目选型 4 | 5 | 1. 三大框架里选哪个? 6 | 7 | - react 个人爱好。 8 | - react-router 定义路由。 9 | - react context 状态管理。 10 | - react hooks 组件化。 11 | 12 | 2. 引入强类型语言? 13 | 14 | - typescript。为 js 提供类型支持,编辑器友好,增加代码可维护性,使用起来心里踏实。 15 | - 在使用第三方库时,可以写出更加符合规范的代码,避免 api 乱用等。 16 | - 项目中依赖了大量 @types/xxx 包,无形中增加了项目体积。 17 | - 编辑器对 ts 文件进行类型检查,需要遍历 node_modules 目录下所有的 @types 文件,会造成编辑器卡顿现象。 18 | - 目前仍然存在很多库没有 @types 支持,使用起来并不方便。 19 | 20 | 3. css 选型? 21 | 22 | - 预编译器 less。项目中使用了变量定义,选择器嵌套,选择器复用等,less 够用了。 23 | - 解决命名冲突可以使用 css modules,暂未考虑 css in js。 24 | - 使用 bem 命名规范。 25 | - 使用 postcss 插件 autoprefixer,增加 css 兼容性。 26 | 27 | 4. 构建工具选哪个? 28 | 29 | - webpack。内置 tree shaking,scope hosting 等,打包效率高,社区活跃。 30 | - webpack-merge 合并不同环境配置文件。 31 | - 配置 externals。引入 cdn 代替 node_modules 中体积较大的包。 32 | - gulp。用来打包 node 端代码。 33 | 34 | 5. 代码规范检查? 35 | 36 | - eslint。辅助编码规范执行,有效控制代码质量。同时也支持校验 typescript 语法。 37 | - 配置 eslint-config-airbnb 规则。 38 | - 配置 eslint-config-prettier 关闭和 prettier 冲突的规则。 39 | 40 | 6. 测试框架选型? 41 | 42 | - jest。大而全,包含:测试框架,断言库,mock 数据,覆盖率等。 43 | - enzyme。测试 react 组件。 44 | 45 | 7. 后端框架选型? 46 | 47 | - koa。精简好用,中间件机制强大。 48 | - apollo-server。帮助搭建 graphQL 后端环境。 49 | 50 | 8. 数据库选型? 51 | 52 | - mongodb。类 json 的存错格式,方便存储,前端友好。 53 | - 配置 mongoose,方便给 mongodb 数据库建模。 54 | 55 | 9. 接口方式选型? 56 | 57 | - graphql。可以根据需要格式获取对应数据,减少接口冗余数据。 58 | - graphql schema 定义了后端接口的参数,操作和返回类型,从此不需要提供接口文档。 59 | - 前端可以在 schema 定义后开始开发,数据格式自己掌握。 60 | - schema 可拼接。可以组合和连接多个 graphql api,进行级联查询等。 61 | - 社区友好,有很多优秀的库可以直接使用: apollo,relay 等。 62 | 63 | 基本框架选型完毕,接下来就开始搭建项目环境。 64 | 65 | ## 搭建 TypeScript 环境 66 | 67 | TypeScript 是 JavaScript 的超集,意味着可以完全兼容 JavaScript 文件,但 TypeScript 文件却并不能直接在浏览器中运行,需要经过编译生成 JavaScript 文件后才能运行。 68 | 69 | 1、 新建 tsconfig.json 文件。 70 | 71 | - tsc -init 生成初始化 tsconfig.json 文件。 72 | - vscode 会根据 tsconfig.json 文件,进行动态类型检查,语法错误提示等。 73 | - tsc 命令会根据 tsconfig.json 文件配置的规则,将 ts 代码转换为 js 代码。 74 | - tslint 会读取 tsconfig.json 文件中的规则,辅助编码规范校验。 75 | - tslint 官宣会被废弃,后将被 eslint 代替。 76 | - eslint 同样会用到 tsconfig.json 文件中的内容。 77 | 78 | 2、 配置 eslint。 79 | 80 | 根据 [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint) 引导,配置 eslint 对 typescript 的支持。 81 | 82 | - @typescript-eslint/parser 解析 ts 语法。 83 | - @typescript-eslint/eslint-plugin 为 ts 文件应用 eslint 和 tslint 规则。 84 | 85 | 3、 选择一个 typescript 编译器,tsc 还是 babel? 86 | 87 | 使用 babel。好处如下: 88 | 89 | - babel 社区有许多非常好的插件,babel-preset-env 可以支持到具体兼容浏览器的版本号,而 tsc 编译器没这个功能。 90 | - babel 可以同时支持编译 js 和 ts,所以没必要在引入 tsc 编译 ts 文件,只管理一个编译器,可维护性更高。 91 | - babel 编译速度更快。tsc 编译器需要遍历所有类型定义文件(\*.d.ts),包括 node_modules 里的,以确保代码中正确地使用,type 太多会造成卡顿。 92 | 93 | > **babel 流程分析** 94 | > 95 | > babel 是一个 js 语法编译器,在编译时分为 3 个阶段:解析、转换、输出。 96 | > 97 | > - 解析阶段:将 js 代码解析为抽象语法树(ast)。 98 | > - 转换阶段:对 ast 进行修改,产生一个转换后的 ast。 99 | > - 输出阶段:将转换后的 ast 输出成 js 文件。 100 | > 101 | > **plugin 和 preset** 102 | > 103 | > - plugin: 解析,转换,并输出转换后的 js 文件。例如:@babel/plugin-proposal-object-rest-spread 会输出支持`{...}`解构语法的 js 文件。 104 | > - preset: 是一组组合好的 plugin 集合。例如:@babel/preset-env 让代码支持最新的 es 语法,自动引入需要支持新特性的 plugin。 105 | 106 | 4、搜集所有的 ts,tsx 页面(前端环境使用 webpack,node 项目使用 gulp),然后通过 babel 编译成 js 文件。 107 | 108 | ## 搭建 React 环境 109 | 110 | React 是一个库,基于组件式开发,开发时常常需要用到以下语法: 111 | 112 | - es6 模块化。 113 | - jsx 语法。 114 | - typescript 语法。 115 | - css 预处理器。 116 | 117 | 这些语法在目前浏览器中并不能直接执行,需要进行打包编译,这也是搭建 React 环境的主要工作。 118 | 119 | ### 具体步骤 120 | 121 | 1、新建一个 html 文件,并在 body 中创建一个根节点,用于挂载 react 最后生成的 dom。 122 | 123 | 2、新建一个 index.tsx 文件,用于将项目中的所有组件,引入进来,并调用 render 方法,将组件渲染到根节点中。 124 | 125 | 3、React 项目分层。 126 | 127 | - containers 目录,存放单独的页面 128 | - components 目录,存放的是组件,一个组件包含 jsx 和 css 两个部分。 129 | - context 目录,存放公用的 react context。 130 | - config 目录,存放公共配置文件。 131 | - utils 目录,公用的函数组件库。 132 | - constants 目录,存放静态变量。 133 | 134 | 4、配置 webpack,以 index.tsx 为入口文件,进行打包编译。 135 | 136 | - 由于不同环境的打包方式并不相同,这里抽象出开发环境、上线环境、优化环境的配置文件,使用 webpack-merge 合并配置文件。 137 | - 配置 css 预处理器,使用 less-loader。 138 | - 配置 ts 编译器,使用 babel-loader。 139 | - @babel/preset-env:编译最新的 es 语法。 140 | - @babel/preset-react:编译 react 语法。 141 | - @babel/preset-typescript:转换 typescript 语法。 142 | - 配置 url-loader,打包项目中的图片资源。 143 | - 配置 html-webpack-plugin 将最后生成的 js,css,注入第 1 步的 html 中。 144 | - 使用 ejs 模板配置开发环境和线上环境引入的 cdn。 145 | - 开发环境配置,使用开箱即用的 webpack-dev-server。 146 | - webpack-dev-server 可以自动监听文件修改,自动刷新页面,以及默认 source-map 等功能。 147 | - 配置热模块替换,react-hot-loader。 148 | 149 | > webpack 打包原理 150 | > 151 | > webpack 打包过程就像是一条流水线,从入口文件开始,搜集项目中所有文件的依赖关系,如果遇到不能够识别的模块,就使用对应的 loader 转换成能够识别的模块。webpack 还能使用 plugin 在流水线生命周期中挂载自定义事件,来控制 webpack 输出结果。 152 | 153 | 5、编写 npm script,一键开启开发模式。 154 | 155 | ```json 156 | // cross-env 用来跨环境设置环境变量 157 | "scripts": { 158 | "dev:client": "cross-env NODE_ENV=development webpack-dev-server --open" 159 | } 160 | ``` 161 | 162 | 6、现在运行 `npm run dev:client` 就可以愉快地编写客户端代码了。 163 | 164 | ## 搭建 NodeJs 环境 165 | 166 | 由于 node 端使用了 typescript 和最新的 es 语法,所以需要进行打包编译。 167 | 168 | - 配置 gulp,遍历每一个 ts 文件,调用 gulp-babel,将 ts 代码转换成 js 代码。 169 | - 配置 supervisor 自动重启 node 服务(nodemon 对于不存在的目录不能进行监控)。 170 | - 编写 npm script 一键启动 node 端开发环境。 171 | 172 | ```json 173 | "scripts": { 174 | "dev:server": "cross-env NODE_ENV=development gulp & cross-env NODE_ENV=development supervisor -i ./dist/client/ -w ./dist/ ./dist/app.js", 175 | } 176 | ``` 177 | 178 | 配置好 gulp 后,就可以运行 `npm run dev:server` 一键启动服务器端开发环境。 179 | 180 | ### 层次结构划分 181 | 182 | 项目采用传统的 mvc 模式进行层次划分。 183 | 184 | #### Model 层 185 | 186 | Model 层的主要工作:连接数据库,封装数据库操作,例如:新增数据、删除数据、查询数据、更新数据等。 187 | 188 | - 新建 model 文件夹,目录下的每一个文件对应数据库的一个表。 189 | - model 文件中包含对一个数据表的增删改查操作。 190 | - 使用 mongoose 更方便地对 mongodb 数据库进行读写操作。 191 | - model 文件返回封装好的对象,提供给 controller 层使用。 192 | 193 | #### Controller 层 194 | 195 | Controller 层的主要工作:接收和发送 http 请求。根据前端请求,调用 model 层获取数据,再返回给前端。 196 | 197 | > 传统的后端一般还包含 service 层,专门用来处理业务逻辑。 198 | 199 | - 根据前端请求,找到对应的 model 层获取数据,经过加工处理后,返回给前端。 200 | - 编写中间件,记录系统日志,错误处理,404 页面等。 201 | - 支持前端 react-router 中的 BrowserRouter。根据前端路由,后端配置对应的路由,匹配结果为 index.html 文件。 202 | - 项目中使用的 graphql 比较基础,也直接放在了 controller 层进行处理。 203 | 204 | #### View 层 205 | 206 | View 层的主要工作:提供前端页面模板。如果是服务器端渲染,是将 model 层的数据注入到 view 层中,最后通过 controller 层返回给客户端。由于本项目前端使用 react 渲染,所以 view 层直接是经过 webpack 打包后的页面。 207 | 208 | - 使用 koa-static 提供一个静态文件服务器,用来访问前端打包后生成的 html 文件。 209 | 210 | ### 搭建 GraphQL 环境 211 | 212 | GraphQL 是一种用于 api 的查询语言,需要服务器端配置 graphql 支持,同时也需要客户端使用 graphql 语法的格式进行请求。 213 | 214 | 使用 apollo 更快的搭建 graphql 环境。 215 | 216 | - 服务器端配置 apollo-server。 217 | - 使用 schema,定义请求的类型,返回的格式。 218 | - 使用 resolvers 来处理对应的 schema。 219 | - 客户端配置 apollo-client。 220 | - 按照 apollo-server 定义的 schema,来请求数据。 221 | 222 | ### 搭建 MongoDB 环境 223 | 224 | MongoDB 是一个面向文档存储的数据库,操作起来十分简单。 225 | 226 | Mongoose 为 mongodb 提供了一种直接的,基于 scheme 结构去定义你的数据模型。它内置数据验证,查询构建,业务逻辑钩子等,开箱即用。 227 | 228 | - 使用 mongoose 建立和本地 mongodb 的连接。 229 | - 创建 model 模型,一个模型对应 mongodb 里的一张表。 230 | - 根据 model 封装增删改查功能,并返回给 controller 层使用。 231 | 232 | 接下来的步骤就是安装 mongodb,启动服务,就可以了。 233 | 234 | ## 搭建测试环境 235 | 236 | 本项目使用 jest 作为测试框架,jest 包含了断言库、测试框架、mock 数据等功能,是一个大而全的测试库。由于前端使用了 react 项目,这里引入了专门用来测试 react 的 enzyme 库。 237 | 238 | 1、新建 jest.config.js 文件。 239 | 240 | - 配置初始化 setup.ts 文件。 241 | - 根据 react 版本配置对应的 enzyme-adapter。 242 | - mock 全局变量,如 fech,canvas 等。 243 | - 配置需要测试的文件。 244 | - 配置 mock 数据文件。 245 | - 配置测试文件的编译方式。 246 | - ts 代码使用 ts-jest 编译。 247 | - 配置代码覆盖率文件。 248 | 249 | 2、编写测试文件。 250 | 251 | - 新建\_\_mocks\_\_,\_\_tests\_\_目录,存放测试文件和 mock 数据文件。 252 | - 根据测试类型,建立测试目录。 253 | - unit 单元测试 254 | - e2e 端到端测试 255 | - service 服务测试 256 | 257 | 3、编写测试脚本和上传覆盖率脚本。 258 | 259 | ```json 260 | "scripts": { 261 | "test": "jest --no-cache --colors --coverage --forceExit --detectOpenHandles", 262 | "coverage": "codecov" 263 | } 264 | ``` 265 | 266 | ## 配置上线环境 267 | 268 | 安装好各种环境之后,接下来就要考虑项目上线了。 269 | 270 | ### 配置服务器环境 271 | 272 | - 安装 nodejs 环境。[nvm 安装 node](https://github.com/nvm-sh/nvm) 273 | - 安装 pm2 进程守护。`npm i pm2 -g` 274 | - 安装 mongodb。[mongodb 官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-red-hat/) 275 | - 安装免费 https 证书。[letsencrypt 官网](https://letsencrypt.org/) 276 | - 域名需要先进行备案(使用阿里云备案,资料准备齐全的话 10 天左右就可以批下来)。 277 | 278 | ### 代码发布 279 | 280 | 本项目发布非常简单,只需要一步操作就搞定了,这些都是经过持续集成配置后的结果。 281 | 282 | ```zsh 283 | # clone with Git Bash 284 | git clone https://github.com/mengsixing/cdfang-spider.git 285 | 286 | # change directory 287 | cd cdfang-spider 288 | 289 | # install dependencies 290 | npm i 291 | 292 | # build for production with minification 293 | npm run build 294 | ``` 295 | 296 | 所有的事情都在 build 命令下完成了,我们分析一下 npm run build 命令做的事情。 297 | 298 | - eslint 语法错误检查。 299 | - 单元测试。 300 | - 上传测试覆盖率。 301 | - 打包客户端代码。 302 | - 打包后生成 html 文件作为 node 端的 view 层,和后端绑定在一起。 303 | - 其他静态资源,在 webpack 打包后自动上传到七牛 cdn,使用 [qiniu-upload-plugin](https://www.npmjs.com/package/qiniu-upload-plugin) 来进行一键上传。 304 | - 打包服务器端代码。 305 | 306 | 上述事情通过创建 npm script 就可以了完成需求了,但这些命令也不应该每次都由手工敲一遍,通过配置 travisCI,每一次 master 分支提交代码时,自动运行上述命令就行了。 307 | 308 | #### travisCI 配置 309 | 310 | travisCI 是一个持续集成平台,每当 github 提交代码时,travisCI 就会得到通知,然后根据 travisCI 中的配置信息执行相应的操作,并及时把运行结果反馈给用户。travisCI 配置文件可以参考项目根目录下的 `.travis.yml` 文件。配置文件核心在于 script 的配置。 311 | 312 | ```yml 313 | script: 314 | - npm run build 315 | - npm run test 316 | after_success: npm run coverage 317 | ``` 318 | 319 | 可以看到,每一次 github 提交后,travisCI 就会执行 名称为 build 的任务,任务分为 2 个步骤,首先执行 build 命令,然后执行 test 命令,当命令都执行完成后,执行 coverage 命令。如果执行命令期间出现任何错误,travisCI 会通过邮件及时通知我们。真正要上线时,先查看 ci 状态,如果已通过所有的步骤,那就不用担心发布的代码有问题了。 320 | 321 | ### Docker 自动部署 322 | 323 | 最新的项目采用 Docker 进行部署,大大降低了部署的难度,只要安装 Docker 和 Docker Compose ,运行一行命令就可以部署好本项目。具体的步骤: 324 | 325 | ```shell 326 | # clone with Git Bash 327 | git clone https://github.com/mengsixing/cdfang-spider.git 328 | 329 | # change directory 330 | cd cdfang-spider/docker 331 | 332 | # run docker containers. It may take a long time. 333 | docker-compose up -d 334 | 335 | # server running at localhost:8082 336 | ``` 337 | 338 | Docker 部署项目原理可以参考[Docker 使用总结](https://mengsixing.github.io/blog/project-docker.html)。 339 | 340 | ## 总结 341 | 342 | 至此,整个项目选型与搭建流程已经介绍完毕了,当然还有一些很细节的地方没有写进去,如果有不太明白的地方,可以提 issue,或者加我微信 yhl2016226。 343 | 344 | 接下来对以下 4 个方面写个小总结。 345 | 346 | - 开发方面:项目将前端、后端、数据库端连通起来,组合成了一个小全栈的项目,加深了我对整个开发环节的理解。 347 | - 测试方面:通过编写单元测试,ui 测试,api 测试,积累了自动化测试方面的经验。 348 | - 运维方面:通过配置持续集成,守护进程,nginx,https 等,让我有能力实现小型项目的部署。 349 | - 技术方面:项目中使用了一些比较新的技术,如:hooks api,graphql 等,但用的都很基础,主要是为了练手,后续还得深入学习。 350 | 351 | 对于项目后期更新,主要是基于以下几个方面:graphql,docker,k8s,微服务,serverless 等,东西太多,还得加油学习啊,😂 352 | 353 | ## 参考链接 354 | 355 | - [TypeScript 和 Babel](https://juejin.im/post/5c822e426fb9a04a0a5ffb49) 356 | - [前端决策树](https://github.com/sorrycc/f2e-decision-tree) 357 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2021 mengsixing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cdfang-spider 2 | 3 | [![Build Status](https://github.com/mengsixing/cdfang-spider/workflows/ci/badge.svg)](https://github.com/mengsixing/cdfang-spider/actions) 4 | [![codecov](https://codecov.io/gh/mengsixing/cdfang-spider/branch/master/graph/badge.svg)](https://codecov.io/gh/mengsixing/cdfang-spider) 5 | [![David](https://img.shields.io/david/mengsixing/cdfang-spider.svg)](https://david-dm.org/mengsixing/cdfang-spider) 6 | 7 | > 成都房协网数据分析 8 | 9 | 统计成都自摇号以来所有的房源信息,通过图表的形式展示出来。 10 | 11 | ## Preview 12 | 13 | 在线预览:[https://cdfangyuan.cn](https://cdfangyuan.cn) 14 | 15 | 源代码:[https://github.com/mengsixing/cdfang-spider](https://github.com/mengsixing/cdfang-spider) 16 | 17 | ## Features 18 | 19 | 已实现的功能: 20 | 21 | - [x] 房源数据同步最新。 22 | - [x] 房源价格动态保持更新。 23 | - [x] 首页:登记中房源、汇总统计展示。 24 | - [x] 首页:按楼盘数、房源数统计,及统计结果排名。 25 | - [x] 首页:按区域统计,汇总表查询。 26 | - [x] 历史页:年度汇总统计展示。 27 | - [x] 历史页:按房源数、区域、月份统计,及统计结果排名。 28 | - [x] 历史页:按房源数、楼盘数、区域统计,汇总表查询。 29 | 30 | ## Technology 31 | 32 | 主要用到的技术: 33 | 34 | - React:MVVM 框架,用于构建前端界面。 35 | - Ant Design:基于 React 的组件库。 36 | - Bizchats:基于 React 的图表库。 37 | - Less:CSS 预处理器,提供变量、计算、嵌套、Mixin、函数等。 38 | - Webpack:打包前端项目,生成静态文件。 39 | - Apollo:基于 GraphQL 封装,用于搭建后端 GraphQL 支持和前端数据请求。 40 | - Koa:后端 Web 层框架,用于接收请求,进行处理。 41 | - Cheerio:解析抓取的 HTML 数据。 42 | - Mongoose:为 MongoDB 定义数据模型。 43 | - Gulp:打包后端项目,编译 TS 语法。 44 | - Jest:测试前后端项目,单元测试,API 测试等。 45 | - Typescript:为 JS 提供良好的类型检查功能,能编译出高质量的 JS 代码。 46 | 47 | 项目没有使用脚手架工具搭建,都是一步一步配置而成。具体的搭建流程:[项目选型与环境搭建](https://github.com/mengsixing/cdfang-spider/blob/master/Introduction.md)。 48 | 49 | ## Start 50 | 51 | ```shell 52 | # clone with Git Bash 53 | git clone https://github.com/mengsixing/cdfang-spider.git 54 | 55 | # change directory 56 | cd cdfang-spider 57 | 58 | # install dependencies 59 | npm i 60 | 61 | # serve with hot reload at localhost:8080 62 | npm run dev 63 | 64 | # build for production with minification 65 | npm run build 66 | ``` 67 | 68 | ## Deploy 69 | 70 | ```shell 71 | # clone with Git Bash 72 | git clone https://github.com/mengsixing/cdfang-spider.git 73 | 74 | # change directory 75 | cd cdfang-spider/docker 76 | 77 | # run docker containers. It may take a long time. 78 | docker-compose up -d 79 | 80 | # server running at localhost:8082 81 | ``` 82 | 83 | ## License 84 | 85 | [MIT](https://github.com/mengsixing/cdfang-spider/blob/master/LICENSE) 86 | 87 | Copyright (c) 2020 mengsixing 88 | -------------------------------------------------------------------------------- /__mocks__/apollo-boost.ts: -------------------------------------------------------------------------------- 1 | class ApolloClient { 2 | constructor() {} 3 | query() { 4 | return Promise.resolve({ 5 | data: { 6 | allHouses: [], 7 | }, 8 | }); 9 | } 10 | } 11 | 12 | export default ApolloClient; 13 | -------------------------------------------------------------------------------- /__mocks__/db.ts: -------------------------------------------------------------------------------- 1 | const mockHouse = [ 2 | { 3 | _id: '6D0555B626DD01BAE053AC1D15D9D93C', 4 | area: '新都区', 5 | name: '龙湖听蓝湾', 6 | number: 343, 7 | beginTime: '2018-06-02 09:00:00', 8 | endTime: '2018-06-04 18:00:00', 9 | status: '报名结束', 10 | __v: 0, 11 | }, 12 | { 13 | _id: '6D3C13A6D47B01E4E053AC1D15D70D6C', 14 | area: '金堂县', 15 | name: '和裕·印象三期', 16 | number: 148, 17 | beginTime: '2018-05-30 09:00:00', 18 | endTime: '2018-06-01 18:00:00', 19 | status: '报名结束', 20 | __v: 0, 21 | }, 22 | { 23 | _id: '6CAF9D0A9C370496E053AC1D15D7073A', 24 | area: '双流区', 25 | name: '蓝光江安城六期', 26 | number: 608, 27 | beginTime: '2018-05-22 09:00:00', 28 | endTime: '2018-05-24 18:00:00', 29 | status: '报名结束', 30 | __v: 0, 31 | }, 32 | { 33 | _id: '6AB7339BCA5A02CAE053AC1D15D7E9B2', 34 | area: '金堂县', 35 | name: '金山国际二期', 36 | number: 168, 37 | beginTime: '2018-05-02 09:00:00', 38 | endTime: '2018-05-04 18:00:00', 39 | status: '报名结束', 40 | __v: 0, 41 | }, 42 | { 43 | _id: '69EF4AA5EC790050E053AC1D15D7C87F', 44 | area: '新津县', 45 | name: '蓝泊湾小区二期', 46 | number: 26, 47 | beginTime: '2018-04-18 09:00:00', 48 | endTime: '2018-04-20 18:00:00', 49 | status: '报名结束', 50 | __v: 0, 51 | }, 52 | { 53 | _id: '689AE95ED49702A8E053AC1D15D7181F', 54 | area: '青白江区', 55 | name: '尚林幸福城', 56 | number: 136, 57 | beginTime: '2018-03-31 09:00:00', 58 | endTime: '2018-04-02 18:00:00', 59 | status: '报名结束', 60 | __v: 0, 61 | }, 62 | { 63 | _id: '6720BEA70C7D0080E053AC1D15D960D8', 64 | area: '双流区', 65 | name: '紫郡蘭园', 66 | number: 251, 67 | beginTime: '2018-03-15 09:00:00', 68 | endTime: '2018-03-17 18:00:00', 69 | status: '报名结束', 70 | __v: 0, 71 | }, 72 | { 73 | _id: '648314368A520094E053AC1D15D70466', 74 | area: '成华区', 75 | name: '恒大锦城', 76 | number: 286, 77 | beginTime: '2018-02-24 09:00:00', 78 | endTime: '2018-02-26 18:00:00', 79 | status: '报名结束', 80 | __v: 0, 81 | }, 82 | { 83 | _id: '6420CDD67BDC0084E053AC1D15D98475', 84 | area: '青白江区', 85 | name: '棠湖清江花语', 86 | number: 784, 87 | beginTime: '2018-02-02 09:00:00', 88 | endTime: '2018-02-04 18:00:00', 89 | status: '报名结束', 90 | __v: 0, 91 | }, 92 | { 93 | _id: '6395DB1F8C4A00C2E053AC1D15D95E3D', 94 | area: '郫都区', 95 | name: '金安花苑', 96 | number: 294, 97 | beginTime: '2018-01-26 09:00:00', 98 | endTime: '2018-01-28 18:00:00', 99 | status: '报名结束', 100 | __v: 0, 101 | }, 102 | { 103 | _id: '61F2AFCA0E90028EE053AC1D15D775B3', 104 | area: '新津县', 105 | name: '雍景苑', 106 | number: 176, 107 | beginTime: '2018-01-06 09:00:00', 108 | endTime: '2018-01-08 18:00:00', 109 | status: '报名结束', 110 | __v: 0, 111 | }, 112 | { 113 | _id: '6D166490F6B0004AE053AC1D15D9C457', 114 | area: '双流区', 115 | name: '汇都华庭一、二期', 116 | number: 634, 117 | beginTime: '2018-05-31 09:00:00', 118 | endTime: '2018-06-02 18:00:00', 119 | status: '报名结束', 120 | __v: 0, 121 | }, 122 | { 123 | _id: '6CC8C09CA0A50310E053AC1D15D71897', 124 | area: '龙泉驿区', 125 | name: '中国水电云立方三期', 126 | number: 188, 127 | beginTime: '2018-05-23 09:00:00', 128 | endTime: '2018-05-25 18:00:00', 129 | status: '报名结束', 130 | __v: 0, 131 | }, 132 | { 133 | _id: '6ACB430C1E300212E053AC1D15D71A80', 134 | area: '温江区', 135 | name: '恒大未来城三期', 136 | number: 282, 137 | beginTime: '2018-05-03 09:00:00', 138 | endTime: '2018-05-05 18:00:00', 139 | status: '报名结束', 140 | __v: 0, 141 | }, 142 | { 143 | _id: '6A2B04B6AAFA017EE053AC1D15D7DB63', 144 | area: '大邑县', 145 | name: '春熙江岸', 146 | number: 90, 147 | beginTime: '2018-04-20 09:00:00', 148 | endTime: '2018-04-22 18:00:00', 149 | status: '报名结束', 150 | __v: 0, 151 | }, 152 | ]; 153 | 154 | const mockArea = [ 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 | export { mockHouse, mockArea }; 180 | -------------------------------------------------------------------------------- /__tests__/e2e/BasicAreaGraph.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import BasicAreaGraph, { 5 | Iprops, 6 | } from '../../src/client/components/BasicAreaGraph'; 7 | 8 | const props: Iprops = { 9 | title: '房源数', 10 | data: [ 11 | { month: '2018-08-08', 楼盘数: 8 }, 12 | { month: '2018-08-09', 楼盘数: 9 }, 13 | { month: '2018-08-10', 楼盘数: 10 }, 14 | ], 15 | }; 16 | 17 | let wrapper: RenderResult; 18 | describe('BasicAreaGraph 组件', () => { 19 | beforeEach(() => { 20 | wrapper = render(); 21 | }); 22 | 23 | it('默认 title 是否正确渲染', () => { 24 | expect( 25 | wrapper.getByText(`${props.title} / 月(统计图)`) 26 | ).toBeInTheDocument(); 27 | }); 28 | 29 | it('自定义 title 是否正确渲染', () => { 30 | wrapper.rerender(); 31 | expect(wrapper.getByText(`楼盘数 / 月(统计图)`)).toBeInTheDocument(); 32 | }); 33 | 34 | it('图表是否正确渲染', () => { 35 | expect(wrapper.container.querySelector('canvas')).toBeInTheDocument(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /__tests__/e2e/BasicColumnGraph.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import BasicColumnGraph, { 5 | Iprops, 6 | } from '../../src/client/components/BasicColumnGraph'; 7 | 8 | const props: Iprops = { 9 | title: '房源数排序图', 10 | data: [ 11 | { 区域: '新津县', 楼盘数: 8 }, 12 | { 区域: '金堂县', 楼盘数: 7 }, 13 | { 区域: '双流区', 楼盘数: 7 }, 14 | ], 15 | xAxis: '区域', 16 | yAxis: '房源', 17 | desc: true, 18 | }; 19 | 20 | let wrapper: RenderResult; 21 | describe('BasicColumnGraph 组件', () => { 22 | beforeEach(() => { 23 | wrapper = render(); 24 | }); 25 | 26 | it('title 是否渲染正确渲染', () => { 27 | expect(wrapper.getByText(props.title)).toBeInTheDocument(); 28 | }); 29 | 30 | it('图表是否正确渲染', () => { 31 | expect(wrapper.container.querySelector('canvas')).toBeInTheDocument(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /__tests__/e2e/ChartPanel.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import { AppContext, globalData } from '../../src/client/context/appContext'; 5 | import ChartPanel, { Iprops } from '../../src/client/components/ChartPanel'; 6 | 7 | const props: Iprops = { 8 | data: [ 9 | { 10 | _id: '', 11 | area: '高新南区', 12 | beginTime: '2018-12-27 09:00:00', 13 | endTime: '2018-12-29 18:00:00', 14 | name: '融创香璟台西苑', 15 | number: 56, 16 | status: '报名结束', 17 | }, 18 | ], 19 | panelKey: '高新南区', 20 | activityKey: '高新南区', 21 | }; 22 | 23 | let wrapper: RenderResult; 24 | describe('ChartPanel 组件', () => { 25 | beforeEach(() => { 26 | wrapper = render( 27 | 28 | 29 | 30 | ); 31 | }); 32 | 33 | it('子图表是否渲染正确渲染', () => { 34 | expect(wrapper.container.querySelectorAll('canvas').length).toBe(2); 35 | expect(wrapper.container.querySelector('.rank')).toBeInTheDocument(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /__tests__/e2e/CricleGraph.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import CricleGraph, { Iprops } from '../../src/client/components/CricleGraph'; 5 | 6 | const props: Iprops = { 7 | data: [ 8 | { 9 | _id: '', 10 | area: '高新南区', 11 | beginTime: '2018-12-27 09:00:00', 12 | endTime: '2018-12-29 18:00:00', 13 | name: '融创香璟台西苑', 14 | number: 56, 15 | status: '报名结束', 16 | }, 17 | ], 18 | isChangeTab: false, 19 | changeMonth: () => {}, 20 | }; 21 | 22 | let wrapper: RenderResult; 23 | describe('CricleGraph 组件', () => { 24 | beforeEach(() => { 25 | wrapper = render(); 26 | }); 27 | 28 | it('是否渲染正确渲染', () => { 29 | expect(wrapper.getByText('房源分部图')).toBeInTheDocument(); 30 | expect(wrapper.container.querySelector('canvas')).toBeInTheDocument(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /__tests__/e2e/CurrentHouse.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import { 5 | AppContext, 6 | globalData, 7 | IappContext, 8 | } from '../../src/client/context/appContext'; 9 | import CurrentHouse from '../../src/client/components/CurrentHouse'; 10 | 11 | const appState: IappContext = { 12 | ...globalData, 13 | allData: [ 14 | { 15 | _id: '', 16 | area: '高新南区', 17 | beginTime: '2018-12-27 09:00:00', 18 | endTime: '2018-12-29 18:00:00', 19 | name: '融创香璟台西苑', 20 | number: 56, 21 | status: '报名中', 22 | }, 23 | ], 24 | activityKey: '高新南区', 25 | }; 26 | 27 | let wrapper: RenderResult; 28 | describe('CurrentHouse 组件', () => { 29 | beforeEach(() => { 30 | wrapper = render( 31 | 32 | 33 | 34 | ); 35 | }); 36 | 37 | it('是否正确渲染', () => { 38 | expect(wrapper.getByText('正在登记')).toBeInTheDocument(); 39 | expect( 40 | wrapper.container.querySelector('.ant-list-item') 41 | ).toBeInTheDocument(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /__tests__/e2e/DoubleAxisGraph.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import DoubleAxisGraph, { 5 | Iprops, 6 | } from '../../src/client/components/DoubleAxisGraph'; 7 | 8 | const props: Iprops = { 9 | data: [ 10 | { 11 | area: '高新南区', 12 | beginTime: '2018-12-27 09:00:00', 13 | endTime: '2018-12-29 18:00:00', 14 | name: '融创香璟台西苑', 15 | number: 56, 16 | status: '报名结束', 17 | _id: '', 18 | }, 19 | ], 20 | }; 21 | 22 | let wrapper: RenderResult; 23 | describe('DoubleAxisGraph 组件', () => { 24 | beforeEach(() => { 25 | wrapper = render(); 26 | }); 27 | 28 | it('是否渲染成功 ?', () => { 29 | expect(wrapper.container.querySelector('canvas')).toBeInTheDocument(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /__tests__/e2e/GroupedColumnGraph.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import GroupedColumnGraph, { 5 | Iprops, 6 | } from '../../src/client/components/GroupedColumnGraph'; 7 | 8 | const props: Iprops = { 9 | data: [ 10 | { 11 | area: '高新南区', 12 | beginTime: '2018-12-27 09:00:00', 13 | endTime: '2018-12-29 18:00:00', 14 | name: '融创香璟台西苑', 15 | number: '56', 16 | status: '报名结束', 17 | price: '0', 18 | _id: '', 19 | }, 20 | ], 21 | title: '房价统计图', 22 | }; 23 | 24 | let wrapper: RenderResult; 25 | describe('GroupedColumnGraph 组件', () => { 26 | beforeEach(() => { 27 | wrapper = render(); 28 | }); 29 | 30 | it('是否正确渲染', () => { 31 | expect(wrapper.getByText('房价统计图')).toBeInTheDocument(); 32 | expect(wrapper.container.querySelector('canvas')).toBeInTheDocument(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /__tests__/e2e/Header.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult, act } from '@testing-library/react'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | import Header from '../../src/client/components/Header'; 6 | 7 | let wrapper: RenderResult; 8 | describe('Header 组件', () => { 9 | it('是否渲染成功 ?', async () => { 10 | await act(async () => { 11 | wrapper = await render( 12 | 13 |
14 | 15 | ); 16 | }); 17 | expect( 18 | wrapper.container.querySelector('.cdfang-header-item') 19 | ).toBeInTheDocument(); 20 | }); 21 | 22 | it('title是否正确 ?', async () => { 23 | await act(async () => { 24 | wrapper = await render( 25 | 26 |
27 | 28 | ); 29 | }); 30 | expect( 31 | wrapper.container.querySelector('.ant-menu-item-selected')?.textContent 32 | ).toEqual('首页'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /__tests__/e2e/HouseDetail.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | 6 | import HouseDetail from '../../src/client/components/HouseDetail'; 7 | 8 | let wrapper: RenderResult; 9 | describe('HouseDetail 组件', () => { 10 | beforeEach(() => { 11 | wrapper = render( 12 | 13 | 14 | 15 | ); 16 | }); 17 | 18 | it('是否渲染成功 ?', () => { 19 | expect(wrapper.getByText('保利天空之城')).toBeInTheDocument(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /__tests__/e2e/Loading.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import Loading from '../../src/client/components/Loading'; 5 | 6 | const props = { 7 | tip: 'test', 8 | }; 9 | 10 | let wrapper: RenderResult; 11 | describe('Loading 组件', () => { 12 | beforeEach(() => { 13 | wrapper = render(); 14 | }); 15 | 16 | it('是否存在根元素?', () => { 17 | expect( 18 | wrapper.container.querySelector('.common-loading') 19 | ).toBeInTheDocument(); 20 | }); 21 | 22 | it('是否渲染 tip 成功 ?', () => { 23 | expect( 24 | wrapper.container.querySelector('.common-loading') 25 | ).toBeInTheDocument(); 26 | expect(wrapper.getByText('test')).toBeInTheDocument(); 27 | expect(wrapper.container.querySelector('.ant-spin-text')?.textContent).toBe( 28 | props.tip 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /__tests__/e2e/Notice.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { 4 | render, 5 | RenderResult, 6 | fireEvent, 7 | waitFor, 8 | } from '@testing-library/react'; 9 | import Notice from '../../src/client/components/Notice'; 10 | 11 | let wrapper: RenderResult; 12 | describe('Notice 组件', () => { 13 | beforeEach(() => { 14 | wrapper = render(); 15 | }); 16 | 17 | it('是否渲染成功?', () => { 18 | expect(wrapper.container.querySelector('.notice-icon')).toBeInTheDocument(); 19 | expect(wrapper.container.querySelector('svg')).toBeInTheDocument(); 20 | }); 21 | 22 | it('点击获取消息', async () => { 23 | fireEvent.click(wrapper.container); 24 | await waitFor(() => { 25 | // todo: 模拟 ajax 26 | expect( 27 | wrapper.container.querySelector('.notice-icon') 28 | ).toBeInTheDocument(); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /__tests__/e2e/Rank.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | 5 | import Rank, { Iprops } from '../../src/client/components/Rank'; 6 | 7 | const props: Iprops = { 8 | title: '2018年06月', 9 | unit: '套', 10 | data: [ 11 | { 12 | _id: '', 13 | name: '融创香璟台西苑', 14 | number: 56, 15 | }, 16 | ], 17 | }; 18 | 19 | let wrapper: RenderResult; 20 | describe('Rank 组件', () => { 21 | beforeEach(() => { 22 | wrapper = render(); 23 | }); 24 | it('title 是否正确 ?', () => { 25 | expect(wrapper.container.querySelector('.rank-title')?.textContent).toBe( 26 | `排名:${props.title}` 27 | ); 28 | }); 29 | it('渲染列表是否正确 ?', () => { 30 | expect(wrapper.container.querySelectorAll('.rank-list>li').length).toBe( 31 | props.data.length 32 | ); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /__tests__/e2e/StatisticCard.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | import { AppContext, globalData } from '../../src/client/context/appContext'; 5 | import StatisticCard from '../../src/client/components/StatisticCard'; 6 | import StatisticCardPast from '../../src/client/components/StatisticCard/past'; 7 | import { mockHouse } from '../../__mocks__/db'; 8 | 9 | const data = { ...globalData, allData: mockHouse }; 10 | 11 | let wrapper: RenderResult; 12 | describe('StatisticCard 组件', () => { 13 | beforeEach(() => { 14 | wrapper = render( 15 | 16 |
17 | 18 | 19 |
20 |
21 | ); 22 | }); 23 | it('是否渲染成功 ?', () => { 24 | // 两个组件 4*2 =8 25 | expect(wrapper.container.querySelectorAll('.ant-card').length).toBe(4 * 2); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /__tests__/e2e/WholeTable.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import { render, RenderResult } from '@testing-library/react'; 4 | 5 | import { AppContext, globalData } from '../../src/client/context/appContext'; 6 | import WholeTable from '../../src/client/components/WholeTable'; 7 | import { mockHouse } from '../../__mocks__/db'; 8 | 9 | const appState = { 10 | ...globalData, 11 | allData: mockHouse, 12 | activityKey: '高新南区', 13 | }; 14 | 15 | let wrapper: RenderResult; 16 | describe('WholeTable 组件', () => { 17 | beforeEach(() => { 18 | wrapper = render( 19 | 20 | 21 | 22 | ); 23 | }); 24 | it('是否渲染成功 ?', () => { 25 | expect(wrapper.container.querySelector('table')).toBeInTheDocument(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /__tests__/service/api.spec.ts: -------------------------------------------------------------------------------- 1 | /*eslint-disable */ 2 | import supertest from 'supertest'; 3 | const server = require('../../dist/app.js'); 4 | 5 | describe('api 测试', () => { 6 | function request() { 7 | return supertest(server); 8 | } 9 | 10 | it('getMongoData 接口返回数据格式是否正确?', done => { 11 | request() 12 | .get('/getMongoData') 13 | .then(response => { 14 | const house = JSON.parse(response.text)[0]; 15 | expect(house).toHaveProperty('_id'); 16 | expect(house).toHaveProperty('area'); 17 | expect(house).toHaveProperty('name'); 18 | expect(house).toHaveProperty('number'); 19 | expect(house).toHaveProperty('beginTime'); 20 | expect(house).toHaveProperty('endTime'); 21 | expect(house).toHaveProperty('status'); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('获取 pvs 接口是否正确?', done => { 27 | request() 28 | .get('/graphql?query={pvs(routerName:%22allHouses%22)}') 29 | .then(response => { 30 | const pvsObj = JSON.parse(response.text).data; 31 | expect(pvsObj).toHaveProperty('pvs'); 32 | expect(typeof pvsObj.pvs).toBe('number'); 33 | done(); 34 | }); 35 | }); 36 | 37 | it('获取房源接口是否正确?', done => { 38 | request() 39 | .get( 40 | '/graphql?query={%20allHouses(year:%200)%20{%20_id%20area%20name%20number%20beginTime%20endTime%20status%20}%20}' 41 | ) 42 | .then(response => { 43 | const allHouses = JSON.parse(response.text).data.allHouses; 44 | expect(allHouses instanceof Array); 45 | const house = allHouses[0]; 46 | expect(house).toHaveProperty('_id'); 47 | expect(house).toHaveProperty('area'); 48 | expect(house).toHaveProperty('name'); 49 | expect(house).toHaveProperty('number'); 50 | expect(house).toHaveProperty('beginTime'); 51 | expect(house).toHaveProperty('endTime'); 52 | expect(house).toHaveProperty('status'); 53 | done(); 54 | }); 55 | }); 56 | 57 | afterAll(() => { 58 | server.close(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /__tests__/setupTests.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'unfetch'; 2 | 3 | window.fetch = fetch; 4 | Object.defineProperty(window, 'matchMedia', { 5 | writable: true, 6 | value: jest.fn().mockImplementation((query) => ({ 7 | matches: false, 8 | media: query, 9 | onchange: null, 10 | addListener: jest.fn(), // deprecated 11 | removeListener: jest.fn(), // deprecated 12 | addEventListener: jest.fn(), 13 | removeEventListener: jest.fn(), 14 | dispatchEvent: jest.fn(), 15 | })), 16 | }); 17 | -------------------------------------------------------------------------------- /__tests__/unit/client-util.test.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { mockHouse as mockData, mockArea } from '../../__mocks__/db'; 3 | import util from '../../src/client/utils/index'; 4 | 5 | const setup = () => ({ 6 | currentQuarter: util.getCurrentQuarter(), 7 | allInfo: util.getAllInfo(mockData), 8 | thisWeekInfo: util.getThisWeekInfo(mockData), 9 | thisMonthInfo: util.getThisMonthInfo(mockData), 10 | thisQuarterInfo: util.getThisQuarterInfo(mockData) 11 | }); 12 | 13 | describe('client util测试', () => { 14 | const { 15 | currentQuarter, 16 | allInfo, 17 | thisWeekInfo, 18 | thisMonthInfo, 19 | thisQuarterInfo 20 | } = setup(); 21 | it('getCurrentQuarter返回参数是否正确?', () => { 22 | expect(typeof util.getCurrentQuarter).toBe('function'); 23 | const quarterMap = [0, 0, 0, 0, 3, 3, 3, 6, 6, 6, 9, 9, 9]; 24 | for (let i = 1; i <= 12; i += 1) { 25 | expect( 26 | util.getCurrentQuarter(dayjs(`2019-${i}-10`)).thisQuarterStart.month() 27 | ).toBe(quarterMap[i]); 28 | } 29 | expect([0, 3, 6, 9]).toContain(currentQuarter.thisQuarterStart.month()); 30 | }); 31 | it('getAllInfo返回参数是否正确?', () => { 32 | expect(typeof util.getAllInfo).toBe('function'); 33 | expect(allInfo).toHaveProperty('houseNumber'); 34 | expect(allInfo).toHaveProperty('buildNumber'); 35 | expect(typeof allInfo.houseNumber).toBe('number'); 36 | expect(typeof allInfo.buildNumber).toBe('number'); 37 | }); 38 | it('getThisWeekInfo返回参数是否正确?', () => { 39 | expect(typeof util.getThisWeekInfo).toBe('function'); 40 | expect(thisWeekInfo).toHaveProperty('houseNumber'); 41 | expect(thisWeekInfo).toHaveProperty('buildNumber'); 42 | expect(thisWeekInfo).toHaveProperty('increaseBuildNumber'); 43 | expect(thisWeekInfo).toHaveProperty('increaseHouseNumber'); 44 | expect(thisWeekInfo).toHaveProperty('increaseBuildNumberString'); 45 | expect(thisWeekInfo).toHaveProperty('increaseHouseNumberString'); 46 | expect(typeof thisWeekInfo.houseNumber).toBe('number'); 47 | expect(typeof thisWeekInfo.buildNumber).toBe('number'); 48 | }); 49 | it('getThisMonthInfo返回参数是否正确?', () => { 50 | expect(typeof util.getThisMonthInfo).toBe('function'); 51 | expect(thisMonthInfo).toHaveProperty('houseNumber'); 52 | expect(thisMonthInfo).toHaveProperty('buildNumber'); 53 | expect(thisMonthInfo).toHaveProperty('increaseBuildNumber'); 54 | expect(thisMonthInfo).toHaveProperty('increaseHouseNumber'); 55 | expect(thisMonthInfo).toHaveProperty('increaseBuildNumberString'); 56 | expect(thisMonthInfo).toHaveProperty('increaseHouseNumberString'); 57 | expect(typeof thisMonthInfo.houseNumber).toBe('number'); 58 | expect(typeof thisMonthInfo.buildNumber).toBe('number'); 59 | }); 60 | it('getThisQuarterInfo返回参数是否正确?', () => { 61 | expect(typeof util.getThisQuarterInfo).toBe('function'); 62 | expect(thisQuarterInfo).toHaveProperty('houseNumber'); 63 | expect(thisQuarterInfo).toHaveProperty('buildNumber'); 64 | expect(thisQuarterInfo).toHaveProperty('increaseBuildNumber'); 65 | expect(thisQuarterInfo).toHaveProperty('increaseHouseNumber'); 66 | expect(thisQuarterInfo).toHaveProperty('increaseBuildNumberString'); 67 | expect(thisQuarterInfo).toHaveProperty('increaseHouseNumberString'); 68 | expect(typeof thisQuarterInfo.houseNumber).toBe('number'); 69 | expect(typeof thisQuarterInfo.buildNumber).toBe('number'); 70 | }); 71 | it('sortArea返回参数是否正确?', () => { 72 | expect(typeof util.sortArea).toBe('function'); 73 | expect(util.sortArea(mockArea)).toHaveLength(mockArea.length); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /__tests__/unit/server-util.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undefined */ 2 | import * as util from '../../src/nodeuii/utils'; 3 | 4 | const mockArray = [ 5 | [ 6 | '7EFCBD1761A402A2E053AC1D15D793F3', 7 | '', 8 | '彭州市', 9 | '成都恒大翡翠龙庭建设项目(A地块)', 10 | '51018220191230', 11 | '第6、7、10、11栋', 12 | '301', 13 | '028-62576999 ', 14 | '2019-04-30 09:00:00', 15 | '2019-05-02 18:00:00', 16 | '2019-05-18', 17 | '2021-01-25', 18 | '2021-01-11 08:59:59', 19 | '正在报名', 20 | '查看' 21 | ] 22 | ]; 23 | 24 | const setup = () => ({ 25 | transformedArray: util.transformArray(mockArray) 26 | }); 27 | 28 | describe('transformArray测试', () => { 29 | const { transformedArray } = setup(); 30 | it('transformArray返回参数长度是否正确?', () => { 31 | expect(typeof util.transformArray).toBe('function'); 32 | expect(transformedArray).toHaveLength(1); 33 | expect(transformedArray[0]).toEqual({ 34 | _id: '7EFCBD1761A402A2E053AC1D15D793F3', 35 | area: '彭州市', 36 | name: '成都恒大翡翠龙庭建设项目(A地块)', 37 | number: 301, 38 | beginTime: '2019-04-30 09:00:00', 39 | endTime: '2019-05-02 18:00:00', 40 | status: '正在报名' 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /build/qiniu.config.js: -------------------------------------------------------------------------------- 1 | // 我的七牛云存储信息,请勿使用!! 2 | module.exports = { 3 | publicPath: 'https://cdn.yinhengli.com/', 4 | accessKey: 'usrU1J2-BTCqaODusj4JnOtW-SPGbw59hVUcFeHJ', 5 | secretKey: 'Ff0Ggl7l8XK2Ysm33pWKfHF0IZWqdVL1uHVl6Mge', 6 | bucket: 'mygithub', 7 | zone: 'Zone_z2', 8 | cover: true 9 | }; 10 | -------------------------------------------------------------------------------- /build/template/404.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 找不到对应页面 15 | 24 | 25 | 29 | 37 | 38 | 39 | 46 | 47 | -------------------------------------------------------------------------------- /build/template/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengsixing/cdfang-spider/8c768cdcfe496f6b805202fbb3851d00899bdd37/build/template/favicon.ico -------------------------------------------------------------------------------- /build/template/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 成都房源分析 13 | 22 | 23 | 24 |
25 | 26 | <% if (htmlWebpackPlugin.options.env !== 'production') { %> 27 | 28 | 29 | <% } else { %> 30 | 31 | 32 | 33 | 45 | <% } %> 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /build/webpack.analysis.config.js: -------------------------------------------------------------------------------- 1 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 2 | // const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); 3 | 4 | const config = require('./webpack.prod.config'); 5 | 6 | // 检查loader,plugin的运行速度(打开时运行很慢,先关闭) 7 | // const smp = new SpeedMeasurePlugin(); 8 | 9 | config.plugins.push(new BundleAnalyzerPlugin()); 10 | 11 | // module.exports = smp.wrap(config); 12 | module.exports = config; 13 | -------------------------------------------------------------------------------- /build/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: { 5 | index: './src/client/index.tsx' 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.(j|t)sx?$/, 11 | use: { 12 | loader: 'babel-loader', 13 | options: { 14 | // 缓存上次编译结果,避免每次重新编译,减少打包时间 15 | cacheDirectory: true 16 | } 17 | }, 18 | exclude: /node_modules/ 19 | }, 20 | { 21 | test: /\.png$/, 22 | use: 'url-loader', 23 | exclude: /node_modules/ 24 | } 25 | ] 26 | }, 27 | resolve: { 28 | extensions: ['.tsx', '.js', '.ts'] 29 | }, 30 | externals: { 31 | lodash: '_', 32 | react: 'React', 33 | bizcharts: 'BizCharts', 34 | '@antv/data-set': 'DataSet', 35 | 'react-dom': 'ReactDOM' 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /build/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | const baseConfig = require('./webpack.base.config'); 6 | 7 | const devConfig = { 8 | mode: 'development', 9 | output: { 10 | path: path.resolve('./dist/client'), 11 | filename: '[name].js', 12 | }, 13 | devServer: { 14 | hot: true, 15 | // 代理服务器端域名 16 | proxy: { 17 | '/': 'http://localhost:8082', 18 | }, 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.less$/, 24 | use: [ 25 | 'style-loader', 26 | 'css-loader', 27 | 'postcss-loader', 28 | { 29 | loader: 'less-loader', 30 | options: { 31 | lessOptions:{ 32 | javascriptEnabled: true, 33 | } 34 | }, 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | plugins: [ 41 | new HtmlWebpackPlugin({ 42 | template: './build/template/index.ejs', 43 | favicon: './build/template/favicon.ico', 44 | env: process.env.NODE_ENV, 45 | }), 46 | new webpack.HotModuleReplacementPlugin(), 47 | ] 48 | }; 49 | 50 | module.exports = merge(baseConfig, devConfig); 51 | -------------------------------------------------------------------------------- /build/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const WorkboxPlugin = require('workbox-webpack-plugin') 5 | const { merge } = require('webpack-merge'); 6 | const QiniuUploadPlugin = require('qiniu-upload-plugin'); 7 | const qiniuConfig = require('./qiniu.config'); 8 | const baseConfig = require('./webpack.base.config'); 9 | 10 | const prodConfig = { 11 | mode: 'production', 12 | output: { 13 | publicPath: qiniuConfig.publicPath, 14 | path: path.resolve('./dist/client'), 15 | filename: 'cdfang-spider-[name]-[contenthash].js' 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.less$/, 21 | use: [ 22 | MiniCssExtractPlugin.loader, 23 | 'css-loader', 24 | 'postcss-loader', 25 | { 26 | loader: 'less-loader', 27 | options: { 28 | lessOptions:{ 29 | javascriptEnabled: true 30 | } 31 | } 32 | } 33 | ] 34 | } 35 | ] 36 | }, 37 | plugins: [ 38 | new MiniCssExtractPlugin({ 39 | filename: 'cdfang-spider-[name].[contenthash].css' 40 | }), 41 | new HtmlWebpackPlugin({ 42 | template: './build/template/index.ejs', 43 | favicon: './build/template/favicon.ico', 44 | env: process.env.NODE_ENV 45 | }), 46 | // 公益 404 47 | new HtmlWebpackPlugin({ 48 | filename: '404.html', 49 | template: './build/template/404.ejs', 50 | favicon: './build/template/favicon.ico', 51 | inject: false 52 | }), 53 | // pwa 支持 54 | new WorkboxPlugin.GenerateSW({ 55 | clientsClaim: true, // 让浏览器立即 servece worker 被接管 56 | skipWaiting: true, // 更新 sw 文件后,立即插队到最前面 57 | include: [/\.js$/, /\.css$/, /\.ico$/], 58 | }), 59 | ], 60 | optimization: { 61 | runtimeChunk: { 62 | name: 'runtime' 63 | }, 64 | splitChunks: { 65 | chunks: 'all', 66 | cacheGroups: { 67 | vendors: { 68 | test: /[\\/]node_modules[\\/]/, 69 | name: 'vendors' 70 | } 71 | } 72 | } 73 | }, 74 | externals: { 75 | 'react-dom': 'ReactDOM', 76 | } 77 | }; 78 | 79 | // ci 环境不上传 cdn 80 | if (process.env.BUILD_ENV !== 'ci' && process.env.BUILD_ENV !== 'analysis') { 81 | prodConfig.plugins.push(new QiniuUploadPlugin(qiniuConfig)); 82 | } 83 | 84 | module.exports = merge(baseConfig, prodConfig); 85 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # 小体积的 node 镜像 2 | FROM mhart/alpine-node 3 | 4 | LABEL maintainer = "mengsixing " 5 | 6 | RUN rm -rf /app 7 | RUN mkdir /app 8 | 9 | WORKDIR /app 10 | 11 | COPY . /app 12 | 13 | RUN npm install 14 | RUN npm run build 15 | RUN mv ./dist/* ./ 16 | 17 | EXPOSE 8082 18 | 19 | CMD BUILD_ENV=docker node app.js 20 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | database: 5 | image: mongo 6 | restart: always 7 | volumes: 8 | - ~/data/db:/data/db 9 | networks: 10 | - webapp-network 11 | 12 | web: 13 | image: yhlben/cdfang-spider 14 | restart: always 15 | depends_on: 16 | - database 17 | ports: 18 | - 8082:8082 19 | networks: 20 | - webapp-network 21 | 22 | networks: 23 | webapp-network: 24 | driver: bridge 25 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const babel = require('gulp-babel'); 3 | 4 | gulp.task('build:graphql', () => 5 | gulp.src('./src/nodeuii/**/*.graphql').pipe(gulp.dest('./dist/')) 6 | ); 7 | 8 | gulp.task('build:ts', () => 9 | gulp 10 | .src('./src/nodeuii/**/*.ts') 11 | .pipe( 12 | // 使用 .babelrc 配置 13 | babel() 14 | ) 15 | .pipe(gulp.dest('./dist/')) 16 | ); 17 | 18 | // 定义 default 任务 19 | gulp.task("default", gulp.series("build:graphql", "build:ts")); 20 | 21 | if (process.env.NODE_ENV !== 'production') { 22 | gulp.watch('./src/nodeuii/**/*.ts', gulp.series('default')); 23 | } 24 | -------------------------------------------------------------------------------- /lerna-debug.log: -------------------------------------------------------------------------------- 1 | 29 error Error: Command failed with exit code 1: git add -- packages/qiniu-upload-plugin/package.json packages/qiniu-upload-plugin/package-lock.json lerna.json 2 | 29 error The following paths are ignored by one of your .gitignore files: 3 | 29 error packages/qiniu-upload-plugin/package-lock.json 4 | 29 error Use -f if you really want to add them. 5 | 29 error at makeError (/Users/mengsixing/.nvm/versions/node/v14.15.1/lib/node_modules/lerna/node_modules/execa/lib/error.js:59:11) 6 | 29 error at handlePromise (/Users/mengsixing/.nvm/versions/node/v14.15.1/lib/node_modules/lerna/node_modules/execa/index.js:114:26) 7 | 29 error at processTicksAndRejections (internal/process/task_queues.js:93:5) 8 | 29 error at async Promise.all (index 0) 9 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "2.12.3" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdfang-spider", 3 | "version": "2.12.1", 4 | "description": "成都房协网爬虫,定时爬取最新房源,可视化数据分析。", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "npm-run-all --parallel client:dev server:dev server:start", 8 | "server:start": "cross-env NODE_ENV=development supervisor -i ./dist/client/ -w ./dist/ ./dist/app.js", 9 | "server:dev": "cross-env NODE_ENV=development gulp", 10 | "server:prod": "cross-env NODE_ENV=production gulp", 11 | "client:dev": "cross-env NODE_ENV=development webpack serve", 12 | "client:prod": "cross-env NODE_ENV=production webpack", 13 | "client:profile": "cross-env NODE_ENV=production BUILD_ENV=analysis webpack", 14 | "prebuild": "npm run lint && rimraf ./dist", 15 | "build": "npm-run-all --parallel client:prod server:prod", 16 | "test:unit": "cd ./__tests__/unit;check_file=`ls | grep '.test.ts' | tr -s '\n'`;jest --findRelatedTests $check_file", 17 | "test:e2e": "cd ./__tests__/e2e;check_file=`ls | grep '.spec.ts' | tr -s '\n'`;jest --findRelatedTests $check_file", 18 | "test:service": "cd ./__tests__/service;check_file=`ls | grep '.spec.ts' | tr -s '\n'`;jest --detectOpenHandles --findRelatedTests $check_file", 19 | "test": "cross-env NODE_ENV=test jest --no-cache --colors --coverage --forceExit --detectOpenHandles", 20 | "lint": "eslint --fix --ext tsx,ts src __tests__", 21 | "prettier": "prettier --write --check ./**/*.{ts,tsx,less}", 22 | "commit": "npx git-cz" 23 | }, 24 | "husky": { 25 | "hooks": { 26 | "pre-commit": "npm run prettier", 27 | "pre-push": "eslint --fix --ext tsx,ts src __tests__" 28 | } 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/mengsixing/cdfang-spider.git" 33 | }, 34 | "author": "mengsixing", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/mengsixing/cdfang-spider/issues" 38 | }, 39 | "homepage": "https://github.com/mengsixing/cdfang-spider#readme", 40 | "dependencies": { 41 | "@ant-design/icons": "^4.3.0", 42 | "@antv/data-set": "^0.11.1", 43 | "antd": "^4.10.0", 44 | "apollo-boost": "^0.4.9", 45 | "apollo-server-koa": "^2.19.1", 46 | "autoprefixer": "^9.8.6", 47 | "bizcharts": "v3-latest", 48 | "cheerio": "^1.0.0-rc.5", 49 | "cssnano": "^4.1.10", 50 | "dayjs": "^1.10.2", 51 | "ejs": "^3.1.5", 52 | "graphql": "^15.4.0", 53 | "graphql-tag": "^2.11.0", 54 | "html-webpack-plugin": "^4.5.1", 55 | "koa": "^2.13.1", 56 | "koa-body": "^4.2.0", 57 | "koa-router": "^10.0.0", 58 | "koa-static": "^5.0.0", 59 | "lodash": "^4.17.20", 60 | "log4js": "^6.3.0", 61 | "mini-css-extract-plugin": "^1.3.3", 62 | "mongoose": "^5.11.11", 63 | "node-schedule": "^1.3.2", 64 | "qiniu-upload-plugin": "^1.2.9", 65 | "react": "^17.0.1", 66 | "react-dom": "^17.0.1", 67 | "react-router": "^5.2.0", 68 | "react-router-dom": "^5.2.0", 69 | "speed-measure-webpack-plugin": "^1.3.3", 70 | "superagent": "^6.1.0", 71 | "supertest": "^6.0.1", 72 | "webpack": "^5.12.1", 73 | "webpack-bundle-analyzer": "^4.3.0", 74 | "webpack-merge": "^5.7.3", 75 | "workbox-webpack-plugin": "^6.0.2" 76 | }, 77 | "devDependencies": { 78 | "@babel/core": "^7.12.10", 79 | "@babel/plugin-proposal-class-properties": "^7.12.1", 80 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 81 | "@babel/plugin-transform-runtime": "^7.12.10", 82 | "@babel/preset-env": "^7.12.11", 83 | "@babel/preset-react": "^7.12.10", 84 | "@babel/preset-typescript": "^7.12.7", 85 | "@babel/register": "^7.12.10", 86 | "@testing-library/jest-dom": "^5.11.8", 87 | "@testing-library/react": "^11.2.3", 88 | "@types/cheerio": "^0.22.23", 89 | "@types/jest": "^26.0.20", 90 | "@types/koa": "^2.11.6", 91 | "@types/koa-router": "^7.4.1", 92 | "@types/koa-static": "^4.0.1", 93 | "@types/lodash": "^4.14.167", 94 | "@types/mongoose": "^5.10.3", 95 | "@types/node": "^14.14.20", 96 | "@types/node-schedule": "^1.3.1", 97 | "@types/react": "^17.0.0", 98 | "@types/react-dom": "^17.0.0", 99 | "@types/react-router-dom": "^5.1.7", 100 | "@types/superagent": "^4.1.10", 101 | "@types/supertest": "^2.0.10", 102 | "@typescript-eslint/eslint-plugin": "^4.12.0", 103 | "@typescript-eslint/parser": "^4.12.0", 104 | "babel-core": "^7.0.0-bridge.0", 105 | "babel-eslint": "^10.1.0", 106 | "babel-jest": "^26.6.3", 107 | "babel-loader": "^8.2.2", 108 | "babel-plugin-import": "^1.13.3", 109 | "codecov": "^3.8.1", 110 | "cross-env": "^7.0.3", 111 | "css-loader": "^5.0.1", 112 | "eslint": "^7.17.0", 113 | "eslint-config-airbnb": "^18.2.1", 114 | "eslint-config-prettier": "^7.1.0", 115 | "eslint-import-resolver-webpack": "^0.13.0", 116 | "eslint-plugin-import": "^2.22.1", 117 | "eslint-plugin-jsx-a11y": "^6.4.1", 118 | "eslint-plugin-prettier": "^3.3.1", 119 | "eslint-plugin-react": "^7.22.0", 120 | "eslint-plugin-react-hooks": "^4.2.0", 121 | "gulp": "^4.0.2", 122 | "gulp-babel": "^8.0.0", 123 | "husky": "^4.3.7", 124 | "identity-obj-proxy": "^3.0.0", 125 | "jest": "^26.6.3", 126 | "jest-canvas-mock": "^2.3.0", 127 | "lerna": "^4.0.0", 128 | "less": "^4.0.0", 129 | "less-loader": "^7.2.1", 130 | "npm-run-all": "^4.1.5", 131 | "postcss-loader": "^4.1.0", 132 | "prettier": "^2.2.1", 133 | "rimraf": "^3.0.2", 134 | "style-loader": "^2.0.0", 135 | "supervisor": "^0.12.0", 136 | "typescript": "^4.1.3", 137 | "unfetch": "^4.2.0", 138 | "url-loader": "^4.1.1", 139 | "webpack-cli": "^4.3.1", 140 | "webpack-dev-server": "^3.11.1" 141 | }, 142 | "jest": { 143 | "testMatch": [ 144 | "**/__tests__/**/*.(test|spec).ts?(x)" 145 | ], 146 | "setupFiles": [ 147 | "./__tests__/setupTests.ts", 148 | "jest-canvas-mock" 149 | ], 150 | "transform": { 151 | "\\.[jt]sx?$": "babel-jest" 152 | }, 153 | "moduleFileExtensions": [ 154 | "js", 155 | "ts", 156 | "tsx", 157 | "json" 158 | ], 159 | "collectCoverage": false, 160 | "collectCoverageFrom": [ 161 | "src/client/components/**/*.{ts,tsx}" 162 | ], 163 | "moduleNameMapper": { 164 | "^.+\\.(css|less|sass|scss)$": "identity-obj-proxy" 165 | } 166 | }, 167 | "prettier": { 168 | "endOfLine": "lf", 169 | "singleQuote": true 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://editorconfig.org/#example-file 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [Makefile] 7 | indent_style = tab 8 | 9 | [*.{js,md,jsx,es6,es}] 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 2 -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/.npmignore: -------------------------------------------------------------------------------- 1 | __mocks__ 2 | __tests__ 3 | coverage 4 | .editorconfig 5 | .DS_Store 6 | appveyor.yml 7 | .travis.yml -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | script: 5 | - npm run test 6 | after_script: 7 | - npm run coverage 8 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 yhlben 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 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/README.md: -------------------------------------------------------------------------------- 1 | # qiniu-upload-plugin 2 | 3 | [![npm 版本](https://img.shields.io/npm/v/qiniu-upload-plugin.svg)](https://www.npmjs.com/package/qiniu-upload-plugin) 4 | [![下载量](https://img.shields.io/npm/dm/qiniu-upload-plugin.svg)](http://npm-stat.com/charts.html?package=qiniu-upload-plugin) 5 | [![Build Status](https://travis-ci.com/yhlben/qiniu-upload-plugin.svg?branch=master)](https://travis-ci.com/yhlben/qiniu-upload-plugin) 6 | [![Build status](https://ci.appveyor.com/api/projects/status/i72dafha6ht7bcnk/branch/master?svg=true)](https://ci.appveyor.com/project/yhlben/qiniu-upload-plugin/branch/master) 7 | [![Coverage Status](https://coveralls.io/repos/github/yhlben/qiniu-upload-plugin/badge.svg)](https://coveralls.io/github/yhlben/qiniu-upload-plugin) [![Greenkeeper badge](https://badges.greenkeeper.io/yhlben/qiniu-upload-plugin.svg)](https://greenkeeper.io/) 8 | [![David](https://img.shields.io/david/yhlben/qiniu-upload-plugin.svg)](https://david-dm.org/yhlben/qiniu-upload-plugin) 9 | 10 | > 将 webpack 打包出来的 assets 上传到七牛云。 11 | 12 | ## 特点 13 | 14 | - 上传 webpack 打包后的所有静态资源到七牛云。 15 | - 自动忽略`.html`文件。 16 | - 支持覆盖已上传文件。 17 | 18 | 基于官方七牛云[Node.js SDK](https://developer.qiniu.com/kodo/sdk/1289/nodejs)。 19 | 20 | ## 安装 21 | 22 | ```js 23 | npm install qiniu-upload-plugin --save-dev 24 | ``` 25 | 26 | ## 使用方法 27 | 28 | ```js 29 | const QiniuUploadPlugin = require('./QiniuUploadPlugin'); 30 | 31 | plugins: [ 32 | new QiniuUploadPlugin({ 33 | publicPath: 'http://cdn.xxx.com', // 七牛云域名,自动替换 publicPath 34 | accessKey: 'your qiniu accessKey', // 个人中心,秘钥管理,AK 35 | secretKey: 'your qiniu secretKey', // 个人中心,秘钥管理,SK 36 | bucket: 'your qiniu bucket', // 存储空间名称 37 | zone: 'Zone_z2', // 存储地区 38 | // 可选参数: 39 | cover: false // 慎用!默认为 false,设置为 true 会覆盖掉已经保存在七牛云上的同名文件。 40 | }) 41 | ]; 42 | ``` 43 | 44 | ## 效果截图 45 | 46 | ![上传七牛云](https://raw.githubusercontent.com/yhlben/qiniu-upload-plugin/master/screenshots/qiniu-upload.png) 47 | 48 | ## 示例项目 49 | 50 | - 成都房协网数据分析。[cdfang-spider](https://github.com/yhlben/cdfang-spider) 51 | - 客户端记事本。[notepad](https://github.com/yhlben/notepad) 52 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/__mocks__/qiniu.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | auth: { 3 | digest: { 4 | Mac: jest.fn() 5 | } 6 | }, 7 | rs: { 8 | PutPolicy: jest.fn(() => ({ 9 | uploadToken: () => 'tokenxxx' 10 | })) 11 | }, 12 | conf: { 13 | Config: jest.fn() 14 | }, 15 | zone: { 16 | Zone_z2: '' 17 | }, 18 | form_up: { 19 | FormUploader: jest.fn(), 20 | PutExtra: jest.fn(() => 'mockExtra'), 21 | putFile: jest.fn((uploadToken, key, localFile, putExtra, cb) => { 22 | process.nextTick(cb()); 23 | }) 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/__tests__/constructor.js: -------------------------------------------------------------------------------- 1 | const QiniuUploadPlugin = require('../lib/qiniuUploadPlugin'); 2 | 3 | describe('QiniuUploadPlugin', () => { 4 | const mock = { 5 | publicPath: 'http://cdn.xxx.com', 6 | accessKey: 'usrU1J2-BTCqaODu', 7 | secretKey: 'Ff0Ggl7l8XdVL1uHVl6Mge', 8 | bucket: 'xxx', 9 | zone: 'Zone_z2' 10 | }; 11 | describe('constructor', () => { 12 | it('不传参数是否提示报错', () => { 13 | expect(() => { 14 | new QiniuUploadPlugin(); 15 | }).toThrow(); 16 | }); 17 | 18 | it('调用Config方法', () => { 19 | const qiniu = require('qiniu'); 20 | new QiniuUploadPlugin(mock); 21 | expect(qiniu.conf.Config).toHaveBeenCalledTimes(1); 22 | expect(qiniu.form_up.FormUploader).toHaveBeenCalledTimes(1); 23 | }); 24 | }); 25 | 26 | it('webpack生命周期是否执行', () => { 27 | const plugin = new QiniuUploadPlugin(mock); 28 | const compiler = { 29 | hooks: { 30 | compilation: { 31 | tap: jest.fn() 32 | }, 33 | done: { 34 | tapAsync: jest.fn() 35 | } 36 | } 37 | }; 38 | plugin.apply(compiler); // webpack will do this 39 | expect(compiler.hooks.compilation.tap).toBeCalled(); 40 | expect(compiler.hooks.done.tapAsync).toBeCalled(); 41 | expect(compiler.hooks.compilation.tap.mock.calls[0][0]).toEqual( 42 | 'QiniuUploadPlugin' 43 | ); 44 | expect(compiler.hooks.done.tapAsync.mock.calls[0][0]).toEqual( 45 | 'QiniuUploadPlugin' 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/__tests__/handler.js: -------------------------------------------------------------------------------- 1 | const QiniuUploadPlugin = require('../lib/qiniuUploadPlugin'); 2 | 3 | // 初始化传参 4 | const mock = { 5 | publicPath: 'http://cdn.xxx.com', 6 | accessKey: 'usrU1J2-BTCqaODu', 7 | secretKey: 'Ff0Ggl7l8XdVL1uHVl6Mge', 8 | bucket: 'xxx', 9 | zone: 'Zone_z2' 10 | }; 11 | 12 | describe('handler', () => { 13 | describe('设置publicPath', () => { 14 | it('publish是否设置成功', done => { 15 | const plugin = new QiniuUploadPlugin(mock); 16 | let handler = null; 17 | const compiler = { 18 | hooks: { 19 | compilation: { 20 | tap: (event, cb) => { 21 | // 获取tap方法的参数 22 | handler = cb; 23 | } 24 | }, 25 | done: { 26 | tapAsync: jest.fn() 27 | } 28 | } 29 | }; 30 | // 模拟webpack编译 31 | plugin.apply(compiler); 32 | handler({ 33 | outputOptions: { 34 | path: 'test.com' 35 | } 36 | }); 37 | done(); 38 | expect(plugin.absolutePath).toBe('test.com'); 39 | }); 40 | }); 41 | 42 | describe('上传接口调用', () => { 43 | // 模拟2个资源,上传2次 44 | const tapAsyncDataMock = { 45 | compilation: { 46 | assets: ['a.js', 'b.js'], 47 | outputOptions: { 48 | publicPath: 'xxx', 49 | path: 'xxx' 50 | } 51 | } 52 | }; 53 | 54 | it('模拟上传失败', done => { 55 | var qiniu = require('qiniu'); 56 | qiniu.form_up.FormUploader = function() { 57 | return { 58 | putFile: jest.fn((uploadToken, key, localFile, putExtra, cb) => { 59 | process.nextTick( 60 | cb(null, null, { 61 | statusCode: 500 62 | }) 63 | ); 64 | }) 65 | }; 66 | }; 67 | const plugin = new QiniuUploadPlugin(mock); 68 | let handler = null; 69 | const compiler = { 70 | hooks: { 71 | compilation: { 72 | tap: (event, cb) => {} 73 | }, 74 | done: { 75 | tapAsync: (event, cb) => { 76 | handler = cb; 77 | } 78 | } 79 | } 80 | }; 81 | plugin.apply(compiler); 82 | handler(tapAsyncDataMock, done); 83 | expect( 84 | plugin.qiniuAuthenticationConfig.formUploader.putFile 85 | ).toHaveBeenCalledTimes(2); 86 | }); 87 | 88 | it('模拟上传成功', done => { 89 | var qiniu = require('qiniu'); 90 | qiniu.form_up.FormUploader = function() { 91 | return { 92 | putFile: jest.fn((uploadToken, key, localFile, putExtra, cb) => { 93 | process.nextTick( 94 | cb(null, null, { 95 | statusCode: 200 96 | }) 97 | ); 98 | }) 99 | }; 100 | }; 101 | const plugin = new QiniuUploadPlugin(mock); 102 | let handler = null; 103 | const compiler = { 104 | hooks: { 105 | compilation: { 106 | tap: (event, cb) => {} 107 | }, 108 | done: { 109 | tapAsync: (event, cb) => { 110 | handler = cb; 111 | } 112 | } 113 | } 114 | }; 115 | plugin.apply(compiler); 116 | handler(tapAsyncDataMock, done); 117 | expect( 118 | plugin.qiniuAuthenticationConfig.formUploader.putFile 119 | ).toHaveBeenCalledTimes(2); 120 | }); 121 | 122 | it('模拟覆盖上传', done => { 123 | var qiniu = require('qiniu'); 124 | qiniu.form_up.FormUploader = function() { 125 | var uploadTimes = 0; 126 | return { 127 | putFile: jest.fn((uploadToken, key, localFile, putExtra, cb) => { 128 | var code = 614; 129 | uploadTimes++; 130 | if (uploadTimes >= 1) { 131 | code = 200; 132 | } 133 | process.nextTick( 134 | cb(null, null, { 135 | statusCode: code 136 | }) 137 | ); 138 | }) 139 | }; 140 | }; 141 | mock.cover = true; 142 | const plugin = new QiniuUploadPlugin(mock); 143 | let handler = null; 144 | const compiler = { 145 | hooks: { 146 | compilation: { 147 | tap: (event, cb) => {} 148 | }, 149 | done: { 150 | tapAsync: (event, cb) => { 151 | handler = cb; 152 | } 153 | } 154 | } 155 | }; 156 | plugin.apply(compiler); 157 | handler(tapAsyncDataMock, done); 158 | expect( 159 | plugin.qiniuAuthenticationConfig.formUploader.putFile 160 | ).toHaveBeenCalledTimes(2);// 第一次,上传返回614,第二次重传 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '10' 4 | install: 5 | - ps: Install-Product node $env:nodejs_version 6 | - set CI=true 7 | - npm -g install npm@latest 8 | - set PATH=%APPDATA%\npm;%PATH% 9 | - npm install 10 | matrix: 11 | fast_finish: true 12 | build: off 13 | version: '{build}' 14 | shallow_clone: true 15 | clone_depth: 1 16 | test_script: 17 | - npm run test 18 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/qiniuUploadPlugin.js'); 2 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/lib/qiniuUploadPlugin.js: -------------------------------------------------------------------------------- 1 | const qiniu = require('qiniu'); 2 | const path = require('path'); 3 | const ora = require('ora'); 4 | 5 | // 上传文件到七牛云 6 | class QiniuUploadPlugin { 7 | constructor(qiniuConfig) { 8 | if ( 9 | !qiniuConfig || 10 | !qiniuConfig.publicPath || 11 | !qiniuConfig.accessKey || 12 | !qiniuConfig.secretKey || 13 | !qiniuConfig.bucket || 14 | !qiniuConfig.zone 15 | ) { 16 | throw new Error('参数没有传递完全!'); 17 | } 18 | // 保存用户传参 19 | this.qiniuConfig = qiniuConfig; 20 | // 创建的七牛认证信息 21 | this.qiniuAuthenticationConfig = {}; 22 | // 鉴权 23 | this.qiniuAuthenticationConfig.mac = new qiniu.auth.digest.Mac( 24 | qiniuConfig.accessKey, 25 | qiniuConfig.secretKey 26 | ); 27 | // 设置存储空间名称 28 | const options = { 29 | scope: qiniuConfig.bucket, 30 | }; 31 | // 创建上传token 32 | const putPolicy = new qiniu.rs.PutPolicy(options); 33 | this.qiniuAuthenticationConfig.uploadToken = putPolicy.uploadToken( 34 | this.qiniuAuthenticationConfig.mac 35 | ); 36 | let config = new qiniu.conf.Config(); 37 | // 存储空间对应的机房 38 | config.zone = qiniu.zone[qiniuConfig.zone]; 39 | this.qiniuAuthenticationConfig.formUploader = new qiniu.form_up.FormUploader( 40 | config 41 | ); 42 | } 43 | apply(compiler) { 44 | compiler.hooks.compilation.tap('QiniuUploadPlugin', (compilation) => { 45 | compilation.outputOptions.publicPath = this.qiniuConfig.publicPath; 46 | this.absolutePath = compilation.outputOptions.path; 47 | }); 48 | 49 | compiler.hooks.done.tapAsync('QiniuUploadPlugin', (data, callback) => { 50 | // 先返回构建结果,然后异步上传 51 | callback(); 52 | let assetsPromise = []; 53 | const spinner = ora('开始上传七牛云...').start(); 54 | Object.keys(data.compilation.assets).forEach((file) => { 55 | // 上传非html文件 56 | if (!/.html$/.test(file)) { 57 | assetsPromise.push(this.uploadFile(file)); 58 | } 59 | }); 60 | Promise.all(assetsPromise) 61 | .then((res) => { 62 | spinner.succeed('七牛云上传完毕!'); 63 | }) 64 | .catch((err) => { 65 | console.log(err); 66 | }); 67 | }); 68 | } 69 | uploadFile(filename, coverUploadToken) { 70 | const key = filename; 71 | const localFile = path.join(this.absolutePath || '', filename); 72 | return new Promise((resolve, reject) => { 73 | // 文件上传 74 | const spinner = ora(`上传文件${key}...`).start(); 75 | const uploadToken = coverUploadToken 76 | ? coverUploadToken 77 | : this.qiniuAuthenticationConfig.uploadToken; 78 | const putExtra = new qiniu.form_up.PutExtra(); 79 | this.qiniuAuthenticationConfig.formUploader.putFile( 80 | uploadToken, 81 | key, 82 | localFile, 83 | putExtra, 84 | (respErr, respBody, respInfo) => { 85 | if (respErr) { 86 | throw respErr; 87 | } 88 | if (respInfo.statusCode == 200) { 89 | resolve(respInfo); 90 | spinner.succeed(`文件:${key},上传成功!`); 91 | } else { 92 | if ( 93 | this.qiniuConfig.cover && 94 | (respInfo.status === 614 || respInfo.statusCode === 614) 95 | ) { 96 | spinner.fail(`文件:${key},已存在,尝试覆盖上传!`); 97 | resolve( 98 | this.uploadFile(filename, this.coverUploadFile(filename)) 99 | ); 100 | } else { 101 | spinner.fail(`文件:${key},上传失败!`); 102 | reject(respInfo); 103 | } 104 | } 105 | } 106 | ); 107 | }); 108 | } 109 | coverUploadFile(filename) { 110 | var options = { 111 | scope: this.qiniuConfig.bucket + ':' + filename, 112 | }; 113 | var putPolicy = new qiniu.rs.PutPolicy(options); 114 | return putPolicy.uploadToken(this.qiniuAuthenticationConfig.mac); 115 | } 116 | } 117 | 118 | module.exports = QiniuUploadPlugin; 119 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qiniu-upload-plugin", 3 | "version": "2.12.3", 4 | "description": "上传 Webpack 打包出来的 assets 到 七牛云。", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "coverage": "cat coverage/lcov.info | coveralls" 9 | }, 10 | "author": "yhlben", 11 | "license": "MIT", 12 | "dependencies": { 13 | "ora": "^3.4.0", 14 | "qiniu": "^7.2.2" 15 | }, 16 | "devDependencies": { 17 | "coveralls": "^3.0.4", 18 | "jest": "^24.8.0" 19 | }, 20 | "keywords": [ 21 | "qiniu", 22 | "webpack", 23 | "plugin" 24 | ], 25 | "jest": { 26 | "collectCoverage": true, 27 | "collectCoverageFrom": [ 28 | "lib/**/*" 29 | ] 30 | }, 31 | "homepage": "https://github.com/yhlben/qiniu-upload-plugin#readme", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/yhlben/qiniu-upload-plugin.git" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/yhlben/qiniu-upload-plugin/issues" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/qiniu-upload-plugin/screenshots/qiniu-upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengsixing/cdfang-spider/8c768cdcfe496f6b805202fbb3851d00899bdd37/packages/qiniu-upload-plugin/screenshots/qiniu-upload.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | const cssnano = require('cssnano'); 3 | 4 | module.exports = { 5 | plugins: [autoprefixer, cssnano], 6 | }; 7 | -------------------------------------------------------------------------------- /src/client/components/BasicAreaGraph/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Chart, Geom, Axis, Tooltip, Legend } from 'bizcharts'; 3 | import DataSet from '@antv/data-set'; 4 | import * as constants from '../../constants'; 5 | import { RenderLoadingComponent } from '../HOC/RenderLoadingComponent'; 6 | 7 | interface IbasicAreaGraphData { 8 | month: string; 9 | [constants.HOUSE_NUMBER]?: number; 10 | [constants.BUILDER_NUMBER]?: number; 11 | } 12 | 13 | export interface Iprops { 14 | title: string; 15 | data: IbasicAreaGraphData[]; 16 | fields?: string[]; 17 | } 18 | 19 | // 基础面积图 https://bizcharts.net/products/bizCharts/demo/detail?id=area-basic&selectedKey=%E9%9D%A2%E7%A7%AF%E5%9B%BE 20 | const BasicAreaGraph: React.FunctionComponent = ({ 21 | data, 22 | title, 23 | fields, 24 | }) => { 25 | const dv = new DataSet.View().source(data); 26 | dv.transform({ 27 | type: 'fold', 28 | fields: fields || [title], 29 | key: 'type', 30 | value: 'value', 31 | }); 32 | const scale = { 33 | value: { 34 | alias: '数量', 35 | }, 36 | month: { 37 | range: [0.01, 0.99], 38 | tickCount: 9, 39 | }, 40 | }; 41 | return ( 42 | 43 |
{`${title} / 月(统计图)`}
44 | 45 | 46 | 47 | 48 | 49 |
50 | ); 51 | }; 52 | 53 | export default RenderLoadingComponent(BasicAreaGraph); 54 | -------------------------------------------------------------------------------- /src/client/components/BasicColumnGraph/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import React from 'react'; 3 | import { Chart, Geom, Axis, Tooltip } from 'bizcharts'; 4 | import RenderNoEmptyComponent from '../HOC/RenderNoEmptyComponent'; 5 | import { RenderLoadingComponent } from '../HOC/RenderLoadingComponent'; 6 | 7 | interface Iarea { 8 | 区域: string; 9 | 房源?: number; 10 | 楼盘数?: number; 11 | [yAxis: string]: any; 12 | } 13 | 14 | export interface Iprops { 15 | data: Iarea[]; 16 | title: string; 17 | xAxis: string; 18 | yAxis: string; 19 | desc?: boolean; 20 | } 21 | 22 | const BasicColumnGraph: React.FunctionComponent = ({ 23 | data, 24 | title, 25 | xAxis, 26 | yAxis, 27 | desc, 28 | }) => { 29 | let chartData: Iarea[] = []; 30 | if (desc) { 31 | chartData = data.sort((a, b): any => b[yAxis] - a[yAxis]); 32 | } 33 | return ( 34 | 35 |
{title}
36 | 37 | 38 | 39 | 40 |
41 | ); 42 | }; 43 | 44 | const BasicColumnGraphMemo = React.memo( 45 | RenderNoEmptyComponent(RenderLoadingComponent(BasicColumnGraph), ['data']), 46 | (): boolean => false 47 | ); 48 | 49 | export default BasicColumnGraphMemo; 50 | -------------------------------------------------------------------------------- /src/client/components/ChartPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Col, Row } from 'antd'; 3 | import dayjs from 'dayjs'; 4 | import _ from 'lodash'; 5 | 6 | import { AppContext } from '../../context/appContext'; 7 | import CricleGraph from '../CricleGraph'; 8 | import Rank from '../Rank'; 9 | import DoubleAxisGraph from '../DoubleAxisGraph'; 10 | import { RenderLoadingComponent } from '../HOC/RenderLoadingComponent'; 11 | 12 | const { useState, useContext } = React; 13 | let currentState: Istate; 14 | 15 | export interface Iprops { 16 | data: cdFang.IhouseData[]; 17 | panelKey: string; 18 | activityKey: string; 19 | } 20 | 21 | interface Istate { 22 | isChangeTab: boolean; 23 | isOpen: boolean; 24 | rank: cdFang.IhouseData[]; 25 | rankTitle: string; 26 | } 27 | 28 | const ChartPanel: React.FunctionComponent = (props) => { 29 | const appState = useContext(AppContext); 30 | const { panelKey, data } = props; 31 | const initState = { 32 | rank: data, 33 | rankTitle: '', 34 | isChangeTab: false, 35 | isOpen: false, 36 | }; 37 | 38 | if (panelKey !== appState.activityKey) { 39 | initState.isChangeTab = true; 40 | } 41 | 42 | // 只会执行一次 43 | const [state, setState] = useState(initState); 44 | const [prevData, setPrevData] = useState(); 45 | 46 | // 模拟 getDerivedStateFromProps https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops 47 | if (data !== prevData) { 48 | setState(initState); 49 | setPrevData(data); 50 | } 51 | 52 | function changeMonth(origin: cdFang.IcircleItem) { 53 | const { rankTitle, isOpen } = currentState; 54 | 55 | if (rankTitle === origin.item && isOpen) { 56 | return { 57 | ...currentState, 58 | rank: data, 59 | rankTitle: '', 60 | isChangeTab: false, 61 | isOpen: false, 62 | }; 63 | } 64 | const selectMonth = origin.date; 65 | const selectMonthTitle = origin.item; 66 | const newRank = _.filter( 67 | data, 68 | (dataItem) => 69 | dayjs(dataItem.beginTime) > dayjs(selectMonth) && 70 | dayjs(dataItem.beginTime) < dayjs(selectMonth).endOf('month') 71 | ); 72 | return { 73 | ...currentState, 74 | rank: newRank, 75 | rankTitle: selectMonthTitle, 76 | isChangeTab: false, 77 | isOpen: true, 78 | }; 79 | } 80 | 81 | const { isChangeTab, rank, rankTitle } = state; 82 | 83 | currentState = state; 84 | 85 | return ( 86 | 87 | 88 | 89 | 90 | 91 | { 94 | // 由于 circle 组件使用 React.memo 在不渲染时,不能获取到最新的属性,这里使用局部变量来获取 95 | setState(changeMonth(item)); 96 | }} 97 | isChangeTab={isChangeTab} 98 | /> 99 | 100 | 101 | {rank ? : ''} 102 | 103 | 104 | ); 105 | }; 106 | 107 | export default RenderLoadingComponent(ChartPanel); 108 | -------------------------------------------------------------------------------- /src/client/components/CricleGraph/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'lodash'; 3 | import dayjs from 'dayjs'; 4 | import { Chart, Geom, Axis, Tooltip, Coord, Label, Guide } from 'bizcharts'; 5 | import DataSet from '@antv/data-set'; 6 | 7 | const { DataView } = DataSet; 8 | const { Html } = Guide; 9 | 10 | export interface Iprops { 11 | isChangeTab: boolean; 12 | data: cdFang.IhouseData[]; 13 | changeMonth(origin: cdFang.IcircleItem): void; 14 | } 15 | 16 | interface IcircleData { 17 | item: string; 18 | number: number; 19 | date: string; 20 | } 21 | 22 | const CircleGraph: React.FunctionComponent = ({ 23 | data: array, 24 | changeMonth, 25 | }) => { 26 | const selectMonth = (circleObject: { 27 | data: { _origin: cdFang.IcircleItem }; 28 | }) => { 29 | // eslint-disable-next-line no-underscore-dangle 30 | changeMonth(circleObject.data._origin); 31 | }; 32 | const arrayByMonth = _.groupBy(array, (item) => 33 | dayjs(item.beginTime).startOf('month').format('YYYY-MM') 34 | ); 35 | let cricleArray: IcircleData[] = []; 36 | Object.keys(arrayByMonth).forEach((key) => { 37 | const houseNumber = _.sumBy(arrayByMonth[key], 'number'); 38 | cricleArray.push({ 39 | item: dayjs(key).format('M月'), 40 | number: houseNumber, 41 | date: key, 42 | }); 43 | }); 44 | // 按日期排序 45 | cricleArray = _.sortBy(cricleArray, 'date'); 46 | const dv = new DataView(); 47 | dv.source(cricleArray).transform({ 48 | type: 'percent', 49 | field: 'number', 50 | dimension: 'item', 51 | as: 'percent', 52 | }); 53 | const scales = { 54 | percent: { 55 | formatter: (val: number) => `${(val * 100).toFixed(2)}%`, 56 | }, 57 | }; 58 | const houseNumber = _.sumBy(array, 'number'); 59 | const guideHtml = `
总计
${houseNumber}
`; 60 | return ( 61 | 68 |
房源分部图
69 | 70 | 71 | 75 | 76 | 82 | 83 | ({ 96 | name: item, 97 | value: `${(percent * 100).toFixed(2)}%`, 98 | }), 99 | ]} 100 | style={{ lineWidth: 1, stroke: '#fff' }} 101 | > 102 | 107 |
108 | ); 109 | }; 110 | 111 | function shouldComponentUpdate(prevProps: Iprops, nextProps: Iprops) { 112 | if (nextProps.data.length !== prevProps.data.length) { 113 | return false; 114 | } 115 | if (nextProps.isChangeTab) { 116 | return false; 117 | } 118 | return true; 119 | } 120 | 121 | export default React.memo(CircleGraph, shouldComponentUpdate); 122 | -------------------------------------------------------------------------------- /src/client/components/CurrentHouse/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Collapse, List, Col, Row } from 'antd'; 3 | import { NotificationOutlined } from '@ant-design/icons'; 4 | import { HOUSE_PURCHASE_REGISTRATION } from '../../constants'; 5 | import { AppContext } from '../../context/appContext'; 6 | import { RenderLoadingComponent } from '../HOC/RenderLoadingComponent'; 7 | import HouseDetail from '../HouseDetail'; 8 | import './styles.less'; 9 | 10 | const CurrentHouse: React.FunctionComponent = () => { 11 | const { allData } = useContext(AppContext); 12 | const currentHouses = allData 13 | .filter((item) => item.status !== '报名结束') 14 | .map((item) => ( 15 | // eslint-disable-next-line no-underscore-dangle 16 |
17 | 18 | 19 | 20 | 21 | 22 | {item.area} 23 | 24 | 25 | 26 | 27 | {`${item.number}套`} 28 | 29 | {`登记截止时间:${item.endTime} `} 30 | {item.status === '正在报名' ? ( 31 | 37 | 正在登记{' '} 38 | 39 | 🔥 40 | 41 | 42 | ) : ( 43 | 44 | {item.status === '未报名' ? '即将报名' : item.status}{' '} 45 | 46 | ⌛ 47 | 48 | 49 | )} 50 | 51 | 52 |
53 | )); 54 | 55 | const result = 56 | currentHouses.length > 0 ? ( 57 | 58 | } 62 | > 63 | {item}} 67 | /> 68 | 69 | 70 | ) : ( 71 | 72 | ); 73 | 74 | return result; 75 | }; 76 | 77 | export default RenderLoadingComponent(CurrentHouse, '50px'); 78 | -------------------------------------------------------------------------------- /src/client/components/CurrentHouse/styles.less: -------------------------------------------------------------------------------- 1 | @import '../../theme/const.less'; 2 | 3 | .current-house-list { 4 | width: 80%; 5 | .current-house-list-notification-icon { 6 | display: inline-block; 7 | margin-right: 10px; 8 | transform: rotateY(180deg); 9 | color: @title-color; 10 | } 11 | .current-house-list-register-link { 12 | margin-left: 20px; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/client/components/DoubleAxisGraph/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Chart, Geom, Axis, Tooltip } from 'bizcharts'; 3 | import dayjs from 'dayjs'; 4 | import _ from 'lodash'; 5 | import { HOUSE_NUMBER, BUILDER_NUMBER, RISE_COLOR } from '../../constants'; 6 | 7 | // 导出给 test 文件 8 | export interface Iprops { 9 | data: cdFang.IhouseData[]; 10 | } 11 | 12 | const DoubleAxisGraph: React.FunctionComponent = ({ data }) => { 13 | const dataGroupByMonth = _.groupBy(data, (item) => 14 | dayjs(item.beginTime).format('M月') 15 | ); 16 | 17 | const chartData = Object.keys(dataGroupByMonth).map((key) => { 18 | const houseNumber = _.sumBy(dataGroupByMonth[key], (item) => item.number); 19 | const builderBumber = dataGroupByMonth[key].length; 20 | return { 21 | date: key, 22 | houseNumber, 23 | builderBumber, 24 | }; 25 | }); 26 | 27 | const scale = { 28 | builderBumber: { 29 | alias: BUILDER_NUMBER, 30 | }, 31 | houseNumber: { 32 | min: 0, 33 | alias: HOUSE_NUMBER, 34 | }, 35 | }; 36 | return ( 37 | 38 | 39 | 40 | 41 | 48 | 49 | ); 50 | }; 51 | 52 | export default DoubleAxisGraph; 53 | -------------------------------------------------------------------------------- /src/client/components/GroupedColumnGraph/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Chart, Geom, Axis, Tooltip } from 'bizcharts'; 3 | import DataSet from '@antv/data-set'; 4 | 5 | interface Ihouseprice { 6 | [K: string]: string; 7 | } 8 | 9 | export interface Iprops { 10 | data: Ihouseprice[]; 11 | title: string; 12 | xAxis: string; 13 | yAxis: string; 14 | } 15 | 16 | const GroupedColumnGraph: React.FC = ({ 17 | data, 18 | title, 19 | xAxis, 20 | yAxis, 21 | }) => { 22 | const fields = Object.keys(data[0]).slice(1); 23 | const ds = new DataSet(); 24 | const dv = ds.createView().source(data); 25 | dv.transform({ 26 | type: 'fold', 27 | fields, 28 | key: xAxis, 29 | value: yAxis, 30 | }); 31 | return ( 32 |
33 | 34 |
{title}
35 | 36 | 37 | 38 | 49 |
50 |
51 | ); 52 | }; 53 | 54 | export default GroupedColumnGraph; 55 | -------------------------------------------------------------------------------- /src/client/components/HOC/RenderLoadingComponent.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import React from 'react'; 3 | import Loading from '../Loading'; 4 | import { AppContext } from '../../context/appContext'; 5 | 6 | const { useContext } = React; 7 | 8 | export const RenderLoadingComponent = ( 9 | WrapperedComponent: React.FunctionComponent, 10 | LoadingHeight = '300px' 11 | ) => { 12 | function Temp(props: any) { 13 | const { isLoading } = useContext(AppContext); 14 | return isLoading ? ( 15 | 16 | ) : ( 17 | 18 | ); 19 | } 20 | return Temp; 21 | }; 22 | 23 | export function RenderLoadingJSX( 24 | WrapperedComponent: JSX.Element, 25 | isLoading: boolean 26 | ): JSX.Element { 27 | return isLoading ? : WrapperedComponent; 28 | } 29 | -------------------------------------------------------------------------------- /src/client/components/HOC/RenderNoEmptyComponent.tsx: -------------------------------------------------------------------------------- 1 | // 拒绝渲染属性为空的组件 2 | import React from 'react'; 3 | 4 | interface Iprops { 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | [x: string]: any; 7 | } 8 | 9 | function RenderNoEmptyComponent( 10 | WrapperedComponent: React.FunctionComponent, 11 | checkProps: string[] 12 | ): React.FunctionComponent { 13 | const newComponent: React.FunctionComponent = (props: Iprops) => { 14 | const hasEmpty = checkProps.some((propName) => { 15 | if (Array.isArray(props[propName])) { 16 | return props[propName].length === 0; 17 | } 18 | return !!props[propName]; 19 | }); 20 | return hasEmpty ? : ; 21 | }; 22 | return newComponent; 23 | } 24 | 25 | export default RenderNoEmptyComponent; 26 | -------------------------------------------------------------------------------- /src/client/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Menu } from 'antd'; 3 | import { HomeOutlined, CalendarOutlined } from '@ant-design/icons'; 4 | import { RouteComponentProps } from 'react-router'; 5 | import { withRouter } from 'react-router-dom'; 6 | import Notice from '../Notice'; 7 | import { tabKeyRouterMap, GITHUB_URL } from '../../constants'; 8 | import { requestPvs, requestData } from '../../utils/request'; 9 | import { AppContext } from '../../context/appContext'; 10 | import './styles.less'; 11 | 12 | const { useState, useEffect, useContext } = React; 13 | 14 | const Header: React.FunctionComponent = ({ 15 | history, 16 | location, 17 | }) => { 18 | const gotoGithub = () => { 19 | window.location.href = GITHUB_URL; 20 | }; 21 | const appState = useContext(AppContext); 22 | const [pvs, changePvs] = useState(0); 23 | 24 | const selectedYear = tabKeyRouterMap[location.pathname]; 25 | 26 | const requestDataWrapper = (year: string) => { 27 | appState.changeLoading(true); 28 | requestData(year, (allHouses: cdFang.IhouseData[]) => { 29 | appState.changeData(allHouses); 30 | appState.changeLoading(false); 31 | }); 32 | }; 33 | 34 | useEffect(() => { 35 | // 获取 pv 36 | requestPvs((pvNumber: number): void => { 37 | changePvs(pvNumber); 38 | }); 39 | 40 | // 获取房源信息 41 | requestDataWrapper(selectedYear); 42 | }, []); 43 | 44 | // 根据理由选中对应 menu 项 45 | const defaultYear = [selectedYear]; 46 | 47 | // 路由切换 48 | const clickMenu = ({ key }: { key: string }) => { 49 | if (key !== selectedYear) { 50 | history.push(tabKeyRouterMap[key]); 51 | requestDataWrapper(key); 52 | } 53 | }; 54 | 55 | return ( 56 |
57 |
58 | {`累计查询:${pvs}次`} 59 | 60 |