├── .github └── workflows │ └── deploy.yaml ├── .gitignore ├── README.md ├── config ├── env.js ├── jest │ ├── cssTransform.js │ └── fileTransform.js ├── modules.js ├── paths.js ├── pnpTs.js ├── webpack.config.js └── webpackDevServer.config.js ├── package-lock.json ├── package.json ├── public ├── MP_verify_UDYcE1TXOkawMeA0.txt ├── favicon.ico ├── index.html ├── logo.jpg ├── manifest.json └── robots.txt ├── scripts ├── build-origin.js ├── build.js ├── start.js ├── test.js └── upload-oss.js └── src ├── App.css ├── App.js ├── App.test.js ├── Map.js ├── Tag.css ├── Tag.js ├── data ├── area.json ├── countries.json └── overall.json ├── feiyan.jpeg ├── feiyan.png ├── index.css ├── index.js ├── logo.jpg ├── serviceWorker.js └── setupTests.js /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: deploy to aliyun oss 2 | 3 | on: 4 | push: 5 | barches: 6 | - master 7 | # schedule: 8 | # - cron: '*/15 * * * *' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | env: 14 | CI: true 15 | ACCESS_KEY: ${{ secrets.OSS_KEY_ID }} 16 | ACCESS_SECRET: ${{ secrets.OSS_KEY_SECRET }} 17 | 18 | steps: 19 | # 切代码到 runner 20 | - uses: actions/checkout@v1 21 | with: 22 | submodules: true 23 | # 使用 node:10 24 | - name: use Node.js 10.x 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 10.x 28 | - name: npm install 29 | run: npm ci 30 | - name: build data 31 | run: node scripts/build-origin.js 32 | - name: npm run build 33 | run: npm run build 34 | - name: cp static files to aliyun 35 | run: node scripts/upload-oss.js 36 | 37 | # 设置阿里云OSS的 id/secret,存储到 github 的 secrets 中 38 | # - name: setup aliyun oss 39 | # uses: manyuanrong/setup-ossutil@v1.0 40 | # with: 41 | # endpoint: oss-cn-beijing.aliyuncs.com 42 | # access-key-id: ${{ secrets.OSS_KEY_ID }} 43 | # access-key-secret: ${{ secrets.OSS_KEY_SECRET }} 44 | # - name: 删除冗余文件 45 | # run: ossutil rm oss://shanyue-ncov/static -rf 46 | # if: github.event_name == 'schedule' 47 | # - name: update ossutil 48 | # run: ossutil update -f 49 | # - name: cp static files to aliyun 50 | # run: ossutil cp -r build/static/ oss://shanyue-ncov/static/ --update 51 | # - name: cp all files to aliyun 52 | # run: ossutil cp -r build/ oss://shanyue-ncov/ --update --only-current-dir --exclude precache* 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 新型冠状病毒疫情实时动态省市地图 2 | 3 | > 武汉加油,众志成城,共抗疫情 4 | 5 | 最近所有人的注意力都集中在武汉的疫情上,在 [丁香园肺炎疫情实时动态](https://3g.dxy.cn/newh5/view/pneumonia?from=timeline) 中提供了疫情地图及实时权威新闻,但其中并没有省市地图。 6 | 7 | 我在每天闭门不出为国家做贡献的同时,对疫情地图做了简单的扩展,能够显示某个省的疫情以及消息播报,旨在帮助大家更加直观了解自己家乡的情况。最后提醒大家尽量不出门,出门后必戴口罩。 8 | 9 | ## Todo 10 | 11 | > **目前打包后加载总体积 gzip 后小于 300KB,每次自动部署时间平均在一分半左右** 12 | 13 | + [x] 需求,使用脚本自动拉取丁香园的最新数据 14 | + [x] 部署,添加 CICD,每半个小时部署一次 (github actions) 15 | + [x] 优化,配置 cdn/https/http2,优化网站加载速度 16 | + [x] 需求,添加省级地图 17 | + [x] 优化部署策略,避免白屏问题 18 | + [x] 添加信息汇总 19 | + [x] 优化打包策略,对首屏地图组件进行按需加载,加速首屏速度 20 | + [x] 运营,对省级信息生成独立路由,便于分享及传播 21 | + [x] Bugfix,修复中国各省市地级市简称与全称对应关系,如恩施,阿坝,西双版纳等 22 | + [x] Bugfix,修复湖北天门,潜江,仙桃为县级市的问题 23 | + [x] Bugfix,修复西藏,重庆多拼音问题 24 | + [x] Bugfix,修复客户端路由不匹配首页路径时的404行为,在微信浏览器中可能会遇到问题 (使用 alicdn rewrite 解决) 25 | + [x] Bugfix,解决 swr 带来的 Request Queue 时间过长的问题 26 | + [x] 需求,添加实时播报以及省级的实时播报 27 | + [x] 优化打包策略,由于丁香园数据常变,对丁香园数据抽离打包,强化永久缓存并加速部署时间 28 | + [x] 优化,添加 webpack bundle analyze 29 | + [x] 优化打包策略,对 echarts 各组件进行按需加载 (目前 gzip 后总体积不超过 200KB) 30 | + [x] 优化打包粗略,对 echarts 中省级地图数据进行按需加载 31 | + [x] 优化,压缩首图,优化网站加载速度 32 | + [x] 优化部署策略,对 OSS 上静态资源增量(以前是全量)配置永久缓存,加速部署时间 33 | + [x] 优化部署策略,对 OSS 上静态资源增量传输(类似 rsync),加速部署时间 34 | + [ ] 优化部署策略,重复利用 npm cache,加快 npm install 速度 35 | + [ ] 优化部署/打包策略,加快 npm run build 速度 36 | + [ ] 运营,分享时生成图片,利于分享及传播 37 | + [ ] 运营,添加微信分享的 API 38 | 39 | ## 快速开始 40 | 41 | ``` bash 42 | # 获取数据 43 | $ node scripts/build-origin.js 44 | 45 | $ npm start 46 | ``` 47 | 48 | ## 部署 49 | 50 | 使用 `github actions` 与 `alioss` 自动部署,使用 `github actions` 的定时任务每半个小时部署一次(为了获取最新数据)。 51 | 52 | 关于部署可以参考以下两篇文章 53 | 54 | + [使用 AliOSS 部署及加速你的静态网站](https://github.com/shfshanyue/you-dont-need-vps/blob/master/deploy-fe-with-alioss.md) 55 | + [github actions 持续集成简介及实践](https://github.com/shfshanyue/you-dont-need-vps/blob/master/github-action-guide.md) 56 | 57 | ## 数据来源 58 | 59 | 数据爬自丁香园,使用脚本 `build-origin.js` 获取数据。数据每半个小时爬取一次,直接注入到前端,因此对丁香园造成的压力很小。 60 | 61 | 另外,如果你需要更详细的数据,可以参考项目 [BlankerL/DXY-2019-nCoV-Crawler](https://github.com/BlankerL/DXY-2019-nCoV-Crawler)。 62 | 63 | ## 截图 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | 7 | // Make sure that including paths.js after env.js will read .env variables. 8 | delete require.cache[require.resolve('./paths')]; 9 | 10 | const NODE_ENV = process.env.NODE_ENV; 11 | if (!NODE_ENV) { 12 | throw new Error( 13 | 'The NODE_ENV environment variable is required but was not specified.' 14 | ); 15 | } 16 | 17 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 18 | const dotenvFiles = [ 19 | `${paths.dotenv}.${NODE_ENV}.local`, 20 | `${paths.dotenv}.${NODE_ENV}`, 21 | // Don't include `.env.local` for `test` environment 22 | // since normally you expect tests to produce the same 23 | // results for everyone 24 | NODE_ENV !== 'test' && `${paths.dotenv}.local`, 25 | paths.dotenv, 26 | ].filter(Boolean); 27 | 28 | // Load environment variables from .env* files. Suppress warnings using silent 29 | // if this file is missing. dotenv will never modify any environment variables 30 | // that have already been set. Variable expansion is supported in .env files. 31 | // https://github.com/motdotla/dotenv 32 | // https://github.com/motdotla/dotenv-expand 33 | dotenvFiles.forEach(dotenvFile => { 34 | if (fs.existsSync(dotenvFile)) { 35 | require('dotenv-expand')( 36 | require('dotenv').config({ 37 | path: dotenvFile, 38 | }) 39 | ); 40 | } 41 | }); 42 | 43 | // We support resolving modules according to `NODE_PATH`. 44 | // This lets you use absolute paths in imports inside large monorepos: 45 | // https://github.com/facebook/create-react-app/issues/253. 46 | // It works similar to `NODE_PATH` in Node itself: 47 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 48 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 49 | // Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. 50 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 51 | // We also resolve them to make sure all tools using them work consistently. 52 | const appDirectory = fs.realpathSync(process.cwd()); 53 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 54 | .split(path.delimiter) 55 | .filter(folder => folder && !path.isAbsolute(folder)) 56 | .map(folder => path.resolve(appDirectory, folder)) 57 | .join(path.delimiter); 58 | 59 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 60 | // injected into the application via DefinePlugin in Webpack configuration. 61 | const REACT_APP = /^REACT_APP_/i; 62 | 63 | function getClientEnvironment(publicUrl) { 64 | const raw = Object.keys(process.env) 65 | .filter(key => REACT_APP.test(key)) 66 | .reduce( 67 | (env, key) => { 68 | env[key] = process.env[key]; 69 | return env; 70 | }, 71 | { 72 | // Useful for determining whether we’re running in production mode. 73 | // Most importantly, it switches React into the correct mode. 74 | NODE_ENV: process.env.NODE_ENV || 'development', 75 | // Useful for resolving the correct path to static assets in `public`. 76 | // For example, . 77 | // This should only be used as an escape hatch. Normally you would put 78 | // images into the `src` and `import` them in code to get their paths. 79 | PUBLIC_URL: publicUrl, 80 | } 81 | ); 82 | // Stringify all values so we can feed into Webpack DefinePlugin 83 | const stringified = { 84 | 'process.env': Object.keys(raw).reduce((env, key) => { 85 | env[key] = JSON.stringify(raw[key]); 86 | return env; 87 | }, {}), 88 | }; 89 | 90 | return { raw, stringified }; 91 | } 92 | 93 | module.exports = getClientEnvironment; 94 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const camelcase = require('camelcase'); 5 | 6 | // This is a custom Jest transformer turning file imports into filenames. 7 | // http://facebook.github.io/jest/docs/en/webpack.html 8 | 9 | module.exports = { 10 | process(src, filename) { 11 | const assetFilename = JSON.stringify(path.basename(filename)); 12 | 13 | if (filename.match(/\.svg$/)) { 14 | // Based on how SVGR generates a component name: 15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 16 | const pascalCaseFilename = camelcase(path.parse(filename).name, { 17 | pascalCase: true, 18 | }); 19 | const componentName = `Svg${pascalCaseFilename}`; 20 | return `const React = require('react'); 21 | module.exports = { 22 | __esModule: true, 23 | default: ${assetFilename}, 24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 25 | return { 26 | $$typeof: Symbol.for('react.element'), 27 | type: 'svg', 28 | ref: ref, 29 | key: null, 30 | props: Object.assign({}, props, { 31 | children: ${assetFilename} 32 | }) 33 | }; 34 | }), 35 | };`; 36 | } 37 | 38 | return `module.exports = ${assetFilename};`; 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /config/modules.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | const chalk = require('react-dev-utils/chalk'); 7 | const resolve = require('resolve'); 8 | 9 | /** 10 | * Get additional module paths based on the baseUrl of a compilerOptions object. 11 | * 12 | * @param {Object} options 13 | */ 14 | function getAdditionalModulePaths(options = {}) { 15 | const baseUrl = options.baseUrl; 16 | 17 | // We need to explicitly check for null and undefined (and not a falsy value) because 18 | // TypeScript treats an empty string as `.`. 19 | if (baseUrl == null) { 20 | // If there's no baseUrl set we respect NODE_PATH 21 | // Note that NODE_PATH is deprecated and will be removed 22 | // in the next major release of create-react-app. 23 | 24 | const nodePath = process.env.NODE_PATH || ''; 25 | return nodePath.split(path.delimiter).filter(Boolean); 26 | } 27 | 28 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 29 | 30 | // We don't need to do anything if `baseUrl` is set to `node_modules`. This is 31 | // the default behavior. 32 | if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { 33 | return null; 34 | } 35 | 36 | // Allow the user set the `baseUrl` to `appSrc`. 37 | if (path.relative(paths.appSrc, baseUrlResolved) === '') { 38 | return [paths.appSrc]; 39 | } 40 | 41 | // If the path is equal to the root directory we ignore it here. 42 | // We don't want to allow importing from the root directly as source files are 43 | // not transpiled outside of `src`. We do allow importing them with the 44 | // absolute path (e.g. `src/Components/Button.js`) but we set that up with 45 | // an alias. 46 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 47 | return null; 48 | } 49 | 50 | // Otherwise, throw an error. 51 | throw new Error( 52 | chalk.red.bold( 53 | "Your project's `baseUrl` can only be set to `src` or `node_modules`." + 54 | ' Create React App does not support other values at this time.' 55 | ) 56 | ); 57 | } 58 | 59 | /** 60 | * Get webpack aliases based on the baseUrl of a compilerOptions object. 61 | * 62 | * @param {*} options 63 | */ 64 | function getWebpackAliases(options = {}) { 65 | const baseUrl = options.baseUrl; 66 | 67 | if (!baseUrl) { 68 | return {}; 69 | } 70 | 71 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 72 | 73 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 74 | return { 75 | src: paths.appSrc, 76 | }; 77 | } 78 | } 79 | 80 | /** 81 | * Get jest aliases based on the baseUrl of a compilerOptions object. 82 | * 83 | * @param {*} options 84 | */ 85 | function getJestAliases(options = {}) { 86 | const baseUrl = options.baseUrl; 87 | 88 | if (!baseUrl) { 89 | return {}; 90 | } 91 | 92 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 93 | 94 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 95 | return { 96 | '^src/(.*)$': '/src/$1', 97 | }; 98 | } 99 | } 100 | 101 | function getModules() { 102 | // Check if TypeScript is setup 103 | const hasTsConfig = fs.existsSync(paths.appTsConfig); 104 | const hasJsConfig = fs.existsSync(paths.appJsConfig); 105 | 106 | if (hasTsConfig && hasJsConfig) { 107 | throw new Error( 108 | 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' 109 | ); 110 | } 111 | 112 | let config; 113 | 114 | // If there's a tsconfig.json we assume it's a 115 | // TypeScript project and set up the config 116 | // based on tsconfig.json 117 | if (hasTsConfig) { 118 | const ts = require(resolve.sync('typescript', { 119 | basedir: paths.appNodeModules, 120 | })); 121 | config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; 122 | // Otherwise we'll check if there is jsconfig.json 123 | // for non TS projects. 124 | } else if (hasJsConfig) { 125 | config = require(paths.appJsConfig); 126 | } 127 | 128 | config = config || {}; 129 | const options = config.compilerOptions || {}; 130 | 131 | const additionalModulePaths = getAdditionalModulePaths(options); 132 | 133 | return { 134 | additionalModulePaths: additionalModulePaths, 135 | webpackAliases: getWebpackAliases(options), 136 | jestAliases: getJestAliases(options), 137 | hasTsConfig, 138 | }; 139 | } 140 | 141 | module.exports = getModules(); 142 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const url = require('url'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebook/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | const envPublicUrl = process.env.PUBLIC_URL; 13 | 14 | function ensureSlash(inputPath, needsSlash) { 15 | const hasSlash = inputPath.endsWith('/'); 16 | if (hasSlash && !needsSlash) { 17 | return inputPath.substr(0, inputPath.length - 1); 18 | } else if (!hasSlash && needsSlash) { 19 | return `${inputPath}/`; 20 | } else { 21 | return inputPath; 22 | } 23 | } 24 | 25 | const getPublicUrl = appPackageJson => 26 | envPublicUrl || require(appPackageJson).homepage; 27 | 28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 29 | // "public path" at which the app is served. 30 | // Webpack needs to know it to put the right 44 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /public/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shfshanyue/2019-ncov/dd01a6e1361eb1eff28b7628592f857d54732214/public/logo.jpg -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "疫情监控", 3 | "name": "疫情监控实时地图 · 地级市", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "https://pic4.zhimg.com/v2-7de8d88befb79738644b55634d2f3fba_xl.jpg", 12 | "type": "image/jpeg", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "https://pic4.zhimg.com/v2-7de8d88befb79738644b55634d2f3fba_xl.jpg", 17 | "type": "image/jpeg", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#f60", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /scripts/build-origin.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const pinyin = require('pinyin') 3 | const fs = require('fs') 4 | const _ = require('lodash') 5 | const province = require('province-city-china/dist/province') 6 | const city = require('province-city-china/dist/data') 7 | 8 | const provinceByName = _.keyBy(province, p => p.name.slice(0, 2)) 9 | const citiesByProvince = _.groupBy(city, 'province') 10 | 11 | const getCitiesByProvince = (name) => { 12 | const provinceName = name.slice(0, 2) 13 | const code = _.get(provinceByName, [provinceName, 'province']) 14 | return citiesByProvince[code] || [] 15 | } 16 | 17 | const loadCountries = async data => { 18 | const countries = data 19 | .match(/window.getListByCountryTypeService2 = (.*?)}catch/)[1] 20 | fs.writeFileSync('./src/data/countries.json', countries) 21 | } 22 | 23 | const loadOverall = async data => { 24 | const overall = data 25 | .match(/window.getStatisticsService = (.*?)}catch/)[1] 26 | fs.writeFileSync('./src/data/overall.json', overall) 27 | } 28 | 29 | const loadCityList = async data => { 30 | const cityList = data 31 | .match(/window.getAreaStat = (.*?)}catch/)[1] 32 | const provinces = JSON.parse(cityList) 33 | const result = JSON.stringify(provinces.map(p => { 34 | if (p.provinceShortName === '陕西') { 35 | p.pinyin = 'shanxi1' 36 | } else if (p.provinceShortName === '重庆') { 37 | p.pinyin = 'chongqing' 38 | } else if (p.provinceShortName === '西藏') { 39 | p.pinyin = 'xizang' 40 | } 41 | const cities = getCitiesByProvince(p.provinceName) 42 | const citiesByName = _.keyBy(cities.reverse(), city => city.name.slice(0, 2)) 43 | return { 44 | pinyin: pinyin(p.provinceShortName, { 45 | style: pinyin.STYLE_NORMAL 46 | }).map(x => x[0]).join(''), 47 | name: p.provinceShortName, 48 | ...p, 49 | cities: p.cities.map(city => { 50 | let fullCityName = city.cityName 51 | const cityName = city.cityName.slice(0, 2) 52 | if (citiesByName[cityName]) { 53 | fullCityName = citiesByName[cityName].name 54 | } 55 | return { 56 | ...city, 57 | fullCityName 58 | } 59 | }) 60 | } 61 | }), null, 2) 62 | fs.writeFileSync('./src/data/area.json', result) 63 | } 64 | 65 | let times = 0 66 | async function request () { 67 | return axios.request('https://3g.dxy.cn/newh5/view/pneumonia').then(({ data: html }) => { 68 | return Promise.all([ 69 | loadCityList(html), 70 | loadOverall(html), 71 | loadCountries(html) 72 | ]) 73 | }).catch(e => { 74 | console.log('Retry') 75 | if (times++ > 1) { 76 | throw e 77 | } 78 | return request() 79 | }) 80 | } 81 | 82 | request().catch(e => { 83 | console.log(e) 84 | process.exit(1) 85 | }) 86 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.BABEL_ENV = 'production'; 5 | process.env.NODE_ENV = 'production'; 6 | 7 | // Makes the script crash on unhandled rejections instead of silently 8 | // ignoring them. In the future, promise rejections that are not handled will 9 | // terminate the Node.js process with a non-zero exit code. 10 | process.on('unhandledRejection', err => { 11 | throw err; 12 | }); 13 | 14 | // Ensure environment variables are read. 15 | require('../config/env'); 16 | 17 | 18 | const path = require('path'); 19 | const chalk = require('react-dev-utils/chalk'); 20 | const fs = require('fs-extra'); 21 | const webpack = require('webpack'); 22 | const configFactory = require('../config/webpack.config'); 23 | const paths = require('../config/paths'); 24 | const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); 25 | const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); 26 | const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); 27 | const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); 28 | const printBuildError = require('react-dev-utils/printBuildError'); 29 | 30 | const measureFileSizesBeforeBuild = 31 | FileSizeReporter.measureFileSizesBeforeBuild; 32 | const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; 33 | const useYarn = fs.existsSync(paths.yarnLockFile); 34 | 35 | // These sizes are pretty large. We'll warn for bundles exceeding them. 36 | const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; 37 | const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; 38 | 39 | const isInteractive = process.stdout.isTTY; 40 | 41 | // Warn and crash if required files are missing 42 | if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { 43 | process.exit(1); 44 | } 45 | 46 | // Generate configuration 47 | const config = configFactory('production'); 48 | 49 | // We require that you explicitly set browsers and do not fall back to 50 | // browserslist defaults. 51 | const { checkBrowsers } = require('react-dev-utils/browsersHelper'); 52 | checkBrowsers(paths.appPath, isInteractive) 53 | .then(() => { 54 | // First, read the current file sizes in build directory. 55 | // This lets us display how much they changed later. 56 | return measureFileSizesBeforeBuild(paths.appBuild); 57 | }) 58 | .then(previousFileSizes => { 59 | // Remove all content but keep the directory so that 60 | // if you're in it, you don't end up in Trash 61 | fs.emptyDirSync(paths.appBuild); 62 | // Merge with the public folder 63 | copyPublicFolder(); 64 | // Start the webpack build 65 | return build(previousFileSizes); 66 | }) 67 | .then( 68 | ({ stats, previousFileSizes, warnings }) => { 69 | if (warnings.length) { 70 | console.log(chalk.yellow('Compiled with warnings.\n')); 71 | console.log(warnings.join('\n\n')); 72 | console.log( 73 | '\nSearch for the ' + 74 | chalk.underline(chalk.yellow('keywords')) + 75 | ' to learn more about each warning.' 76 | ); 77 | console.log( 78 | 'To ignore, add ' + 79 | chalk.cyan('// eslint-disable-next-line') + 80 | ' to the line before.\n' 81 | ); 82 | } else { 83 | console.log(chalk.green('Compiled successfully.\n')); 84 | } 85 | 86 | console.log('File sizes after gzip:\n'); 87 | printFileSizesAfterBuild( 88 | stats, 89 | previousFileSizes, 90 | paths.appBuild, 91 | WARN_AFTER_BUNDLE_GZIP_SIZE, 92 | WARN_AFTER_CHUNK_GZIP_SIZE 93 | ); 94 | console.log(); 95 | 96 | const appPackage = require(paths.appPackageJson); 97 | const publicUrl = paths.publicUrl; 98 | const publicPath = config.output.publicPath; 99 | const buildFolder = path.relative(process.cwd(), paths.appBuild); 100 | printHostingInstructions( 101 | appPackage, 102 | publicUrl, 103 | publicPath, 104 | buildFolder, 105 | useYarn 106 | ); 107 | }, 108 | err => { 109 | const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true'; 110 | if (tscCompileOnError) { 111 | console.log( 112 | chalk.yellow( 113 | 'Compiled with the following type errors (you may want to check these before deploying your app):\n' 114 | ) 115 | ); 116 | printBuildError(err); 117 | } else { 118 | console.log(chalk.red('Failed to compile.\n')); 119 | printBuildError(err); 120 | process.exit(1); 121 | } 122 | } 123 | ) 124 | .catch(err => { 125 | if (err && err.message) { 126 | console.log(err.message); 127 | } 128 | process.exit(1); 129 | }); 130 | 131 | // Create the production build and print the deployment instructions. 132 | function build(previousFileSizes) { 133 | // We used to support resolving modules according to `NODE_PATH`. 134 | // This now has been deprecated in favor of jsconfig/tsconfig.json 135 | // This lets you use absolute paths in imports inside large monorepos: 136 | if (process.env.NODE_PATH) { 137 | console.log( 138 | chalk.yellow( 139 | 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.' 140 | ) 141 | ); 142 | console.log(); 143 | } 144 | 145 | console.log('Creating an optimized production build...'); 146 | 147 | const compiler = webpack(config); 148 | return new Promise((resolve, reject) => { 149 | compiler.run((err, stats) => { 150 | let messages; 151 | if (err) { 152 | if (!err.message) { 153 | return reject(err); 154 | } 155 | 156 | let errMessage = err.message; 157 | 158 | // Add additional information for postcss errors 159 | if (Object.prototype.hasOwnProperty.call(err, 'postcssNode')) { 160 | errMessage += 161 | '\nCompileError: Begins at CSS selector ' + 162 | err['postcssNode'].selector; 163 | } 164 | 165 | messages = formatWebpackMessages({ 166 | errors: [errMessage], 167 | warnings: [], 168 | }); 169 | } else { 170 | messages = formatWebpackMessages( 171 | stats.toJson({ all: false, warnings: true, errors: true }) 172 | ); 173 | } 174 | if (messages.errors.length) { 175 | // Only keep the first error. Others are often indicative 176 | // of the same problem, but confuse the reader with noise. 177 | if (messages.errors.length > 1) { 178 | messages.errors.length = 1; 179 | } 180 | return reject(new Error(messages.errors.join('\n\n'))); 181 | } 182 | if ( 183 | process.env.CI && 184 | (typeof process.env.CI !== 'string' || 185 | process.env.CI.toLowerCase() !== 'false') && 186 | messages.warnings.length 187 | ) { 188 | console.log( 189 | chalk.yellow( 190 | '\nTreating warnings as errors because process.env.CI = true.\n' + 191 | 'Most CI servers set it automatically.\n' 192 | ) 193 | ); 194 | return reject(new Error(messages.warnings.join('\n\n'))); 195 | } 196 | 197 | return resolve({ 198 | stats, 199 | previousFileSizes, 200 | warnings: messages.warnings, 201 | }); 202 | }); 203 | }); 204 | } 205 | 206 | function copyPublicFolder() { 207 | fs.copySync(paths.appPublic, paths.appBuild, { 208 | dereference: true, 209 | filter: file => file !== paths.appHtml, 210 | }); 211 | } 212 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.BABEL_ENV = 'development'; 5 | process.env.NODE_ENV = 'development'; 6 | 7 | // Makes the script crash on unhandled rejections instead of silently 8 | // ignoring them. In the future, promise rejections that are not handled will 9 | // terminate the Node.js process with a non-zero exit code. 10 | process.on('unhandledRejection', err => { 11 | throw err; 12 | }); 13 | 14 | // Ensure environment variables are read. 15 | require('../config/env'); 16 | 17 | 18 | const fs = require('fs'); 19 | const chalk = require('react-dev-utils/chalk'); 20 | const webpack = require('webpack'); 21 | const WebpackDevServer = require('webpack-dev-server'); 22 | const clearConsole = require('react-dev-utils/clearConsole'); 23 | const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); 24 | const { 25 | choosePort, 26 | createCompiler, 27 | prepareProxy, 28 | prepareUrls, 29 | } = require('react-dev-utils/WebpackDevServerUtils'); 30 | const openBrowser = require('react-dev-utils/openBrowser'); 31 | const paths = require('../config/paths'); 32 | const configFactory = require('../config/webpack.config'); 33 | const createDevServerConfig = require('../config/webpackDevServer.config'); 34 | 35 | const useYarn = fs.existsSync(paths.yarnLockFile); 36 | const isInteractive = process.stdout.isTTY; 37 | 38 | // Warn and crash if required files are missing 39 | if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { 40 | process.exit(1); 41 | } 42 | 43 | // Tools like Cloud9 rely on this. 44 | const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; 45 | const HOST = process.env.HOST || '0.0.0.0'; 46 | 47 | if (process.env.HOST) { 48 | console.log( 49 | chalk.cyan( 50 | `Attempting to bind to HOST environment variable: ${chalk.yellow( 51 | chalk.bold(process.env.HOST) 52 | )}` 53 | ) 54 | ); 55 | console.log( 56 | `If this was unintentional, check that you haven't mistakenly set it in your shell.` 57 | ); 58 | console.log( 59 | `Learn more here: ${chalk.yellow('https://bit.ly/CRA-advanced-config')}` 60 | ); 61 | console.log(); 62 | } 63 | 64 | // We require that you explicitly set browsers and do not fall back to 65 | // browserslist defaults. 66 | const { checkBrowsers } = require('react-dev-utils/browsersHelper'); 67 | checkBrowsers(paths.appPath, isInteractive) 68 | .then(() => { 69 | // We attempt to use the default port but if it is busy, we offer the user to 70 | // run on a different port. `choosePort()` Promise resolves to the next free port. 71 | return choosePort(HOST, DEFAULT_PORT); 72 | }) 73 | .then(port => { 74 | if (port == null) { 75 | // We have not found a port. 76 | return; 77 | } 78 | const config = configFactory('development'); 79 | const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; 80 | const appName = require(paths.appPackageJson).name; 81 | const useTypeScript = fs.existsSync(paths.appTsConfig); 82 | const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true'; 83 | const urls = prepareUrls(protocol, HOST, port); 84 | const devSocket = { 85 | warnings: warnings => 86 | devServer.sockWrite(devServer.sockets, 'warnings', warnings), 87 | errors: errors => 88 | devServer.sockWrite(devServer.sockets, 'errors', errors), 89 | }; 90 | // Create a webpack compiler that is configured with custom messages. 91 | const compiler = createCompiler({ 92 | appName, 93 | config, 94 | devSocket, 95 | urls, 96 | useYarn, 97 | useTypeScript, 98 | tscCompileOnError, 99 | webpack, 100 | }); 101 | // Load proxy config 102 | const proxySetting = require(paths.appPackageJson).proxy; 103 | const proxyConfig = prepareProxy(proxySetting, paths.appPublic); 104 | // Serve webpack assets generated by the compiler over a web server. 105 | const serverConfig = createDevServerConfig( 106 | proxyConfig, 107 | urls.lanUrlForConfig 108 | ); 109 | const devServer = new WebpackDevServer(compiler, serverConfig); 110 | // Launch WebpackDevServer. 111 | devServer.listen(port, HOST, err => { 112 | if (err) { 113 | return console.log(err); 114 | } 115 | if (isInteractive) { 116 | clearConsole(); 117 | } 118 | 119 | // We used to support resolving modules according to `NODE_PATH`. 120 | // This now has been deprecated in favor of jsconfig/tsconfig.json 121 | // This lets you use absolute paths in imports inside large monorepos: 122 | if (process.env.NODE_PATH) { 123 | console.log( 124 | chalk.yellow( 125 | 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.' 126 | ) 127 | ); 128 | console.log(); 129 | } 130 | 131 | console.log(chalk.cyan('Starting the development server...\n')); 132 | openBrowser(urls.localUrlForBrowser); 133 | }); 134 | 135 | ['SIGINT', 'SIGTERM'].forEach(function(sig) { 136 | process.on(sig, function() { 137 | devServer.close(); 138 | process.exit(); 139 | }); 140 | }); 141 | }) 142 | .catch(err => { 143 | if (err && err.message) { 144 | console.log(err.message); 145 | } 146 | process.exit(1); 147 | }); 148 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.BABEL_ENV = 'test'; 5 | process.env.NODE_ENV = 'test'; 6 | process.env.PUBLIC_URL = ''; 7 | 8 | // Makes the script crash on unhandled rejections instead of silently 9 | // ignoring them. In the future, promise rejections that are not handled will 10 | // terminate the Node.js process with a non-zero exit code. 11 | process.on('unhandledRejection', err => { 12 | throw err; 13 | }); 14 | 15 | // Ensure environment variables are read. 16 | require('../config/env'); 17 | 18 | 19 | const jest = require('jest'); 20 | const execSync = require('child_process').execSync; 21 | let argv = process.argv.slice(2); 22 | 23 | function isInGitRepository() { 24 | try { 25 | execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); 26 | return true; 27 | } catch (e) { 28 | return false; 29 | } 30 | } 31 | 32 | function isInMercurialRepository() { 33 | try { 34 | execSync('hg --cwd . root', { stdio: 'ignore' }); 35 | return true; 36 | } catch (e) { 37 | return false; 38 | } 39 | } 40 | 41 | // Watch unless on CI or explicitly running all tests 42 | if ( 43 | !process.env.CI && 44 | argv.indexOf('--watchAll') === -1 && 45 | argv.indexOf('--watchAll=false') === -1 46 | ) { 47 | // https://github.com/facebook/create-react-app/issues/5210 48 | const hasSourceControl = isInGitRepository() || isInMercurialRepository(); 49 | argv.push(hasSourceControl ? '--watch' : '--watchAll'); 50 | } 51 | 52 | 53 | jest.run(argv); 54 | -------------------------------------------------------------------------------- /scripts/upload-oss.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ 2 | path: '.env.local' 3 | }) 4 | 5 | const fs = require('fs') 6 | const path = require('path') 7 | const OSS = require('ali-oss') 8 | 9 | const store = new OSS({ 10 | accessKeyId: process.env.ACCESS_KEY, 11 | accessKeySecret: process.env.ACCESS_SECRET, 12 | region: 'oss-cn-beijing', 13 | bucket: 'shanyue-ncov', 14 | timeout: '120s' 15 | }) 16 | 17 | const baseDir = path.resolve(__dirname, '../build') 18 | 19 | function probeObjectMeta(...args) { 20 | return store.getObjectMeta(...args).catch(e => null) 21 | } 22 | 23 | async function uploadDir (dir, headers, skip = false) { 24 | const files = fs.readdirSync(path.resolve(baseDir, dir)).filter(x => !x.endsWith('map')) 25 | for (const file of files) { 26 | const stats = fs.statSync(path.resolve(baseDir, dir, file)) 27 | // 如果是文件,则上传到 oss,目录跳过 28 | const isSkip = skip && await probeObjectMeta(path.join(dir, file)) 29 | if (stats.isFile()) { 30 | if (isSkip) { 31 | console.info(`skip: ${path.join(dir, file)}`) 32 | } else { 33 | const o = await store.put(path.join(dir, file), path.resolve(baseDir, dir, file), { 34 | headers 35 | }) 36 | console.info(`done: ${o.name}`) 37 | } 38 | } 39 | } 40 | return true 41 | } 42 | 43 | Promise.all([ 44 | uploadDir('.', { 45 | 'Cache-Control': 'public, no-cache' 46 | }), 47 | uploadDir('static/media', { 48 | 'Cache-Control': 3600 * 24 * 365 49 | }, true), 50 | uploadDir('static/js', { 51 | 'Cache-Control': 3600 * 24 * 365 52 | }, true), 53 | uploadDir('static/css', { 54 | 'Cache-Control': 3600 * 24 * 365 55 | }, true) 56 | ]).catch(e => { 57 | console.error(e) 58 | process.exit(1) 59 | }) 60 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | background-color: #111; 4 | } 5 | 6 | header i { 7 | color: #eee; 8 | } 9 | 10 | .due { 11 | font-size: .9rem; 12 | color: #888; 13 | font-weight: 400; 14 | padding-left: 1rem; 15 | } 16 | 17 | .title { 18 | font-size: 1.2rem; 19 | font-weight: 600; 20 | color: #f60; 21 | margin: .5rem 0; 22 | display: block; 23 | } 24 | 25 | .new { 26 | padding: 1rem 0; 27 | border-bottom: 1px solid #efefef; 28 | } 29 | 30 | .new-date { 31 | color: #888; 32 | } 33 | 34 | .new-date > .relative { 35 | color: #000; 36 | font-weight: 600; 37 | display: inline-block; 38 | padding-right: 1rem; 39 | } 40 | 41 | .province { 42 | display: flex; 43 | text-align: center; 44 | } 45 | 46 | .province.header > div { 47 | background-color: #f60; 48 | color: #fcfcfc; 49 | } 50 | 51 | .area, 52 | .confirmed, 53 | .death, 54 | .cured { 55 | border: 1px solid #fff; 56 | background-color: #f8f8f8; 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | } 61 | 62 | .area { 63 | flex: 3; 64 | padding: .4rem 0; 65 | position: relative; 66 | } 67 | 68 | .area.active::before { 69 | content: '.'; 70 | display: block; 71 | position: absolute; 72 | font-size: 0; 73 | line-height: 0; 74 | width: 0; 75 | height: 0; 76 | border-width: 6px 0 6px 6px; 77 | border-style: solid; 78 | border-color: transparent transparent transparent #f60; 79 | left: 10px; 80 | top: 50%; 81 | transform: translate(0, -50%); 82 | } 83 | 84 | .confirmed, 85 | .death, 86 | .cured { 87 | padding: .3rem 0; 88 | flex: 2; 89 | } 90 | 91 | .fallback { 92 | padding: 0 1rem 1rem; 93 | color: #999; 94 | text-align: center; 95 | } 96 | 97 | a { 98 | text-decoration: none; 99 | color: unset; 100 | } 101 | 102 | .loading { 103 | display: flex; 104 | justify-content: center; 105 | align-items: center; 106 | height: 300px; 107 | } 108 | 109 | .info a { 110 | color: #fff; 111 | cursor: pointer; 112 | line-height: 1.6em; 113 | font-size: 1.5rem; 114 | font-weight: 600; 115 | } 116 | 117 | .info > li { 118 | list-style: none; 119 | background-color: #2834b9; 120 | padding: .8rem 1.8rem; 121 | border-radius: 8px; 122 | text-decoration: none; 123 | margin: .5rem 0; 124 | background-image: url(https://assets.dxycdn.com/gitrepo/bbs-mobile/dist/static/bg-image-3-2@2x.b56753cd.png); 125 | background-position: left bottom; 126 | background-repeat: no-repeat; 127 | background-size: auto 3rem; 128 | } 129 | 130 | .tip { 131 | color: #888; 132 | padding: .5rem 0; 133 | /* border-left: 1px solid #f60; */ 134 | } 135 | 136 | h2 small { 137 | color: #f60; 138 | position: absolute; 139 | right: 0; 140 | top: 50%; 141 | transform: translateY(-50%); 142 | } -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, Suspense, useEffect } from 'react' 2 | import keyBy from 'lodash.keyby' 3 | import dayjs from 'dayjs' 4 | import 'dayjs/locale/zh-cn' 5 | import relativeTime from 'dayjs/plugin/relativeTime' 6 | 7 | import all from './data/overall' 8 | import provinces from './data/area' 9 | 10 | import Tag from './Tag' 11 | 12 | import './App.css' 13 | import axios from 'axios' 14 | 15 | dayjs.extend(relativeTime) 16 | 17 | const Map = React.lazy(() => import('./Map')) 18 | 19 | const provincesByName = keyBy(provinces, 'name') 20 | const provincesByPinyin = keyBy(provinces, 'pinyin') 21 | 22 | const fetcher = (url) => axios(url).then(data => { 23 | return data.data.data 24 | }) 25 | 26 | function New ({ title, summary, sourceUrl, pubDate, pubDateStr }) { 27 | return ( 28 |
29 |
30 |
31 | {dayjs(pubDate).locale('zh-cn').fromNow()} 32 |
33 | {dayjs(pubDate).format('YYYY-MM-DD HH:mm')} 34 |
35 | { title } 36 |
{ summary.slice(0, 100) }...
37 |
38 | ) 39 | } 40 | 41 | function News ({ province }) { 42 | const [len, setLen] = useState(8) 43 | const [news, setNews] = useState([]) 44 | 45 | useEffect(() => { 46 | fetcher(`https://file1.dxycdn.com/2020/0130/492/3393874921745912795-115.json?t=${46341925 + Math.random()}`).then(news => { 47 | setNews(news) 48 | }) 49 | }, []) 50 | 51 | return ( 52 |
53 |

实时动态

54 | { 55 | news 56 | .filter(n => province ? province.provinceShortName === (n.provinceName && n.provinceName.slice(0, 2)) : true) 57 | .slice(0, len) 58 | .map(n => ) 59 | } 60 |
{ setLen() }}>点击查看全部动态
61 |
62 | ) 63 | } 64 | 65 | function Summary () { 66 | return ( 67 |
68 |

信息汇总

69 |
  • 70 | 疫情24小时 | 与疫情赛跑 71 |
  • 72 |
  • 确诊患者同行查询工具
  • 73 |
  • 腾讯新闻新冠疫情实时动态
  • 74 |
  • 丁香园新冠疫情实时动态
  • 75 |
  • 新型冠状病毒实时辟谣
  • 76 |
  • 微医抗击疫情实时救助
  • 77 |
    78 | ) 79 | } 80 | 81 | function Stat ({ modifyTime, confirmedCount, suspectedCount, deadCount, curedCount, name, quanguoTrendChart, hbFeiHbTrendChart }) { 82 | return ( 83 |
    84 |

    85 | 统计 {name ? `· ${name}` : false} 86 | 87 | 截止时间: {dayjs(modifyTime).format('YYYY-MM-DD HH:mm')} 88 | 89 |

    90 |
    91 | 92 | 确诊 93 | 94 | 95 | 疑似 96 | 97 | 98 | 死亡 99 | 100 | 101 | 治愈 102 | 103 |
    104 |
    105 | 106 |
    107 |
    108 | 109 |
    110 |
    111 | ) 112 | } 113 | 114 | function Fallback () { 115 | return ( 116 |
    117 |
    118 | 代码仓库: shfshanyue/2019-ncov 119 |
    120 |
    121 | ) 122 | } 123 | 124 | function Area ({ area, onChange }) { 125 | const renderArea = () => { 126 | return area.map(x => ( 127 |
    { 128 | // 表示在省一级 129 | if (x.name) { 130 | onChange(x) 131 | } 132 | }}> 133 |
    134 | { x.name || x.cityName } 135 |
    136 |
    { x.confirmedCount }
    137 |
    { x.deadCount }
    138 |
    { x.curedCount }
    139 |
    140 | )) 141 | } 142 | 143 | return ( 144 | <> 145 |
    146 |
    地区
    147 |
    确诊
    148 |
    死亡
    149 |
    治愈
    150 |
    151 | { renderArea() } 152 | 153 | ) 154 | } 155 | 156 | function Header ({ province }) { 157 | return ( 158 |
    159 |
    160 |

    161 | 新型冠状病毒 162 |
    163 | 疫情实时动态 · { province ? province.name : '省市地图' } 164 |

    165 | By 全栈成长之路 166 |
    167 | ) 168 | } 169 | 170 | function App () { 171 | const [province, _setProvince] = useState(null) 172 | const setProvinceByUrl = () => { 173 | const p = window.location.pathname.slice(1) 174 | _setProvince(p ? provincesByPinyin[p] : null) 175 | } 176 | 177 | useEffect(() => { 178 | setProvinceByUrl() 179 | window.addEventListener('popstate', setProvinceByUrl) 180 | return () => { 181 | window.removeEventListener('popstate', setProvinceByUrl) 182 | } 183 | }, []) 184 | 185 | useEffect(() => { 186 | if (province) { 187 | window.document.title = `肺炎疫情实时地图 | ${province.name}` 188 | } 189 | }, [province]) 190 | 191 | const setProvince = (p) => { 192 | _setProvince(p) 193 | window.history.pushState(null, null, p ? p.pinyin : '/') 194 | } 195 | 196 | const data = !province ? provinces.map(p => ({ 197 | name: p.provinceShortName, 198 | value: p.confirmedCount 199 | })) : provincesByName[province.name].cities.map(city => ({ 200 | name: city.fullCityName, 201 | value: city.confirmedCount 202 | })) 203 | 204 | const area = province ? provincesByName[province.name].cities : provinces 205 | const overall = province ? province : all 206 | 207 | return ( 208 |
    209 |
    210 | 211 |
    212 |

    疫情地图 { province ? `· ${province.name}` : false } 213 | { 214 | province ? setProvince(null)} 216 | >返回全国 : null 217 | } 218 |

    219 | 地图正在加载中...
    }> 220 | { 221 | const p = provincesByName[name] 222 | if (p) { 223 | setProvince(p) 224 | } 225 | }} /> 226 | { 227 | province ? false : 228 |
    229 | 在地图中点击省份可跳转到相应省份的疫情地图,并查看该省相关的实时动态 230 |
    231 | } 232 | 233 | 234 |
    235 | 236 | 237 | 238 | 239 | ); 240 | } 241 | 242 | export default App; 243 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/Map.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import ReactEcharts from 'echarts-for-react/lib/core' 3 | import echarts from 'echarts/lib/echarts' 4 | 5 | import 'echarts/lib/chart/map' 6 | import 'echarts/lib/component/visualMap' 7 | 8 | function Map ({ province, data, onClick }) { 9 | const [loading, setLoading] = useState(true) 10 | 11 | useEffect(() => { 12 | setLoading(true) 13 | if (province) { 14 | import(`echarts/map/json/province/${province.pinyin}.json`).then(map => { 15 | echarts.registerMap(province.pinyin, map.default) 16 | setLoading(false) 17 | }) 18 | } else { 19 | import(`echarts/map/json/china.json`).then(map => { 20 | echarts.registerMap('china', map.default) 21 | setLoading(false) 22 | }) 23 | } 24 | }, [province]) 25 | 26 | const getOption = () => { 27 | return { 28 | visualMap: { 29 | show: true, 30 | type: 'piecewise', 31 | min: 0, 32 | max: 2000, 33 | align: 'right', 34 | top: province ? 0 : '40%', 35 | right: 0, 36 | left: province ? 0 : 'auto', 37 | inRange: { 38 | color: [ 39 | '#ffc0b1', 40 | '#ff8c71', 41 | '#ef1717', 42 | '#9c0505' 43 | ] 44 | }, 45 | pieces: [ 46 | {min: 1000}, 47 | {min: 500, max: 999}, 48 | {min: 100, max: 499}, 49 | {min: 10, max: 99}, 50 | {min: 1, max: 9}, 51 | ], 52 | padding: 5, 53 | // "inverse": false, 54 | // "splitNumber": 5, 55 | orient: province ? 'horizontal' : 'vertical', 56 | showLabel: province ? false : true, 57 | text: ['高', '低'], 58 | itemWidth: 10, 59 | itemHeight: 10, 60 | textStyle: { 61 | fontSize: 10 62 | } 63 | // "borderWidth": 0 64 | }, 65 | series: [{ 66 | left: 'center', 67 | // top: '15%', 68 | // bottom: '10%', 69 | type: 'map', 70 | name: '确诊人数', 71 | silent: province ? true : false, 72 | label: { 73 | show: true, 74 | position: 'inside', 75 | // margin: 8, 76 | fontSize: 6 77 | }, 78 | mapType: province ? province.pinyin : 'china', 79 | data, 80 | zoom: 1.2, 81 | roam: false, 82 | showLegendSymbol: false, 83 | emphasis: {}, 84 | rippleEffect: { 85 | show: true, 86 | brushType: 'stroke', 87 | scale: 2.5, 88 | period: 4 89 | } 90 | }] 91 | } 92 | } 93 | return ( 94 | loading ?
    地图正在加载中...
    : 95 | 105 | ) 106 | } 107 | 108 | export default Map 109 | -------------------------------------------------------------------------------- /src/Tag.css: -------------------------------------------------------------------------------- 1 | .tag { 2 | border-radius: 4px; 3 | padding: 5px 10px; 4 | background-color: #f3f3f3; 5 | display: inline-block; 6 | text-align: center; 7 | color: #888; 8 | flex: 1; 9 | margin: 5px; 10 | } 11 | 12 | .number { 13 | color: #f60; 14 | font-size: 1.2rem; 15 | font-weight: 600; 16 | } -------------------------------------------------------------------------------- /src/Tag.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './Tag.css' 3 | 4 | function Tag ({ children, number }) { 5 | return ( 6 |
    7 |
    8 | { number } 9 |
    10 | { children } 11 |
    12 | ) 13 | } 14 | 15 | export default Tag -------------------------------------------------------------------------------- /src/data/countries.json: -------------------------------------------------------------------------------- 1 | [{"id":64827,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"6","provinceName":"日本","provinceShortName":"","cityName":"","confirmedCount":203,"suspectedCount":0,"curedCount":9,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":951002},{"id":64824,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"3","provinceName":"新加坡","provinceShortName":"","cityName":"","confirmedCount":47,"suspectedCount":0,"curedCount":9,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":952009},{"id":64823,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"2","provinceName":"泰国","provinceShortName":"","cityName":"","confirmedCount":33,"suspectedCount":0,"curedCount":10,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":952010},{"id":64828,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"7","provinceName":"韩国","provinceShortName":"","cityName":"","confirmedCount":28,"suspectedCount":0,"curedCount":4,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":951004},{"id":64825,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"4","provinceName":"马来西亚","provinceShortName":"","cityName":"","confirmedCount":18,"suspectedCount":0,"curedCount":3,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":952007},{"id":64835,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"德国","provinceShortName":"","cityName":"","confirmedCount":16,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":963003},{"id":64830,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"9","provinceName":"越南","provinceShortName":"","cityName":"","confirmedCount":15,"suspectedCount":0,"curedCount":3,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":952011},{"id":64832,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"大洋洲","provinceId":"10","provinceName":"澳大利亚","provinceShortName":"","cityName":"","confirmedCount":15,"suspectedCount":0,"curedCount":5,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":990001},{"id":64829,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"北美洲","provinceId":"8","provinceName":"美国","provinceShortName":"","cityName":"","confirmedCount":13,"suspectedCount":0,"curedCount":3,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":971002},{"id":64826,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"5","provinceName":"法国","provinceShortName":"","cityName":"","confirmedCount":11,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":961002},{"id":64838,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"10","provinceName":"阿联酋","provinceShortName":"","cityName":"","confirmedCount":8,"suspectedCount":0,"curedCount":1,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":955019},{"id":64842,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"英国","provinceShortName":"","cityName":"","confirmedCount":8,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":961007},{"id":64833,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"北美洲","provinceId":"10","provinceName":"加拿大","provinceShortName":"","cityName":"","confirmedCount":7,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":971001},{"id":64840,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"10","provinceName":"印度","provinceShortName":"","cityName":"","confirmedCount":3,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":953003},{"id":64841,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"意大利","provinceShortName":"","cityName":"","confirmedCount":3,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":965008},{"id":64846,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"10","provinceName":"菲律宾","provinceShortName":"","cityName":"","confirmedCount":3,"suspectedCount":0,"curedCount":1,"deadCount":1,"comment":"","sort":0,"operator":"zhaijihong","locationId":952008},{"id":64843,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"俄罗斯","provinceShortName":"","cityName":"","confirmedCount":2,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":964006},{"id":64844,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"西班牙","provinceShortName":"","cityName":"","confirmedCount":2,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":965015},{"id":64831,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"10","provinceName":"尼泊尔","provinceShortName":"","cityName":"","confirmedCount":1,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":953005},{"id":64834,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"10","provinceName":"柬埔寨","provinceShortName":"","cityName":"","confirmedCount":1,"suspectedCount":0,"curedCount":1,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":952003},{"id":64836,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"亚洲","provinceId":"10","provinceName":"斯里兰卡","provinceShortName":"","cityName":"","confirmedCount":1,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":953007},{"id":64839,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"芬兰","provinceShortName":"","cityName":"","confirmedCount":1,"suspectedCount":0,"curedCount":1,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":962002},{"id":64845,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"瑞典","provinceShortName":"","cityName":"","confirmedCount":1,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":962005},{"id":64857,"createTime":1581507840000,"modifyTime":1581507840000,"tags":"","countryType":2,"continents":"欧洲","provinceId":"10","provinceName":"比利时","provinceShortName":"","cityName":"","confirmedCount":1,"suspectedCount":0,"curedCount":0,"deadCount":0,"comment":"","sort":0,"operator":"zhaijihong","locationId":961001}] -------------------------------------------------------------------------------- /src/data/overall.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "createTime": 1579537899000, 4 | "modifyTime": 1581507840000, 5 | "infectSource": "该字段已替换为说明2", 6 | "passWay": "该字段已替换为说明3", 7 | "imgUrl": "https://img1.dxycdn.com/2020/0201/450/3394153392393266839-135.png", 8 | "dailyPic": "https://img1.dxycdn.com/2020/0211/763/3395998884005602079-135.png,https://img1.dxycdn.com/2020/0211/362/3395998896890788910-135.png,https://img1.dxycdn.com/2020/0211/365/3395998905480724211-135.png,https://img1.dxycdn.com/2020/0211/364/3395998916217859778-135.png,https://img1.dxycdn.com/2020/0211/922/3395998929103046444-135.png,https://img1.dxycdn.com/2020/0211/089/3395998939840182072-135.png", 9 | "dailyPics": [ 10 | "https://img1.dxycdn.com/2020/0211/763/3395998884005602079-135.png", 11 | "https://img1.dxycdn.com/2020/0211/362/3395998896890788910-135.png", 12 | "https://img1.dxycdn.com/2020/0211/365/3395998905480724211-135.png", 13 | "https://img1.dxycdn.com/2020/0211/364/3395998916217859778-135.png", 14 | "https://img1.dxycdn.com/2020/0211/922/3395998929103046444-135.png", 15 | "https://img1.dxycdn.com/2020/0211/089/3395998939840182072-135.png" 16 | ], 17 | "summary": "", 18 | "deleted": false, 19 | "countRemark": "", 20 | "confirmedCount": 44763, 21 | "suspectedCount": 16067, 22 | "curedCount": 5007, 23 | "deadCount": 1115, 24 | "seriousCount": 8204, 25 | "suspectedIncr": 3342, 26 | "confirmedIncr": 2055, 27 | "curedIncr": 1009, 28 | "deadIncr": 98, 29 | "seriousIncr": 871, 30 | "virus": "该字段已替换为说明1", 31 | "remark1": "易感人群:人群普遍易感。老年人及有基础疾病者感染后病情较重,儿童及婴幼儿也有发病", 32 | "remark2": "潜伏期:一般为 3~7 天,最长不超过 14 天,潜伏期内可能存在传染性,其中无症状病例传染性非常罕见", 33 | "remark3": "宿主:野生动物,可能为中华菊头蝠", 34 | "remark4": "", 35 | "remark5": "", 36 | "note1": "病毒:SARS-CoV-2,其导致疾病命名 COVID-19", 37 | "note2": "传染源:新冠肺炎的患者。无症状感染者也可能成为传染源。", 38 | "note3": "传播途径:经呼吸道飞沫、接触传播是主要的传播途径。气溶胶传播和消化道等传播途径尚待明确。", 39 | "generalRemark": "疑似病例数来自国家卫健委数据,目前为全国数据,未分省市自治区等", 40 | "abroadRemark": "", 41 | "marquee": [ 42 | { 43 | "id": 233, 44 | "marqueeLabel": "公告", 45 | "marqueeContent": "出门买药怕感染?在线开药顺丰到家", 46 | "marqueeLink": "https://ask.dxy.com/ama/index#/activity-share?activity_id=131" 47 | }, 48 | { 49 | "id": 234, 50 | "marqueeLabel": "科普", 51 | "marqueeContent": "疫情期间,专家告诉你如何正确洗手", 52 | "marqueeLink": "https://mp.weixin.qq.com/s/CtLJd3FAYOqGQstq77rEXA" 53 | } 54 | ], 55 | "quanguoTrendChart": [ 56 | { 57 | "imgUrl": "https://img1.dxycdn.com/2020/0212/654/3396262678749612937-135.png", 58 | "title": "新增疑似/新增确诊" 59 | }, 60 | { 61 | "imgUrl": "https://img1.dxycdn.com/2020/0212/875/3396262644389688009-135.png", 62 | "title": "确诊/疑似" 63 | }, 64 | { 65 | "imgUrl": "https://img1.dxycdn.com/2020/0212/106/3396191747364539128-135.png", 66 | "title": "新增死亡/新增治愈" 67 | }, 68 | { 69 | "imgUrl": "https://img1.dxycdn.com/2020/0212/104/3396191762397126635-135.png", 70 | "title": "死亡/治愈" 71 | }, 72 | { 73 | "imgUrl": "https://img1.dxycdn.com/2020/0212/548/3396191775282029632-135.png", 74 | "title": "病死率/治愈率" 75 | } 76 | ], 77 | "hbFeiHbTrendChart": [ 78 | { 79 | "imgUrl": "https://img1.dxycdn.com/2020/0212/196/3396191826821441309-135.png", 80 | "title": "新增确诊" 81 | }, 82 | { 83 | "imgUrl": "https://img1.dxycdn.com/2020/0212/665/3396262734584191783-135.png", 84 | "title": "确诊" 85 | }, 86 | { 87 | "imgUrl": "https://img1.dxycdn.com/2020/0212/422/3396191854738731406-135.png", 88 | "title": "死亡/治愈" 89 | }, 90 | { 91 | "imgUrl": "https://img1.dxycdn.com/2020/0212/150/3396191869771318584-135.png", 92 | "title": "病死率" 93 | }, 94 | { 95 | "imgUrl": "https://img1.dxycdn.com/2020/0212/071/3396191899836092581-135.png", 96 | "title": "治愈率" 97 | } 98 | ] 99 | } -------------------------------------------------------------------------------- /src/feiyan.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shfshanyue/2019-ncov/dd01a6e1361eb1eff28b7628592f857d54732214/src/feiyan.jpeg -------------------------------------------------------------------------------- /src/feiyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shfshanyue/2019-ncov/dd01a6e1361eb1eff28b7628592f857d54732214/src/feiyan.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | background-color: #efefef; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | 16 | header { 17 | padding: 1rem; 18 | position: relative; 19 | color: #fff; 20 | } 21 | 22 | header .bg { 23 | position: absolute; 24 | top: 0; 25 | left: 0; 26 | width: 100%; 27 | height: 100%; 28 | background-image: url(./feiyan.jpeg); 29 | background-position: center; 30 | background-size: 100%; 31 | z-index: -1; 32 | filter: brightness(0.5); 33 | } 34 | 35 | h1 { 36 | text-align: left; 37 | margin: .5rem 0; 38 | } 39 | 40 | h2 { 41 | margin: .5rem 0; 42 | padding: 3px 10px; 43 | border-left: .2rem solid #f60; 44 | border-bottom: 1px solid #efefef; 45 | position: relative; 46 | } 47 | 48 | .row { 49 | display: flex; 50 | justify-content: space-between; 51 | padding: 3px 0; 52 | } 53 | 54 | .card { 55 | background-color: #fff; 56 | padding: 1rem; 57 | margin: 10px 0; 58 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /src/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shfshanyue/2019-ncov/dd01a6e1361eb1eff28b7628592f857d54732214/src/logo.jpg -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready.then(registration => { 134 | registration.unregister(); 135 | }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | --------------------------------------------------------------------------------