├── .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 | [](https://github.com/mengsixing/cdfang-spider/actions)
4 | [](https://codecov.io/gh/mengsixing/cdfang-spider)
5 | [](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 | [](https://www.npmjs.com/package/qiniu-upload-plugin)
4 | [](http://npm-stat.com/charts.html?package=qiniu-upload-plugin)
5 | [](https://travis-ci.com/yhlben/qiniu-upload-plugin)
6 | [](https://ci.appveyor.com/project/yhlben/qiniu-upload-plugin/branch/master)
7 | [](https://coveralls.io/github/yhlben/qiniu-upload-plugin) [](https://greenkeeper.io/)
8 | [](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 | 
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 |
68 |
69 |
97 |
98 | );
99 | };
100 |
101 | export default withRouter(Header);
102 |
--------------------------------------------------------------------------------
/src/client/components/Header/styles.less:
--------------------------------------------------------------------------------
1 | .cdfang-header {
2 | .cdfang-header-item {
3 | width: 300px;
4 | line-height: 31px;
5 | background: rgba(255, 255, 255, 0.2);
6 | margin: 16px 0 16px 0;
7 | float: right;
8 | text-align: right;
9 | font-size: 20px;
10 | display: flex;
11 | align-items: center;
12 | justify-content: space-around;
13 | * {
14 | cursor: pointer;
15 | }
16 | > .cdfang-header-item-pv {
17 | cursor: default;
18 | font-size: 14px;
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/client/components/HouseDetail/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Popover } from 'antd';
3 | import { InfoCircleOutlined } from '@ant-design/icons';
4 | import { AppContext } from '../../context/appContext';
5 | import './styles.less';
6 |
7 | const { useContext } = React;
8 | interface Iprops {
9 | name: string;
10 | city?: string;
11 | }
12 |
13 | const HouseDetail: React.FunctionComponent = ({
14 | name,
15 | city = '510100', // 成都
16 | }) => {
17 | const { allData } = useContext(AppContext);
18 | const price = allData.find((data) => data.name.includes(name))?.price;
19 | const content = (
20 |
21 |
22 | 单价:
23 | {price ? (
24 |
25 | {price}元/m2
26 |
27 | ) : (
28 | '暂无'
29 | )}
30 |
31 |
32 | 位置:
33 |
38 | 查看地图
39 |
40 |
41 |
42 | );
43 |
44 | return (
45 |
46 | {name}
47 |
48 |
49 | );
50 | };
51 |
52 | export default HouseDetail;
53 |
--------------------------------------------------------------------------------
/src/client/components/HouseDetail/styles.less:
--------------------------------------------------------------------------------
1 | .house-price {
2 | color: #5eba00;
3 | padding-right: 5px;
4 | }
5 |
6 | .house-detail-name {
7 | cursor: pointer;
8 | padding-right: 5px;
9 | }
10 |
11 | .house-detail-info {
12 | color: #1890ff;
13 | cursor: pointer;
14 | }
15 |
--------------------------------------------------------------------------------
/src/client/components/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Spin } from 'antd';
3 | import './styles.less';
4 |
5 | interface Iprops {
6 | height?: string;
7 | tip?: string;
8 | }
9 |
10 | const Loading: React.FunctionComponent = ({
11 | height = '50px',
12 | tip = '',
13 | }) => (
14 |
15 |
16 |
17 | );
18 |
19 | export default Loading;
20 |
--------------------------------------------------------------------------------
/src/client/components/Loading/styles.less:
--------------------------------------------------------------------------------
1 | @import '../../theme/const.less';
2 |
3 | .common-loading {
4 | text-align: center;
5 | background-color: @main-color;
6 | }
7 |
--------------------------------------------------------------------------------
/src/client/components/Notice/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { notification } from 'antd';
3 | import { SyncOutlined } from '@ant-design/icons';
4 | import gql from 'graphql-tag';
5 | import config from '../../config';
6 | import './styles.less';
7 | import { AppContext, IappContext } from '../../context/appContext';
8 |
9 | const { useState, useContext } = React;
10 |
11 | function openNotification(
12 | setLoading: (...args: boolean[]) => void,
13 | appState: IappContext
14 | ) {
15 | setLoading(true);
16 | const { getGraphqlClient } = config;
17 |
18 | getGraphqlClient()
19 | .query({
20 | query: gql`
21 | {
22 | spiderPageOne {
23 | allLength
24 | successArray {
25 | _id
26 | area
27 | name
28 | number
29 | beginTime
30 | endTime
31 | status
32 | }
33 | }
34 | }
35 | `,
36 | })
37 | .then((result) => {
38 | const data = result.data.spiderPageOne;
39 | notification.open({
40 | message: '消息提醒',
41 | description: `成功更新数据${data.allLength}条,新数据${data.successArray.length}条。`,
42 | });
43 | setLoading(false);
44 | if (data.successArray.length > 0) {
45 | appState.changeData(appState.allData.concat(data.successArray));
46 | }
47 | });
48 | }
49 |
50 | const Notice: React.FunctionComponent = () => {
51 | const [isLoading, setLoading] = useState(false);
52 | const appState = useContext(AppContext);
53 | return (
54 |
55 | {
57 | openNotification(setLoading, appState);
58 | }}
59 | />
60 |
61 | );
62 | };
63 |
64 | export default Notice;
65 |
--------------------------------------------------------------------------------
/src/client/components/Notice/styles.less:
--------------------------------------------------------------------------------
1 | @keyframes donut-spin {
2 | 0% {
3 | transform: rotate(0deg);
4 | }
5 | 100% {
6 | transform: rotate(360deg);
7 | }
8 | }
9 |
10 | .notice-icon {
11 | display: inline-block;
12 | }
13 |
14 | .loading {
15 | animation: donut-spin 3s linear infinite;
16 | }
17 |
--------------------------------------------------------------------------------
/src/client/components/Rank/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import _ from 'lodash';
3 | import './styles.less';
4 | import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
5 | import { RenderLoadingComponent } from '../HOC/RenderLoadingComponent';
6 | import HouseDetail from '../HouseDetail';
7 |
8 | interface Irank {
9 | _id: string;
10 | name: string;
11 | number: number;
12 | }
13 |
14 | // 导出给 test 文件使用
15 | export interface Iprops {
16 | data: Irank[];
17 | title: string;
18 | unit: string;
19 | isLink?: boolean;
20 | }
21 |
22 | const Rank: React.FunctionComponent = ({
23 | data,
24 | title,
25 | unit,
26 | isLink,
27 | }) => {
28 | const [rankData, changeRankData] = useState(
29 | _.sortBy(data, (item: Irank) => -item.number)
30 | );
31 | const [desc, changeDesc] = useState(1);
32 | const rankTitle = title ? `排名:${title}` : '排名';
33 |
34 | const changeSort = () => {
35 | if (desc) {
36 | changeRankData(_.sortBy(data, (item: Irank) => item.number));
37 | changeDesc(0);
38 | } else {
39 | changeRankData(_.sortBy(data, (item: Irank) => -item.number));
40 | changeDesc(1);
41 | }
42 | };
43 |
44 | useEffect(() => {
45 | changeRankData(_.sortBy(data, (item: Irank) => -item.number));
46 | changeDesc(1);
47 | }, [data]);
48 |
49 | return (
50 |
51 |
52 |
{rankTitle}
53 |
58 | {desc ? : }
59 |
60 |
61 |
62 | {rankData.map((item: Irank, index: number) => {
63 | const istop3 = index < 3 ? 'top3' : '';
64 | return (
65 | // eslint-disable-next-line no-underscore-dangle
66 | -
67 | {index + 1}
68 |
69 | {isLink ? : item.name}
70 |
71 | {item.number + unit}
72 |
73 | );
74 | })}
75 |
76 |
77 | );
78 | };
79 |
80 | export default RenderLoadingComponent(Rank);
81 |
--------------------------------------------------------------------------------
/src/client/components/Rank/styles.less:
--------------------------------------------------------------------------------
1 | @import '../../theme/const.less';
2 |
3 | .rank {
4 | padding: 0 32px;
5 | height: 410px;
6 | overflow-y: scroll;
7 | .rank-title {
8 | margin-bottom: 0.5em;
9 | color: rgba(0, 0, 0, 0.85);
10 | font-weight: 500;
11 | display: flex;
12 | justify-content: space-between;
13 | .rank-title-desc {
14 | color: #1890ff;
15 | cursor: pointer;
16 | }
17 | }
18 | .rank-list {
19 | margin: 25px 0 0;
20 | padding: 0;
21 | list-style: none;
22 | li {
23 | margin-top: 16px;
24 | display: flex;
25 | > span {
26 | font-size: 14px;
27 | line-height: 22px;
28 | &:first-child {
29 | background-color: @bg-color;
30 | border-radius: 20px;
31 | display: inline-block;
32 | font-size: 12px;
33 | font-weight: 600;
34 | margin-right: 24px;
35 | height: 20px;
36 | line-height: 20px;
37 | width: 20px;
38 | text-align: center;
39 | }
40 | &:nth-child(2) {
41 | flex: 1;
42 | overflow: hidden;
43 | text-overflow: ellipsis;
44 | white-space: nowrap;
45 | }
46 | &:last-child {
47 | width: 60px;
48 | text-align: right;
49 | }
50 | &.top3 {
51 | background-color: @title-color;
52 | color: @main-color;
53 | }
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/client/components/StatisticCard/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card, Col, Row } from 'antd';
3 | import util, { IhouseInfo } from '../../utils/index';
4 | import { AppContext } from '../../context/appContext';
5 | import * as constants from '../../constants';
6 | import { RenderLoadingJSX } from '../HOC/RenderLoadingComponent';
7 | import './styles.less';
8 |
9 | const { useContext } = React;
10 |
11 | const StatisticCard: React.FunctionComponent = () => {
12 | const appState = useContext(AppContext);
13 | const { allData, isLoading } = appState;
14 | const allInfo = util.getAllInfo(allData);
15 | const thisWeekInfo = util.getThisWeekInfo(allData);
16 | const thisMonthInfo = util.getThisMonthInfo(allData);
17 | const thisQuarterInfo = util.getThisQuarterInfo(allData);
18 |
19 | const renderCard = (info: IhouseInfo) => (
20 |
21 |
22 | {`${constants.BUILDER_NUMBER}:${info.buildNumber}`}
23 |
30 | {info.increaseBuildNumberString}
31 |
32 |
33 |
34 | {`${constants.HOUSE_NUMBER}:${info.houseNumber}`}
35 |
42 | {info.increaseHouseNumberString}
43 |
44 |
45 |
46 | );
47 |
48 | return (
49 |
50 |
51 |
52 | {RenderLoadingJSX(renderCard(thisWeekInfo), isLoading)}
53 |
54 |
55 |
56 |
57 | {RenderLoadingJSX(renderCard(thisMonthInfo), isLoading)}
58 |
59 |
60 |
61 |
62 | {RenderLoadingJSX(renderCard(thisQuarterInfo), isLoading)}
63 |
64 |
65 |
66 |
67 | {RenderLoadingJSX(
68 |
69 | {`${constants.BUILDER_NUMBER}:${allInfo.buildNumber}`}
70 |
71 | {`${constants.HOUSE_NUMBER}:${allInfo.houseNumber}`}
72 |
,
73 | isLoading
74 | )}
75 |
76 |
77 |
78 | );
79 | };
80 |
81 | export default StatisticCard;
82 |
--------------------------------------------------------------------------------
/src/client/components/StatisticCard/past.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card, Col, Row } from 'antd';
3 | import _ from 'lodash';
4 | import util from '../../utils';
5 | import { AppContext } from '../../context/appContext';
6 | import * as constants from '../../constants';
7 | import { RenderLoadingJSX } from '../HOC/RenderLoadingComponent';
8 |
9 | const { useContext } = React;
10 |
11 | const StatisticCardPast: React.FunctionComponent = () => {
12 | const appState = useContext(AppContext);
13 | const { allData, isLoading } = appState;
14 | const allInfo = util.getAllInfo(allData);
15 |
16 | // 年度房源
17 | const maxHouse = _.maxBy(
18 | allData,
19 | (house) => house.number
20 | ) as cdFang.IhouseData;
21 |
22 | // 年度楼盘
23 | const dataByName = _.groupBy(allData, (item) => item.name);
24 | const maxBuilderName =
25 | _.maxBy(Object.keys(dataByName), (item) => dataByName[item].length) || '';
26 |
27 | let maxBuildLength = 0;
28 | let maxBuild = 0;
29 | if (dataByName[maxBuilderName]) {
30 | maxBuildLength = dataByName[maxBuilderName].length;
31 | maxBuild = _.sumBy(dataByName[maxBuilderName], (item) => item.number);
32 | }
33 |
34 | // 年度区域
35 | const dataByArea = _.groupBy(allData, (item) => item.area);
36 | const maxAreaName =
37 | _.maxBy(Object.keys(dataByArea), (item) => dataByArea[item].length) || '';
38 | let maxAreaLength = 0;
39 | let maxArea = 0;
40 | if (dataByArea[maxAreaName]) {
41 | maxAreaLength = dataByArea[maxAreaName].length;
42 | maxArea = _.sumBy(dataByArea[maxAreaName], (item) => item.number);
43 | }
44 |
45 | return (
46 |
47 |
48 |
53 | {RenderLoadingJSX(
54 |
55 | {`${constants.HOUSE_NUMBER}:${
56 | (maxHouse && maxHouse.number) || 0
57 | }`}
58 |
59 | {`${constants.AREA}:${(maxHouse && maxHouse.area) || '暂无'}`}
60 |
,
61 | isLoading
62 | )}
63 |
64 |
65 |
66 |
67 | {RenderLoadingJSX(
68 |
69 | {`${constants.SALE_TIMES}:${maxBuildLength}`}
70 |
71 | {`${constants.HOUSE_NUMBER}:${maxBuild}`}
72 |
,
73 | isLoading
74 | )}
75 |
76 |
77 |
78 |
79 | {RenderLoadingJSX(
80 |
81 | {`${constants.SALE_TIMES}:${maxAreaLength}`}
82 |
83 | {`${constants.HOUSE_NUMBER}:${maxArea}`}
84 |
,
85 | isLoading
86 | )}
87 |
88 |
89 |
90 |
91 | {RenderLoadingJSX(
92 |
93 | {`${constants.BUILDER_NUMBER}:${allInfo.buildNumber}`}
94 |
95 | {`${constants.HOUSE_NUMBER}:${allInfo.houseNumber}`}
96 |
,
97 | isLoading
98 | )}
99 |
100 |
101 |
102 | );
103 | };
104 |
105 | export default StatisticCardPast;
106 |
--------------------------------------------------------------------------------
/src/client/components/StatisticCard/styles.less:
--------------------------------------------------------------------------------
1 | .statistic-card-text {
2 | display: flex;
3 | justify-content: space-between;
4 | }
5 |
--------------------------------------------------------------------------------
/src/client/components/WholeTable/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 | import { Table } from 'antd';
4 | import { AppContext } from '../../context/appContext';
5 | import { RenderLoadingComponent } from '../HOC/RenderLoadingComponent';
6 | import HouseDetail from '../HouseDetail';
7 |
8 | const { useContext } = React;
9 |
10 | const CommonTable: React.FunctionComponent = () => {
11 | const { allData } = useContext(AppContext);
12 | const areas = _.groupBy(allData, (item: cdFang.IhouseData) => item.area);
13 | const areasList = Object.keys(areas);
14 | const nameFilter = areasList.map((item) => ({
15 | text: item,
16 | value: item,
17 | }));
18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 | const columns: any[] = [
20 | {
21 | title: '区域',
22 | dataIndex: 'area',
23 | key: 'area',
24 | filters: nameFilter,
25 | filterMultiple: true,
26 | onFilter: (value: string, datalist: cdFang.IhouseData) =>
27 | datalist.area.indexOf(value) === 0,
28 | },
29 | {
30 | title: '项目名称',
31 | dataIndex: 'name',
32 | key: 'name',
33 | render: (name: string) => ,
34 | },
35 | {
36 | title: '住房套数',
37 | dataIndex: 'number',
38 | key: 'number',
39 | sorter: (a: cdFang.IhouseData, b: cdFang.IhouseData): boolean =>
40 | a.number > b.number,
41 | },
42 | {
43 | title: '登记开始时间',
44 | dataIndex: 'beginTime',
45 | key: 'beginTime',
46 | sorter: (a: cdFang.IhouseData, b: cdFang.IhouseData) =>
47 | new Date(a.beginTime).getTime() - new Date(b.beginTime).getTime(),
48 | },
49 | {
50 | title: '登记结束时间',
51 | dataIndex: 'endTime',
52 | key: 'endTime',
53 | sorter: (a: cdFang.IhouseData, b: cdFang.IhouseData) =>
54 | new Date(a.endTime).getTime() - new Date(b.endTime).getTime(),
55 | },
56 | {
57 | title: '报名状态',
58 | dataIndex: 'status',
59 | key: 'status',
60 | filters: [
61 | {
62 | text: '未报名',
63 | value: '未报名',
64 | },
65 | {
66 | text: '正在报名',
67 | value: '正在报名',
68 | },
69 | {
70 | text: '报名结束',
71 | value: '报名结束',
72 | },
73 | ],
74 | filterMultiple: true,
75 | onFilter: (value: string, datalist: cdFang.IhouseData) =>
76 | datalist.status.indexOf(value) === 0,
77 | render: (text: string) => {
78 | if (text !== '报名结束') {
79 | return {text};
80 | }
81 | return text;
82 | },
83 | },
84 | ];
85 |
86 | // eslint-disable-next-line no-underscore-dangle
87 | const data = allData.map((item) => ({ key: item._id, ...item }));
88 |
89 | return (
90 | '汇总表'}
92 | columns={columns}
93 | dataSource={data}
94 | locale={{
95 | filterTitle: '筛选',
96 | filterConfirm: '确定',
97 | filterReset: '重置',
98 | emptyText: '暂无数据',
99 | }}
100 | />
101 | );
102 | };
103 |
104 | export default RenderLoadingComponent(CommonTable);
105 |
--------------------------------------------------------------------------------
/src/client/config/index.ts:
--------------------------------------------------------------------------------
1 | import ApolloClient from 'apollo-boost';
2 |
3 | // 默认为当前域名
4 | const serverDomain = '';
5 |
6 | function getGraphqlClient(): ApolloClient {
7 | return new ApolloClient({
8 | uri: `${serverDomain}/graphql`
9 | });
10 | }
11 |
12 | const config = {
13 | serverDomain,
14 | getGraphqlClient
15 | };
16 |
17 | export default config;
18 |
--------------------------------------------------------------------------------
/src/client/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const HOUSE_NUMBER = '房源数';
2 | export const BUILDER_NUMBER = '楼盘数';
3 | export const HOUSE_PRICE = '房价';
4 | export const HOUSE_PRICE_MAX = '最高价格';
5 | export const HOUSE_PRICE_MIN = '最低价格';
6 | export const SALE_TIMES = '开盘数';
7 | export const AREA = '区域';
8 | export const GITHUB_URL = 'https://github.com/mengsixing/cdfang-spider';
9 | export const COPYRIGHT = 'Copyright 2021 mengsixing. All Rights Reserved';
10 | export const BEIAN_ICP = '蜀ICP备17036475号-2';
11 | export const RISE_COLOR = '#5eba00';
12 | export const DECLINE_COLOR = '#cd201f';
13 | export const LOADING_TIP = '页面加载中...';
14 | export const HOUSE_PURCHASE_REGISTRATION =
15 | 'http://zw.cdzj.chengdu.gov.cn/lottery/accept/index';
16 |
17 | export const tabKeyRouterMap: { [x: string]: string } = {
18 | '2020': '/2020',
19 | '2019': '/2019',
20 | '2018': '/2018',
21 | '2017': '/2017',
22 | home: '/',
23 | '/2020': '2020',
24 | '/2019': '2019',
25 | '/2018': '2018',
26 | '/2017': '2017',
27 | '/': 'home'
28 | };
29 |
--------------------------------------------------------------------------------
/src/client/containers/App/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Layout, BackTop } from 'antd';
3 | import Header from '../../components/Header';
4 |
5 | import { COPYRIGHT, BEIAN_ICP } from '../../constants';
6 |
7 | import './styles.less';
8 |
9 | const App: React.FunctionComponent = ({ children }) => (
10 |
11 |
12 |
13 |
14 |
15 |
16 | {children}
17 |
18 |
23 | {COPYRIGHT}
24 |
25 |
26 |
27 | );
28 |
29 | export default App;
30 |
--------------------------------------------------------------------------------
/src/client/containers/App/styles.less:
--------------------------------------------------------------------------------
1 | @import '../../theme/reset.less';
2 | @import '../../theme/const.less';
3 |
4 | // 全局样式
5 |
6 | .common-margin {
7 | margin: 24px;
8 | }
9 |
10 | .common-background {
11 | background-color: @bg-color;
12 | }
13 |
14 | .main-background {
15 | background-color: @main-color;
16 | }
17 |
18 | .margin-white {
19 | .main-background();
20 | .common-margin();
21 | }
22 |
23 | .chart-title {
24 | text-align: center;
25 | margin: 0 16px;
26 | font-weight: bold;
27 | }
28 |
29 | .content {
30 | .common-background();
31 | .content-section {
32 | .common-margin();
33 | .main-background();
34 | }
35 | .content-statistic-card {
36 | .common-margin();
37 | .common-background();
38 | }
39 | .content-graph-bar {
40 | .main-background();
41 | margin: 0 24px;
42 | padding-top: 16px;
43 | padding-bottom: 24px;
44 | .ant-tabs-nav-list {
45 | margin-left: 24px;
46 | }
47 | }
48 | .content-basic-column {
49 | .margin-white();
50 | .content-basic-column-title {
51 | color: @title-color;
52 | font-weight: 500;
53 | padding: 16px;
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/client/containers/CurrentYear/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 | import { Layout, Tabs } from 'antd';
4 | import { RouteComponentProps } from 'react-router';
5 |
6 | import util from '../../utils';
7 | import ChartPanel from '../../components/ChartPanel';
8 | import WholeTable from '../../components/WholeTable';
9 | import StatisticCard from '../../components/StatisticCard';
10 | import BasicColumnGraph from '../../components/BasicColumnGraph';
11 | import { RenderLoadingJSX } from '../../components/HOC/RenderLoadingComponent';
12 | import { AppContext } from '../../context/appContext';
13 | import * as constants from '../../constants';
14 |
15 | const { useContext } = React;
16 | const { Content } = Layout;
17 | const { TabPane } = Tabs;
18 |
19 | const CurrentYear: React.FunctionComponent = () => {
20 | const { allData, activityKey, changeActivityKey, isLoading } = useContext(
21 | AppContext
22 | );
23 |
24 | const areasGroup = _.groupBy(allData, (item: cdFang.IhouseData) => item.area);
25 | const areasList = Object.keys(areasGroup);
26 | const tabpanels = util.sortArea(areasList).map((item: string) => (
27 |
28 |
33 |
34 | ));
35 | // 柱状图数据
36 | const { chartHouseData, chartBuilderData } = util.getBasicColumnGraphData(
37 | allData
38 | );
39 | return (
40 |
41 |
42 |
43 |
44 |
45 | {RenderLoadingJSX(
46 |
47 | {tabpanels}
48 | ,
49 | isLoading
50 | )}
51 |
52 |
53 |
整体统计
54 |
61 |
62 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
77 | export default CurrentYear;
78 |
--------------------------------------------------------------------------------
/src/client/containers/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import _ from 'lodash';
3 | import dayjs from 'dayjs';
4 | import { Layout, Col, Row, Tabs, Tag, Timeline } from 'antd';
5 | import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
6 | import { RouteComponentProps } from 'react-router';
7 |
8 | import utils from '../../utils';
9 | import BasicAreaGraph from '../../components/BasicAreaGraph';
10 | import WholeTable from '../../components/WholeTable';
11 | import StatisticCard from '../../components/StatisticCard';
12 | import Rank from '../../components/Rank';
13 | import BasicColumnGraph from '../../components/BasicColumnGraph';
14 | import GroupedColumnGraph from '../../components/GroupedColumnGraph';
15 | import HouseDetail from '../../components/HouseDetail';
16 | import { AppContext } from '../../context/appContext';
17 | import * as constants from '../../constants';
18 | import './styles.less';
19 |
20 | const { lazy, useContext } = React;
21 | const { TabPane } = Tabs;
22 | const { Content } = Layout;
23 | const CurrentHouse = lazy(() => import('../../components/CurrentHouse'));
24 |
25 | interface ImonthHouse {
26 | month: string;
27 | [constants.HOUSE_NUMBER]: number;
28 | }
29 |
30 | interface ImonthBuilder {
31 | month: string;
32 | [constants.BUILDER_NUMBER]: number;
33 | }
34 |
35 | interface ImonthHousePrice {
36 | month: string;
37 | [constants.HOUSE_PRICE_MAX]: number;
38 | [constants.HOUSE_PRICE_MIN]: number;
39 | }
40 |
41 | const Home: React.FunctionComponent = () => {
42 | const { allData } = useContext(AppContext);
43 | const [desc, changeTimelineDesc] = useState(true);
44 |
45 | // 构建区域图需要的数据
46 | const arrayByMonth = _.groupBy(allData, (item) =>
47 | dayjs(item.beginTime).format('YYYY-MM')
48 | );
49 | const arrayByDay = _.groupBy(allData, (item) =>
50 | dayjs(item.beginTime).format('YYYY-MM-DD')
51 | );
52 |
53 | const houseData: ImonthHouse[] = [];
54 | const builderData: ImonthBuilder[] = [];
55 | const housePriceData: ImonthHousePrice[] = [];
56 | Object.keys(arrayByMonth)
57 | .sort()
58 | .forEach((key) => {
59 | const houseNumber = _.sumBy(arrayByMonth[key], 'number');
60 | builderData.push({
61 | month: key,
62 | [constants.BUILDER_NUMBER]: arrayByMonth[key].length,
63 | });
64 | houseData.push({
65 | month: key,
66 | [constants.HOUSE_NUMBER]: houseNumber,
67 | });
68 |
69 | const hasPriceHouses = arrayByMonth[key].filter((house) => house.price);
70 | if (hasPriceHouses.length > 0) {
71 | const housePriceMax = _.maxBy(hasPriceHouses, 'price')?.price || 0;
72 | const housePriceMin = _.minBy(hasPriceHouses, 'price')?.price || 0;
73 | housePriceData.push({
74 | month: key,
75 | [constants.HOUSE_PRICE_MAX]: housePriceMax,
76 | [constants.HOUSE_PRICE_MIN]: housePriceMin,
77 | });
78 | }
79 | });
80 |
81 | // 构建排行数据
82 | const builderRankData = builderData.map((item) => ({
83 | _id: utils.getRandomId(),
84 | name: item.month,
85 | number: item[constants.BUILDER_NUMBER],
86 | }));
87 | const houseRankData = houseData.map((item) => ({
88 | _id: utils.getRandomId(),
89 | name: item.month,
90 | number: item[constants.HOUSE_NUMBER],
91 | }));
92 | const maxHousePriceRankData = housePriceData.map((item) => ({
93 | _id: utils.getRandomId(),
94 | name: `${item.month} 最高价`,
95 | number: item[constants.HOUSE_PRICE_MAX],
96 | }));
97 | const minHousePriceRankData = housePriceData.map((item) => ({
98 | _id: utils.getRandomId(),
99 | name: `${item.month} 最低价`,
100 | number: item[constants.HOUSE_PRICE_MAX],
101 | }));
102 | const housePriceRankData = maxHousePriceRankData.concat(
103 | minHousePriceRankData
104 | );
105 |
106 | // 柱状图数据
107 | const {
108 | chartHouseData,
109 | chartBuilderData,
110 | chartHousePriceData,
111 | } = utils.getBasicColumnGraphData(allData);
112 |
113 | return (
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
144 |
145 |
146 |
147 |
148 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
165 |
166 |
169 | {constants.HOUSE_PRICE}
170 |
171 | new
172 |
173 |
174 | }
175 | key="3"
176 | >
177 |
178 |
179 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
200 |
201 |
202 |
203 |
204 |
205 |
{
207 | changeTimelineDesc(!desc);
208 | }}
209 | aria-hidden="true"
210 | >
211 | 时间轴
212 | {desc ? : }
213 |
214 |
215 |
216 |
217 | {Object.keys(arrayByDay)
218 | .sort()
219 | .map((day) => (
220 |
221 | {arrayByDay[day].map((item) => (
222 |
223 |
224 |
225 | ))}
226 |
227 | ))}
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 | );
236 | };
237 |
238 | export default Home;
239 |
--------------------------------------------------------------------------------
/src/client/containers/Home/styles.less:
--------------------------------------------------------------------------------
1 | @import '../../theme/const.less';
2 |
3 | .home-content-houses {
4 | background-color: @main-color;
5 | margin: 0 24px;
6 | padding-top: 16px;
7 | padding-bottom: 24px;
8 | .ant-tabs-nav-list {
9 | margin-left: 24px;
10 | }
11 | }
12 |
13 | .content-timeline {
14 | .content-timeline-title {
15 | color: @title-color;
16 | font-weight: 500;
17 | padding: 16px;
18 | span {
19 | cursor: pointer;
20 | }
21 | }
22 | .content-timeline-content {
23 | height: 300px;
24 | overflow: scroll;
25 | padding-top: 20px;
26 | .content-timeline-item {
27 | display: inline-block;
28 | padding: 0 5px;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/client/containers/PastYear/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 | import { Layout, Tabs } from 'antd';
4 | import { RouteComponentProps } from 'react-router';
5 |
6 | import util from '../../utils/index';
7 | import ChartPanel from '../../components/ChartPanel';
8 | import WholeTable from '../../components/WholeTable';
9 | import StatisticCard from '../../components/StatisticCard/past';
10 | import BasicColumnGraph from '../../components/BasicColumnGraph';
11 | import { RenderLoadingJSX } from '../../components/HOC/RenderLoadingComponent';
12 | import { AppContext } from '../../context/appContext';
13 | import * as constants from '../../constants';
14 |
15 | const { useContext } = React;
16 | const { Content } = Layout;
17 | const { TabPane } = Tabs;
18 |
19 | const PastYear: React.FunctionComponent = () => {
20 | const { allData, activityKey, changeActivityKey, isLoading } = useContext(
21 | AppContext
22 | );
23 |
24 | const areasGroup = _.groupBy(allData, (item: cdFang.IhouseData) => item.area);
25 | const areasList = Object.keys(areasGroup);
26 |
27 | // 选中的 key 不在区域列表中
28 | if (!areasList.includes(activityKey) && areasList.length > 0) {
29 | changeActivityKey(areasList[0]);
30 | }
31 |
32 | const tabpanels = util.sortArea(areasList).map((item: string) => (
33 |
34 |
39 |
40 | ));
41 |
42 | // 柱状图数据
43 | const { chartHouseData, chartBuilderData } = util.getBasicColumnGraphData(
44 | allData
45 | );
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
53 | {RenderLoadingJSX(
54 |
55 | {tabpanels}
56 | ,
57 | isLoading
58 | )}
59 |
60 |
61 |
整体统计
62 |
69 |
70 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 | };
84 |
85 | export default PastYear;
86 |
--------------------------------------------------------------------------------
/src/client/context/appContext.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export interface IappContext {
4 | allData: cdFang.IhouseData[];
5 | activityKey: string;
6 | selectedYear: number;
7 | isLoading: boolean;
8 | changeData(data: cdFang.IhouseData[]): void;
9 | changeActivityKey(key: string): void;
10 | changeSelectedYear(key: number): void;
11 | changeLoading(isLoading: boolean): void;
12 | }
13 |
14 | // 初始化 context,具体的方法在 provider 中实现
15 | export const globalData: IappContext = {
16 | allData: [],
17 | activityKey: '天府新区',
18 | selectedYear: 0,
19 | isLoading: false,
20 | changeData:()=>{},
21 | changeActivityKey:()=>{},
22 | changeSelectedYear:()=>{},
23 | changeLoading:()=>{}
24 | };
25 |
26 | export const AppContext = React.createContext(globalData);
27 |
--------------------------------------------------------------------------------
/src/client/context/appContextProvider.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-use-before-define, @typescript-eslint/no-use-before-define */
2 | import React, { useState } from 'react';
3 | import { AppContext, IappContext } from './appContext';
4 |
5 | const AppProvider = ({ children }: React.Props<{ value: IappContext }>) => {
6 | const changeData = (data: cdFang.IhouseData[]) => {
7 | changeAppState((prevState) => ({
8 | ...prevState,
9 | allData: data,
10 | }));
11 | };
12 |
13 | const changeActivityKey = (key: string) => {
14 | changeAppState((prevState) => ({
15 | ...prevState,
16 | activityKey: key,
17 | }));
18 | };
19 |
20 | const changeSelectedYear = (year: number) => {
21 | changeAppState((prevState) => ({
22 | ...prevState,
23 | selectedYear: year,
24 | }));
25 | };
26 |
27 | const changeLoading = (isLoading: boolean) => {
28 | changeAppState((prevState) => ({
29 | ...prevState,
30 | isLoading,
31 | }));
32 | };
33 |
34 | const initAppState: IappContext = {
35 | allData: [],
36 | activityKey: '天府新区',
37 | selectedYear: 0,
38 | isLoading: false,
39 | changeData,
40 | changeActivityKey,
41 | changeSelectedYear,
42 | changeLoading,
43 | };
44 |
45 | const [appState, changeAppState] = useState(initAppState);
46 |
47 | return {children};
48 | };
49 |
50 | export default AppProvider;
51 |
--------------------------------------------------------------------------------
/src/client/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import Loading from './components/Loading';
5 | import AppContextProvider from './context/appContextProvider';
6 | import { LOADING_TIP } from './constants';
7 | import Routes from './router';
8 |
9 | const { lazy, Suspense } = React;
10 |
11 | const App = lazy(() => import('./containers/App/index'));
12 |
13 | ReactDOM.render(
14 | }>
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ,
23 | document.getElementById('root')
24 | );
25 |
--------------------------------------------------------------------------------
/src/client/router.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Switch } from 'react-router-dom';
3 | import PastYear from './containers/PastYear';
4 | import CurrentYear from './containers/CurrentYear';
5 | import Home from './containers/Home';
6 |
7 | function Routes() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | }
19 |
20 | export default Routes;
21 |
--------------------------------------------------------------------------------
/src/client/theme/const.less:
--------------------------------------------------------------------------------
1 | @bg-color: rgb(240, 242, 245);
2 | @main-color: white;
3 | @title-color: #1890ff;
4 |
--------------------------------------------------------------------------------
/src/client/theme/reset.less:
--------------------------------------------------------------------------------
1 | // antd 样式覆盖
2 | @import './const.less';
3 |
4 | .ant-layout-header {
5 | background-color: @main-color;
6 | padding: 0 24px 0 4px;
7 | }
8 |
9 | .ant-table-title {
10 | color: @title-color;
11 | font-weight: 500;
12 | padding: 16px;
13 | }
14 |
15 | .ant-collapse-header {
16 | background-color: @main-color;
17 | font-weight: 500;
18 | color: @title-color !important;
19 | }
20 |
21 | .ant-layout-footer a {
22 | color: inherit;
23 | }
24 |
--------------------------------------------------------------------------------
/src/client/utils/index.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import dayjs from 'dayjs';
3 | import { HOUSE_NUMBER, BUILDER_NUMBER, AREA } from '../constants';
4 |
5 | interface Iauarter {
6 | thisQuarterStart: dayjs.Dayjs;
7 | thisQuarterEnd: dayjs.Dayjs;
8 | }
9 |
10 | export interface IhouseInfo {
11 | houseNumber: number;
12 | buildNumber: number;
13 | increaseHouseNumber?: number;
14 | increaseBuildNumber?: number;
15 | increaseHouseNumberString?: string;
16 | increaseBuildNumberString?: string;
17 | }
18 |
19 | // 当前季度
20 | function getCurrentQuarter(dayjsObject = dayjs()): Iauarter {
21 | switch (dayjsObject.month()) {
22 | case 0:
23 | case 1:
24 | case 2:
25 | return {
26 | thisQuarterStart: dayjsObject.set('month', 0).startOf('month'),
27 | thisQuarterEnd: dayjsObject.set('month', 2).startOf('month')
28 | };
29 | case 3:
30 | case 4:
31 | case 5:
32 | return {
33 | thisQuarterStart: dayjsObject.set('month', 3).startOf('month'),
34 | thisQuarterEnd: dayjsObject.set('month', 5).startOf('month')
35 | };
36 | case 6:
37 | case 7:
38 | case 8:
39 | return {
40 | thisQuarterStart: dayjsObject.set('month', 6).startOf('month'),
41 | thisQuarterEnd: dayjsObject.set('month', 8).startOf('month')
42 | };
43 | case 9:
44 | case 10:
45 | case 11:
46 | return {
47 | thisQuarterStart: dayjsObject.set('month', 9).startOf('month'),
48 | thisQuarterEnd: dayjsObject.set('month', 11).startOf('month')
49 | };
50 | default:
51 | // 默认返回第一季度
52 | return {
53 | thisQuarterStart: dayjsObject.set('month', 0).startOf('month'),
54 | thisQuarterEnd: dayjsObject.set('month', 2).startOf('month')
55 | };
56 | }
57 | }
58 |
59 | // 获取增长量
60 | function getIncreaseNumber(number: number): string {
61 | if (number > 0) {
62 | return `${number}↑`;
63 | }
64 | if (number === 0) {
65 | return '持平';
66 | }
67 | return `${number}↓`;
68 | }
69 |
70 | // 获取长度为 10 的随机string
71 | function getRandomId(): string {
72 | const str = 'abcdefghijklmnopqrstuvwxyz';
73 | const newStrArray = [];
74 | for (let i = 0; i < 10; i += 1) {
75 | newStrArray.push(str[Math.floor(Math.random() * 26)]);
76 | }
77 | return newStrArray.join('');
78 | }
79 |
80 | // 获取基础柱状图数据
81 | function getBasicColumnGraphData(allData: cdFang.IhouseData[]) {
82 | const areasGroup = _.groupBy(allData, (item: cdFang.IhouseData) => item.area);
83 | const chartHouseData: cdFang.IareaHouse[] = [];
84 | const chartBuilderData: cdFang.IareaBuilder[] = [];
85 | const chartHousePriceData: {[T:string]:string|number}[] = [{name:'最高价格'},{name:'最低价格'}];
86 | Object.keys(areasGroup).forEach(key => {
87 | chartHouseData.push({
88 | [AREA]: key,
89 | [HOUSE_NUMBER]: _.sumBy(areasGroup[key], 'number')
90 | });
91 | chartBuilderData.push({
92 | [AREA]: key,
93 | [BUILDER_NUMBER]: areasGroup[key].length
94 | });
95 | chartHousePriceData[0][key] = _.maxBy(areasGroup[key],'price')?.price || 0
96 | chartHousePriceData[1][key] = _.minBy(areasGroup[key].filter(item=>item.price),'price')?.price || 0
97 | });
98 | return { chartHouseData, chartBuilderData,chartHousePriceData };
99 | }
100 |
101 | function getYearList(): number[] {
102 | let currentYear = new Date().getFullYear();
103 | const startYear = 2017;
104 | const yearList = [];
105 |
106 | while (currentYear >= startYear) {
107 | yearList.push(currentYear);
108 | currentYear -= 1;
109 | }
110 | return yearList;
111 | }
112 |
113 | const util = {
114 | getAllInfo(allData: cdFang.IhouseData[]): IhouseInfo {
115 | const houseNumber = _.sumBy(allData, 'number');
116 | const buildNumber = allData.length;
117 | return {
118 | houseNumber,
119 | buildNumber
120 | };
121 | },
122 | getThisWeekInfo(allData: cdFang.IhouseData[]): IhouseInfo {
123 | const thisWeekStart = dayjs().set('day', 0);
124 | const thisWeekEnd = dayjs().set('day', 7);
125 | const weekData = _.filter(
126 | allData,
127 | (item: cdFang.IhouseData): boolean => {
128 | const beginTime = dayjs(item.beginTime);
129 | return beginTime > thisWeekStart && beginTime < thisWeekEnd;
130 | }
131 | );
132 | const houseNumber = _.sumBy(weekData, 'number');
133 | const buildNumber = weekData.length;
134 | const lastWeekInfo = this.getLastWeekInfo(allData);
135 | const increaseHouseNumber = houseNumber - lastWeekInfo.houseNumber;
136 | const increaseBuildNumber = buildNumber - lastWeekInfo.buildNumber;
137 | return {
138 | houseNumber,
139 | buildNumber,
140 | increaseHouseNumber,
141 | increaseBuildNumber,
142 | increaseHouseNumberString: getIncreaseNumber(increaseHouseNumber),
143 | increaseBuildNumberString: getIncreaseNumber(increaseBuildNumber)
144 | };
145 | },
146 | getThisMonthInfo(allData: cdFang.IhouseData[]): IhouseInfo {
147 | const thisMonthStart = dayjs().startOf('month');
148 | const thisMonthEnd = dayjs().endOf('month');
149 | const weekData = _.filter(
150 | allData,
151 | (item: cdFang.IhouseData): boolean => {
152 | const beginTime = dayjs(item.beginTime);
153 | return beginTime > thisMonthStart && beginTime < thisMonthEnd;
154 | }
155 | );
156 | const houseNumber = _.sumBy(weekData, 'number');
157 | const buildNumber = weekData.length;
158 | const lastMonthInfo = this.getLastMonthInfo(allData);
159 | const increaseHouseNumber = houseNumber - lastMonthInfo.houseNumber;
160 | const increaseBuildNumber = buildNumber - lastMonthInfo.buildNumber;
161 | return {
162 | houseNumber,
163 | buildNumber,
164 | increaseHouseNumber,
165 | increaseBuildNumber,
166 | increaseHouseNumberString: getIncreaseNumber(increaseHouseNumber),
167 | increaseBuildNumberString: getIncreaseNumber(increaseBuildNumber)
168 | };
169 | },
170 | getThisQuarterInfo(allData: cdFang.IhouseData[]): IhouseInfo {
171 | const time = getCurrentQuarter();
172 | const { thisQuarterStart, thisQuarterEnd } = time;
173 | const quarterData = _.filter(
174 | allData,
175 | (item: cdFang.IhouseData): boolean => {
176 | const beginTime = dayjs(item.beginTime);
177 | return beginTime > thisQuarterStart && beginTime < thisQuarterEnd;
178 | }
179 | );
180 | const houseNumber = _.sumBy(quarterData, 'number');
181 | const buildNumber = quarterData.length;
182 | const lastQuarterInfo = this.getLastQuarterInfo(allData);
183 | const increaseHouseNumber = houseNumber - lastQuarterInfo.houseNumber;
184 | const increaseBuildNumber = buildNumber - lastQuarterInfo.buildNumber;
185 | return {
186 | houseNumber,
187 | buildNumber,
188 | increaseHouseNumber,
189 | increaseBuildNumber,
190 | increaseHouseNumberString: getIncreaseNumber(increaseHouseNumber),
191 | increaseBuildNumberString: getIncreaseNumber(increaseBuildNumber)
192 | };
193 | },
194 | getLastWeekInfo(allData: cdFang.IhouseData[]): IhouseInfo {
195 | const thisWeekStart = dayjs()
196 | .set('day', 0)
197 | .add(-7, 'day');
198 | const thisWeekEnd = dayjs()
199 | .set('day', 7)
200 | .add(-7, 'day');
201 | const weekData = _.filter(
202 | allData,
203 | (item: cdFang.IhouseData): boolean => {
204 | const beginTime = dayjs(item.beginTime);
205 | return beginTime > thisWeekStart && beginTime < thisWeekEnd;
206 | }
207 | );
208 | const houseNumber = _.sumBy(weekData, 'number');
209 | const buildNumber = weekData.length;
210 | return {
211 | houseNumber,
212 | buildNumber
213 | };
214 | },
215 | getLastMonthInfo(allData: cdFang.IhouseData[]): IhouseInfo {
216 | const thisMonthStart = dayjs()
217 | .add(-1, 'month')
218 | .startOf('month');
219 | const thisMonthEnd = dayjs()
220 | .add(-1, 'month')
221 | .endOf('month');
222 | const weekData = _.filter(
223 | allData,
224 | (item: cdFang.IhouseData): boolean => {
225 | const beginTime = dayjs(item.beginTime);
226 | return beginTime > thisMonthStart && beginTime < thisMonthEnd;
227 | }
228 | );
229 | const houseNumber = _.sumBy(weekData, 'number');
230 | const buildNumber = weekData.length;
231 | return {
232 | houseNumber,
233 | buildNumber
234 | };
235 | },
236 | getLastQuarterInfo(allData: cdFang.IhouseData[]): IhouseInfo {
237 | const time = getCurrentQuarter(dayjs().add(-3, 'month'));
238 | const { thisQuarterStart, thisQuarterEnd } = time;
239 | const quarterData = _.filter(
240 | allData,
241 | (item: cdFang.IhouseData): boolean => {
242 | const beginTime = dayjs(item.beginTime);
243 | return beginTime > thisQuarterStart && beginTime < thisQuarterEnd;
244 | }
245 | );
246 | const houseNumber = _.sumBy(quarterData, 'number');
247 | const buildNumber = quarterData.length;
248 | return {
249 | houseNumber,
250 | buildNumber
251 | };
252 | },
253 | sortArea(areaArray: string[]): string[] {
254 | // 把主城区排在前面
255 | const mainArea =
256 | '天府新区,高新南区,龙泉驿区,金牛区,成华区,武侯区,青羊区,锦江区';
257 | const newArray = _.sortBy(areaArray, [
258 | (area: string) => -mainArea.indexOf(area)
259 | ]);
260 | return newArray;
261 | },
262 | getCurrentQuarter,
263 | getRandomId,
264 | getBasicColumnGraphData,
265 | getYearList
266 | };
267 |
268 | export default util;
269 |
--------------------------------------------------------------------------------
/src/client/utils/request.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import config from '../config';
3 |
4 | interface IallHouses {
5 | allHouses: cdFang.IhouseData[];
6 | }
7 |
8 | interface Ipvs {
9 | pvs: number;
10 | }
11 |
12 | const { getGraphqlClient } = config;
13 |
14 | export function requestData(year: string, callback: (...args: any[]) => void): void {
15 | const yearParam = year === 'home' ? '0' : year;
16 |
17 | getGraphqlClient()
18 | .query({
19 | query: gql`
20 | {
21 | allHouses(year: ${yearParam}) {
22 | _id
23 | area
24 | name
25 | number
26 | beginTime
27 | endTime
28 | status
29 | price
30 | }
31 | }
32 | `
33 | })
34 | .then(result => {
35 | callback(result.data.allHouses);
36 | });
37 | }
38 |
39 | export function requestPvs(callback: (...args: any[]) => void): void {
40 | getGraphqlClient()
41 | .query({
42 | query: gql`
43 | {
44 | pvs(routerName: "allHouses")
45 | }
46 | `
47 | })
48 | .then(result => {
49 | callback(result.data.pvs);
50 | });
51 | }
52 |
--------------------------------------------------------------------------------
/src/nodeuii/app.ts:
--------------------------------------------------------------------------------
1 | import Koa from 'koa';
2 | import serve from 'koa-static';
3 | import log4js from 'log4js';
4 | import koaBody from 'koa-body';
5 |
6 | import ErrorHander from './middleware/ErrorHander';
7 | import AnalysicsHander from './middleware/AnalysicsHander';
8 | import controller from './controllers';
9 | import config from './config';
10 | import './controllers/schedule';
11 | import initGraphQL from "./graphql";
12 |
13 | const app = new Koa();
14 | app.use(koaBody());
15 |
16 | // 错误日志记录
17 | log4js.configure({
18 | appenders: {
19 | globallog: {
20 | type: 'file',
21 | filename: './logs/globallog.log'
22 | }
23 | },
24 | categories: {
25 | default: {
26 | appenders: ['globallog'],
27 | level: 'debug'
28 | }
29 | }
30 | });
31 | const logger = log4js.getLogger('globallog');
32 |
33 | ErrorHander.init(app, logger);
34 | AnalysicsHander.init(app);
35 |
36 | // 初始化路由
37 | controller.init(app);
38 | // 初始化 graphql
39 | initGraphQL(app)
40 | // 静态资源目录
41 | app.use(serve('client'));
42 |
43 | // eslint-disable-next-line no-console
44 | console.log(`server is running at : http://localhost:${config.serverPort}`);
45 |
46 | // 全局异常捕获
47 | process.on('uncaughtException', err => {
48 | logger.error(JSON.stringify(err));
49 | });
50 |
51 |
52 | // 导出给 jest 测试
53 | module.exports = app.listen(config.serverPort);
54 |
--------------------------------------------------------------------------------
/src/nodeuii/config/index.ts:
--------------------------------------------------------------------------------
1 | const mongoDBHost =
2 | process.env.BUILD_ENV === 'docker'
3 | ? 'mongodb://database/test'
4 | : 'mongodb://localhost/test';
5 |
6 | export default {
7 | spiderDomain: 'http://zw.cdzj.chengdu.gov.cn',
8 | spiderPriceDomain:'https://cd.lianjia.com',
9 | serverPort: 8082,
10 | // 和 docker-compose 里的 mongo 容器相对应
11 | databaseUrl: mongoDBHost
12 | };
13 |
--------------------------------------------------------------------------------
/src/nodeuii/controllers/index.ts:
--------------------------------------------------------------------------------
1 | import Router from 'koa-router';
2 | import * as Koa from 'koa';
3 | import fs from 'fs';
4 | // eslint-disable-next-line import/no-extraneous-dependencies
5 | import path from "path";
6 | import houseModel from '../models/houseModel';
7 | import spider from '../utils/spiderHelper';
8 |
9 | const router = new Router();
10 |
11 | router
12 | .get(
13 | '/initspider',
14 | async (ctx): Promise => {
15 | const {
16 | query: { pageStart, pageEnd }
17 | } = ctx.request;
18 |
19 | const pageStartNumber = Number.parseInt(pageStart, 10);
20 | const pageEndNumber = Number.parseInt(pageEnd, 10);
21 | const result = await spider.initspider(pageStartNumber, pageEndNumber);
22 | ctx.body = result;
23 | }
24 | )
25 | .get(
26 | '/getMongoData',
27 | async (ctx): Promise => {
28 | let result = [];
29 | if (process.env.NODE_ENV === 'test') {
30 | result = [
31 | {
32 | _id: '8493C6779815042CE053AC1D15D7580C',
33 | area: '温江区',
34 | name: '明信城',
35 | number: 388,
36 | beginTime: '2019-03-22 09:00:00',
37 | endTime: '2019-03-24 18:00:00',
38 | status: '正在报名',
39 | price:0,
40 | __v: 0
41 | }
42 | ];
43 | } else {
44 | result = await houseModel.find();
45 | }
46 | ctx.body = result;
47 | }
48 | )
49 | .get(
50 | '/spiderPage',
51 | async (ctx): Promise => {
52 | const {
53 | query: { pageNo }
54 | } = ctx.request;
55 | const result = await spider.spiderPage(pageNo);
56 | ctx.body = result;
57 | }
58 | )
59 | .get(
60 | '/initSpiderPrice',
61 | async (ctx): Promise => {
62 | const result = await spider.initSpiderPrice();
63 | ctx.body = result;
64 | }
65 | )
66 | // 支持 browserRouter
67 | .get(/\/20[1-9][0-9]/, ctx => {
68 | const file = fs.readFileSync(path.join('client/index.html'));
69 | ctx.set('Content-Type', 'text/html; charset=utf-8');
70 | ctx.body = file;
71 | });
72 |
73 | export default {
74 | init(app: Koa): void {
75 | app.use(router.routes()).use(router.allowedMethods());
76 | }
77 | };
78 |
--------------------------------------------------------------------------------
/src/nodeuii/controllers/schedule.ts:
--------------------------------------------------------------------------------
1 | import * as schedule from 'node-schedule';
2 | import log4js from 'log4js';
3 | import houseModel from '../models/houseModel';
4 | import { createRequestPromise } from '../utils/spiderHelper';
5 |
6 | const logger = log4js.getLogger('globallog');
7 |
8 | // 定时器middleware,每隔15分钟爬一次
9 | const runEvery15Minute = async (): Promise => {
10 | schedule.scheduleJob(
11 | '*/15 * * * *',
12 | async (): Promise => {
13 | const pageList = await Promise.all([
14 | createRequestPromise(1),
15 | createRequestPromise(2),
16 | createRequestPromise(3)
17 | ]);
18 | const page = [...pageList[0], ...pageList[1], ...pageList[2]];
19 | const newNumber = await new Promise(
20 | (resolve): void => {
21 | let newDataNumber = 0;
22 | let i = 0;
23 | page.forEach(
24 | (item): void => {
25 | houseModel.add(item).then(
26 | (isSuccess): void => {
27 | i += 1;
28 | if (isSuccess) {
29 | newDataNumber += 1;
30 | }
31 | if (i === page.length - 1) {
32 | resolve(newDataNumber);
33 | }
34 | }
35 | );
36 | }
37 | );
38 | }
39 | );
40 | logger.info(`抓取数据${page.length}条,新数据${newNumber}条。`);
41 | }
42 | );
43 | };
44 |
45 | // 每15分钟自动抓取前三页数据(房协网一个时间点不可能同时发布30套房源)
46 | runEvery15Minute();
47 |
--------------------------------------------------------------------------------
/src/nodeuii/graphql/index.ts:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from 'apollo-server-koa';
2 | import * as Koa from 'koa';
3 | import {readFileSync} from 'fs'
4 | // eslint-disable-next-line import/no-extraneous-dependencies
5 | import { join } from "path";
6 | import resolvers from "./resolvers";
7 |
8 | const typeDefs = readFileSync(join(__dirname,'./typeDefs.graphql'), 'UTF-8')
9 |
10 | function initGraphQL(app: Koa): void {
11 | const server = new ApolloServer({ typeDefs, resolvers });
12 | server.applyMiddleware({ app });
13 | }
14 |
15 | export default initGraphQL;
16 |
--------------------------------------------------------------------------------
/src/nodeuii/graphql/resolvers/Mutation.ts:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | }
4 |
--------------------------------------------------------------------------------
/src/nodeuii/graphql/resolvers/Query.ts:
--------------------------------------------------------------------------------
1 | // import { ObjectID } from 'mongodb'
2 | import spider from '../../utils/spiderHelper';
3 | import houseModel from '../../models/houseModel';
4 | import analyticsModel from '../../models/analyticsModel';
5 |
6 | interface Iyear {
7 | year: number;
8 | }
9 |
10 | interface Ipvs {
11 | routerName: string;
12 | }
13 |
14 | export default {
15 | // 和 type Query 中的 allHouses 对应
16 | allHouses: async (
17 | _parent: never, // 不使用第一个变量
18 | args: Iyear
19 | ): Promise => {
20 | let query = {};
21 | if (args.year !== 0) {
22 | const reg = new RegExp(`^${args.year}`);
23 | query = { beginTime: reg };
24 | }
25 |
26 | let allHouses;
27 | if (process.env.NODE_ENV === 'test') {
28 | allHouses = [
29 | {
30 | _id: '8493C6779815042CE053AC1D15D7580C',
31 | area: '温江区',
32 | name: '明信城',
33 | number: 388,
34 | beginTime: '2019-03-22 09:00:00',
35 | endTime: '2019-03-24 18:00:00',
36 | status: '正在报名',
37 | price:0,
38 | __v: 0
39 | }
40 | ];
41 | } else {
42 | allHouses = await houseModel.find(query);
43 | }
44 | return allHouses;
45 | },
46 | housePrice: async (
47 | _parent: never, // 不使用第一个变量
48 | args: {houseName:string}):Promise => spider.spiderHousePrice(args.houseName),
49 | spiderPageOne: async () => spider.spiderPage(),
50 | pvs: async (
51 | _parent: never, // 不使用第一个变量
52 | args: Ipvs
53 | ): Promise => {
54 | let analytics;
55 | if (process.env.NODE_ENV === 'test') {
56 | analytics = {
57 | length: 7864
58 | };
59 | } else {
60 | analytics = await analyticsModel.find(args);
61 | }
62 | return analytics.length;
63 | }
64 | };
65 |
--------------------------------------------------------------------------------
/src/nodeuii/graphql/resolvers/Type.ts:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | }
4 |
--------------------------------------------------------------------------------
/src/nodeuii/graphql/resolvers/index.ts:
--------------------------------------------------------------------------------
1 | import Query from "./Query";
2 | // import Mutation from "./Mutation";
3 | // import Type from "./Type";
4 |
5 | export default {
6 | Query,
7 | // Mutation,
8 | // ...Type
9 | }
10 |
--------------------------------------------------------------------------------
/src/nodeuii/graphql/typeDefs.graphql:
--------------------------------------------------------------------------------
1 | type House {
2 | _id: ID
3 | area: String
4 | name: String
5 | number: Int
6 | beginTime: String
7 | endTime: String
8 | status: String
9 | price: Int
10 | }
11 |
12 | type PageOneArray {
13 | successArray: [House]
14 | allLength: Int
15 | }
16 |
17 | type Query {
18 | allHouses(year: Int): [House]
19 | housePrice(houseName:String): Float
20 | spiderPageOne: PageOneArray
21 | pvs(routerName: String): Int
22 | }
23 |
--------------------------------------------------------------------------------
/src/nodeuii/middleware/AnalysicsHander.ts:
--------------------------------------------------------------------------------
1 | import * as Koa from 'koa';
2 | import log4js from 'log4js';
3 | import analyticsModel from '../models/analyticsModel';
4 |
5 | const logger = log4js.getLogger('globallog');
6 |
7 | const analyticsHander = {
8 | init(app: Koa): void {
9 | // 捕获 请求
10 | app.use(async (ctx: Koa.Context, next: Koa.Next) => {
11 | logger.info(ctx.req.url);
12 | if (ctx.method !== 'OPTIONS') {
13 | // graphql 请求
14 | if (ctx.request.url === '/graphql' && ctx.request.body.query) {
15 | const queryString: string = ctx.request.body.query.replace(
16 | /[\s|\n]/g,
17 | ''
18 | );
19 | const matchedArray = queryString.match(/(?<={)\w+/);
20 | const routerName = matchedArray == null ? '' : matchedArray[0];
21 | analyticsModel.add({ routerName });
22 | } else {
23 | analyticsModel.add({ routerName: ctx.request.path.substr(1) });
24 | }
25 | }
26 | await next();
27 | });
28 | }
29 | };
30 |
31 | export default analyticsHander;
32 |
--------------------------------------------------------------------------------
/src/nodeuii/middleware/ErrorHander.ts:
--------------------------------------------------------------------------------
1 | import * as Koa from 'koa';
2 | import { Logger } from 'log4js';
3 |
4 | const ErrorHander = {
5 | init(app: Koa, logger: Logger): void {
6 | // 捕获内部错误
7 | app.use(async (ctx: Koa.Context, next: Koa.Next) => {
8 | try {
9 | await next();
10 | } catch (e) {
11 | logger.error(JSON.stringify(e));
12 | ctx.status = 500;
13 | ctx.body = '内部错误';
14 | }
15 | });
16 | // 捕获 404 错误
17 | app.use(async (ctx: Koa.Context, next: Koa.Next) => {
18 | await next();
19 | if (ctx.status === 404 && ctx.url !== '/404.html') {
20 | ctx.redirect('/404.html');
21 | }
22 | });
23 | }
24 | };
25 |
26 | export default ErrorHander;
27 |
--------------------------------------------------------------------------------
/src/nodeuii/models/analyticsModel.ts:
--------------------------------------------------------------------------------
1 | import log4js from 'log4js';
2 | import { FilterQuery } from 'mongoose';
3 | import DbHelper from '../utils/dbHelper';
4 |
5 | const mongoose = DbHelper.connect();
6 | const logger = log4js.getLogger('globallog');
7 |
8 | // 创建数据库
9 | const analyticsSchema = new mongoose.Schema({
10 | routerName: String,
11 | createdTime: { type: Date, default: Date.now }
12 | });
13 | // 创建表
14 | const AnalyticsCol = mongoose.model('analytics', analyticsSchema);
15 |
16 | const analyticsModel = {
17 | async add(item: cdFang.Ianalytics): Promise {
18 | const house = new AnalyticsCol(item);
19 | house.save(err => {
20 | if (err) {
21 | logger.error(JSON.stringify(err));
22 | }
23 | });
24 | return item;
25 | },
26 |
27 | find(query: FilterQuery): cdFang.Ianalytics[] {
28 | return AnalyticsCol.find(query, err => {
29 | if (err) {
30 | logger.error(JSON.stringify(err));
31 | }
32 | });
33 | }
34 | };
35 |
36 | export default analyticsModel;
37 |
--------------------------------------------------------------------------------
/src/nodeuii/models/houseModel.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import log4js from 'log4js';
3 | import { FilterQuery } from 'mongoose';
4 | import DbHelper from '../utils/dbHelper';
5 |
6 | const mongoose = DbHelper.connect();
7 | const logger = log4js.getLogger('globallog');
8 |
9 | // 创建数据库
10 | const HouseSchema = new mongoose.Schema({
11 | _id: String,
12 | area: String,
13 | name: String,
14 | number: Number,
15 | beginTime: String,
16 | endTime: String,
17 | status: String,
18 | price: Number
19 | });
20 | // 创建表
21 | const HouseCol = mongoose.model('house', HouseSchema);
22 |
23 | const houseModel = {
24 | /**
25 | *
26 | * 新增一个房源信息,若存在,则更新
27 | * @param {cdFang.IhouseData} item
28 | * @returns {(Promise)}
29 | */
30 | async add(item: cdFang.IhouseData): Promise {
31 | let result: boolean | cdFang.IhouseData = item;
32 | const findItem = await this.find({ _id: item._id });
33 | if (findItem.length > 0) {
34 | // 如果状态变更执行更新操作
35 | if (findItem[0].status !== item.status) {
36 | this.findOneAndUpdate(item);
37 | } else {
38 | result = false;
39 | }
40 | } else {
41 | const house = new HouseCol(item);
42 | result = await new Promise(resolve => {
43 | house.save(err => {
44 | if (err) {
45 | logger.error(JSON.stringify(err));
46 | resolve(false);
47 | }
48 | });
49 | });
50 | }
51 | return result;
52 | },
53 |
54 | /**
55 | *
56 | * 批量插入房源信息
57 | * @param {cdFang.IhouseData[]} array
58 | * @returns {Promise}
59 | */
60 | async addMany(array: cdFang.IhouseData[]): Promise {
61 | const newArray: cdFang.IhouseData[] = [];
62 | // eslint-disable-next-line no-restricted-syntax
63 | for await (const item of array) {
64 | const findItem = await this.find({ _id: item._id });
65 | if (findItem.length === 0) {
66 | newArray.push(item);
67 | }
68 | }
69 |
70 | HouseCol.create(
71 | newArray,
72 | (err): void => {
73 | if (err) {
74 | logger.error(JSON.stringify(err));
75 | }
76 | }
77 | );
78 | },
79 |
80 | /**
81 | *
82 | * 更新一个房源信息
83 | * @param {cdFang.IhouseData} item
84 | */
85 | findOneAndUpdate(item: Partial,query: FilterQuery = { _id: item._id }): void {
86 | HouseCol.findOneAndUpdate(
87 | query,
88 | item,
89 | null,
90 | (err) => {
91 | if (err) {
92 | logger.error(JSON.stringify(err));
93 | }
94 | }
95 | );
96 | },
97 |
98 | update(query: FilterQuery,item: Partial): void {
99 | HouseCol.updateOne(
100 | query,
101 | item,
102 | null,
103 | (err) => {
104 | if (err) {
105 | logger.error(JSON.stringify(err));
106 | }
107 | }
108 | );
109 | },
110 |
111 | /**
112 | *
113 | *
114 | * @param {object} [query]
115 | * @returns {cdFang.IhouseData[]}
116 | */
117 | find(query: FilterQuery): cdFang.IhouseData[] {
118 | return HouseCol.find(query, err => {
119 | if (err) {
120 | logger.error(JSON.stringify(err));
121 | }
122 | });
123 | }
124 | };
125 |
126 | export default houseModel;
127 |
--------------------------------------------------------------------------------
/src/nodeuii/utils/dbHelper.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import mongoose from 'mongoose';
3 | import config from '../config/index';
4 |
5 | let connectTimeOut: NodeJS.Timeout;
6 |
7 | const DbHelper = {
8 | connectTimes: 8,
9 | connect(): mongoose.Mongoose {
10 | if (process.env.NODE_ENV !== 'test') {
11 | DbHelper.mongooseConnect();
12 | }
13 |
14 | const db = mongoose.connection;
15 | db.once('error', () => {
16 | console.error('连接 mongodb 失败。');
17 | connectTimeOut = setInterval(() => {
18 | if (DbHelper.connectTimes > 0) {
19 | console.log(`正在重连 mongodb,剩余次数 ${DbHelper.connectTimes}。`);
20 | DbHelper.connectTimes -= 1;
21 | DbHelper.mongooseConnect();
22 | } else {
23 | console.log('重连 mongodb 失败。');
24 | clearTimeout(connectTimeOut);
25 | }
26 | }, 8000);
27 | });
28 | db.on('open', () => {
29 | console.log('连接 mongodb 成功。');
30 | clearTimeout(connectTimeOut);
31 | });
32 | // 单例模式
33 | DbHelper.connect = () => mongoose;
34 | return mongoose;
35 | },
36 | mongooseConnect(): void {
37 | mongoose.connect(config.databaseUrl, {
38 | useNewUrlParser: true,
39 | // 弃用警告 https://mongoosejs.com/docs/deprecations.html#-findandmodify-
40 | useFindAndModify: false
41 | });
42 | }
43 | };
44 |
45 | export default DbHelper;
46 |
--------------------------------------------------------------------------------
/src/nodeuii/utils/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 |
3 | export function transformArray(array: string[][]): cdFang.IhouseData[] {
4 | const result = array.map(
5 | (item): cdFang.IhouseData => ({
6 | _id: item[0],
7 | area: item[2],
8 | name: item[3],
9 | number: Number.parseInt(item[6], 10),
10 | beginTime: item[8],
11 | endTime: item[9],
12 | status: item[13],
13 | price: undefined
14 | })
15 | );
16 | return result;
17 | }
18 |
--------------------------------------------------------------------------------
/src/nodeuii/utils/spiderHelper.ts:
--------------------------------------------------------------------------------
1 | import * as request from 'superagent';
2 | import * as cheerio from 'cheerio';
3 | import * as util from './index';
4 | import houseModel from '../models/houseModel';
5 | import config from '../config';
6 |
7 | interface Ipage {
8 | successArray: cdFang.IhouseData[];
9 | allLength: number;
10 | }
11 |
12 | export const createRequestPromise = (
13 | pageNo: number
14 | ): Promise => new Promise(
15 | (resolve): void => {
16 | request
17 | .post(
18 | `${config.spiderDomain}/lottery/accept/projectList?pageNo=${pageNo}`
19 | )
20 | .end(
21 | (err, result): void => {
22 | if (err) {
23 | return;
24 | }
25 | const $ = cheerio.load(result.text);
26 | const trList: string[][] = [];
27 | $('#_projectInfo>tr').each(
28 | (i, tr): void => {
29 | const tdList: string[] = [];
30 | $(tr)
31 | .find('td')
32 | .each(
33 | (j, td): void => {
34 | tdList.push($(td).text());
35 | }
36 | );
37 | trList.push(tdList);
38 | }
39 | );
40 | resolve(util.transformArray(trList));
41 | }
42 | );
43 | }
44 | );
45 |
46 | const initspider = async (pageStart: number, pageEnd: number):Promise => {
47 | const allPromises = [];
48 | for (let i = pageStart; i <= pageEnd; i += 1) {
49 | allPromises.push(createRequestPromise(i));
50 | }
51 |
52 | const result = await Promise.all(allPromises).then(
53 | (posts: cdFang.IhouseData[][]): cdFang.IhouseData[] => {
54 | houseModel.addMany(posts[0]);
55 | return posts[0];
56 | }
57 | );
58 | return result;
59 | };
60 |
61 | const spiderPage = async (pageNo = 1): Promise => {
62 | const page: cdFang.IhouseData[] = await createRequestPromise(pageNo);
63 | const promises = page.map(
64 | (item): Promise =>
65 | new Promise(
66 | (resolve): void => {
67 | resolve(houseModel.add(item));
68 | }
69 | )
70 | );
71 | const successArray = await Promise.all(promises)
72 | .then(
73 | posts => posts.filter((item): boolean => !!item) as cdFang.IhouseData[]
74 | )
75 | .catch(() => []);
76 | return {
77 | successArray,
78 | allLength: page.length
79 | };
80 | };
81 |
82 | const spiderHousePrice = async (houseName:string): Promise => {
83 | const housePrice = new Promise((resolve)=>{
84 | request
85 | .get(
86 | `https://cd.lianjia.com/xiaoqu/rs${encodeURIComponent(houseName)}/`
87 | )
88 | .end(
89 | (err, result): void => {
90 | if (err) {
91 | return;
92 | }
93 | const $ = cheerio.load(result.text);
94 | let price;
95 | if($('.totalPrice').length === 1){
96 | price = Number.parseFloat($('.totalPrice').children('span').text()) || 0
97 | } else {
98 | price = 0
99 | }
100 | resolve(price)
101 | }
102 | );
103 | })
104 | return housePrice;
105 | };
106 |
107 | const initSpiderPrice = async (): Promise => {
108 | const housesNotExist = await houseModel.find({price:{$exists:false}})
109 | const housesNotPrice = await houseModel.find({price:0})
110 | const needSpiderHouse = housesNotExist.concat(housesNotPrice)
111 | needSpiderHouse.forEach(house=>{
112 | request
113 | .get(
114 | `${config.spiderPriceDomain}/xiaoqu/rs${encodeURIComponent(house.name)}/`
115 | )
116 | .end(
117 | (err, result): void => {
118 | if (err) {
119 | return;
120 | }
121 | const $ = cheerio.load(result.text);
122 | let price;
123 | if($('.totalPrice').length === 1){
124 | price = Number.parseFloat($('.totalPrice').children('span').text()) || 0
125 | } else {
126 | price = 0
127 | }
128 | // eslint-disable-next-line no-underscore-dangle
129 | houseModel.update({_id:house._id},{price})
130 | }
131 | );
132 | })
133 | return '后台操作进行中';
134 | };
135 |
136 | export default {
137 | initspider,
138 | initSpiderPrice,
139 | spiderPage,
140 | spiderHousePrice
141 | };
142 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | // https://www.tslang.cn/docs/handbook/compiler-options.html
2 | {
3 | "compilerOptions": {
4 | // 配置编译目标代码的版本标准
5 | "target": "es5",
6 | "lib": [
7 | "dom",
8 | "dom.iterable",
9 | "esnext"
10 | ],
11 | "paths": {
12 | "*": [
13 | "types/*"
14 | ]
15 | },
16 | "allowJs": true,
17 | "esModuleInterop": true, //设置es模块化导出commonjs模块化代码
18 | "allowSyntheticDefaultImports": true,
19 | "strict": true,
20 | // 配置编译目标使用的模块化标准
21 | "module": "esnext",
22 | //https://github.com/ant-design/ant-design/issues/8642 配置解析模块的模式
23 | "moduleResolution": "node",
24 | "jsx": "react"
25 | },
26 | // 影响 webpack 构建
27 | "include": [
28 | "./src",
29 | "./build",
30 | "./types",
31 | "./__tests__"
32 | ],
33 | "exclude": [
34 | "./node_modules",
35 | "./__mocks__"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/types/cdFang.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace cdFang {
2 | interface IhouseData {
3 | _id: string;
4 | __v?: number;
5 | area: string;
6 | beginTime: string;
7 | endTime: string;
8 | name: string;
9 | number: number;
10 | status: string;
11 | price?: number;
12 | }
13 |
14 | interface Ianalytics {
15 | routerName: string;
16 | createdTime?: Date;
17 | }
18 |
19 | // 和client constants 目录保持一致
20 | interface IareaHouse {
21 | 区域: string;
22 | 房源数: number;
23 | }
24 |
25 | interface IareaBuilder {
26 | 区域: string;
27 | 楼盘数: number;
28 | }
29 |
30 | interface IcircleItem {
31 | date: string;
32 | item: string;
33 | number: number;
34 | percent: number;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require */
2 | switch (process.env.NODE_ENV) {
3 | case 'production':
4 | if (process.env.BUILD_ENV === 'analysis') {
5 | module.exports = require('./build/webpack.analysis.config');
6 | } else {
7 | module.exports = require('./build/webpack.prod.config');
8 | }
9 | break;
10 | default:
11 | module.exports = require('./build/webpack.dev.config');
12 | }
13 |
--------------------------------------------------------------------------------