Parcourir la source

push project city-selector

Yaohan Hu il y a 6 ans
commit
8658ff1df6

+ 25 - 0
.gitignore

@@ -0,0 +1,25 @@
+# See https://help.github.com/ignore-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+./src/**/*.css
+.idea
+.vscode

Fichier diff supprimé car celui-ci est trop grand
+ 2444 - 0
README.md


+ 6 - 0
config-overrides.js

@@ -0,0 +1,6 @@
+const { injectBabelPlugin } = require('react-app-rewired');
+
+module.exports = function override(config, env) {
+    config = injectBabelPlugin(['import', { libraryName: 'antd-mobile', style: 'css' }], config);
+    return config;
+};

Fichier diff supprimé car celui-ci est trop grand
+ 12669 - 0
package-lock.json


+ 31 - 0
package.json

@@ -0,0 +1,31 @@
+{
+  "name": "city-selector",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "antd-mobile": "^2.2.0",
+    "axios": "^0.18.0",
+    "node-sass-chokidar": "^1.3.0",
+    "npm-run-all": "^4.1.3",
+    "react": "^16.4.1",
+    "react-dom": "^16.4.1",
+    "react-router-dom": "^4.3.1",
+    "react-scripts": "1.1.4"
+  },
+  "scripts": {
+    "start-js": "react-app-rewired start",
+    "start": "npm-run-all -p watch-css start-js",
+    "build-js": "react-app-rewired build",
+    "build": "npm-run-all build-css build-js",
+    "test": "react-app-rewired test --env=jsdom",
+    "eject": "react-scripts eject",
+    "build-css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/",
+    "watch-css": "npm run build-css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive"
+  },
+  "proxy": "http://www.msece.com",
+  "devDependencies": {
+    "babel-plugin-import": "^1.8.0",
+    "jest-localstorage-mock": "^2.2.0",
+    "react-app-rewired": "^1.5.2"
+  }
+}

BIN
public/favicon.ico


+ 41 - 0
public/index.html

@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="theme-color" content="#000000">
+    <!--
+      manifest.json provides metadata used when your web app is added to the
+      homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
+    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>City Selector</title>
+    <script type="text/javascript" src="http://api.map.baidu.com/api?v=2.0&ak=TtdhnvKOhyEHHvQFFj1sBmmOWwAM21mG"></script>
+  </head>
+  <body>
+    <noscript>
+      You need to enable JavaScript to run this app.
+    </noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>

+ 15 - 0
public/manifest.json

@@ -0,0 +1,15 @@
+{
+  "short_name": "React App",
+  "name": "Create React App Sample",
+  "icons": [
+    {
+      "src": "favicon.ico",
+      "sizes": "64x64 32x32 24x24 16x16",
+      "type": "image/x-icon"
+    }
+  ],
+  "start_url": "./index.html",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff"
+}

+ 13 - 0
src/components/CustomIcon.js

@@ -0,0 +1,13 @@
+import React from 'react';
+
+const CustomIcon = ({ type, className = '', size = 'md', ...restProps }) => (
+    <svg
+       className={`am-icon am-icon-${type.substr(1)} am-icon-${size} ${className}`}
+       {...restProps}
+    >
+       <use xlinkHref={type} /> {/* svg-sprite-loader@0.3.x */}
+       {/* <use xlinkHref={#${type.default.id}} /> */} {/* svg-sprite-loader@latest */}
+    </svg>
+);
+
+export default CustomIcon;

+ 69 - 0
src/components/city/IndexNav.js

@@ -0,0 +1,69 @@
+import React, { Component } from 'react';
+
+class IndexNav extends Component {
+    constructor(props) {
+        super(props);
+        this.state = {
+            navOffsetX: 0
+        };
+    }
+
+    onTouchStart = e => {
+        if (e.target.tagName !== 'LI') {
+            return;
+        }
+
+        const navOffsetX = e.changedTouches[0].clientX;
+        const y = e.changedTouches[0].clientY;
+        this.setState({ navOffsetX }, () => {
+            this.props.onNavChange({ moving: true });
+            this.scrollList(y);
+            window.addEventListener('touchmove', this.onTouchMove, { passive: false });
+            window.addEventListener('touchend', this.onTouchEnd);
+        });
+    }
+
+    onTouchMove = e => {
+        if ( e.cancelable ) {
+            e.preventDefault();
+        }
+        this.scrollList(e.changedTouches[0].clientY);
+    }
+
+    onTouchEnd = () => {
+        setTimeout(() => {
+            this.props.onNavChange({ moving: false, label: '' });
+            window.removeEventListener('touchmove', this.onTouchMove);
+            window.removeEventListener('touchend', this.onTouchEnd);
+        }, 500);
+    }
+
+    scrollList = y => {
+        const { navOffsetX } = this.state;
+        const currentItem = document.elementFromPoint(navOffsetX, y);
+        const label = currentItem.textContent;
+
+        if (!currentItem || !currentItem.classList.contains('city-label-item')) {
+            return;
+        }
+
+        this.props.onNavChange({ label });
+    }
+
+    render() {
+        const labels = this.props.labels;
+
+        return (
+            <ul className="city-label-wrapper" onTouchStart={ this.onTouchStart }>
+                {
+                    labels.map(label => {
+                        return <li key={ label } className="city-label-item">{ label }</li>;
+                    })
+                }
+            </ul>
+        );
+
+    }
+}
+
+export default IndexNav;

+ 11 - 0
src/components/city/Indicator.js

@@ -0,0 +1,11 @@
+import React from 'react';
+
+const Indicator = ({ indicator }) => {
+    if (!indicator) {
+        return null;
+    }
+
+    return <div className="city-indicator">{ indicator }</div>;
+}
+
+export default Indicator;

+ 32 - 0
src/components/city/PositionCity.js

@@ -0,0 +1,32 @@
+import React from 'react';
+
+const PositionCity = ({ title, position, city, positionCity = '', ...rest }) => {
+    return (
+        <div className={ `city-local ${rest.className ? rest.className : ''}` }>
+            <p>{ title }</p>
+            <ul>
+                { 
+                    position && 
+                    <li className="city-item" onClick={ () => rest.onSelectCity(positionCity) }>
+                        <span className="city-location" />
+                        <span className="city-local-text">{ positionCity }</span>
+                    </li> 
+                }
+                {
+                    city && city.map(l => {
+                        if (typeof l === 'string') {
+                            l = { id: l, city: l };
+                        }
+                        return l.city !== positionCity && (
+                            <li key={ l.id } className="city-item" onClick={ () => rest.onSelectCity(l.city) }>
+                                <span className="city-local-text">{ l.city }</span>
+                            </li>
+                        );
+                    })
+                }
+            </ul>
+        </div>
+    );
+}
+
+export default PositionCity;

+ 14 - 0
src/components/city/SearchArea.js

@@ -0,0 +1,14 @@
+import React from 'react';
+import { Menu } from 'antd-mobile';
+
+const SearchArea = ({ city, onChange }) => {
+    return <Menu
+            className="city-search-menu"
+            data={ city }
+            level={ 1 }
+            onChange={ onChange }
+            height={ city.length === 0 ? 0 : document.documentElement.clientHeight * 0.6 }
+        />;
+}
+
+export default SearchArea;

+ 11 - 0
src/components/city/SearchMask.js

@@ -0,0 +1,11 @@
+import React from 'react';
+
+const SearchMask = ({ show, city }) => {
+    if (!(show && city.length > 0)) {
+        return null;
+    }
+
+    return <div className="city-menu-mask"></div>;
+}
+
+export default SearchMask;

+ 13 - 0
src/components/city/index.js

@@ -0,0 +1,13 @@
+import SearchMask from './SearchMask';
+import IndexNav from './IndexNav';
+import Indicator from './Indicator';
+import PositionCity from './PositionCity';
+import SearchArea from './SearchArea';
+
+export {
+    SearchMask,
+    IndexNav,
+    Indicator,
+    PositionCity,
+    SearchArea
+};

+ 6 - 0
src/components/index.js

@@ -0,0 +1,6 @@
+import CustomIcon from './CustomIcon';
+
+export * from './city';
+export {
+    CustomIcon
+};

+ 18 - 0
src/container/index.js

@@ -0,0 +1,18 @@
+import React, { Component } from 'react';
+import { Switch, Route } from "react-router-dom";
+
+import App from '../pages/app/App';
+import City from '../pages/city/City';
+
+class Index extends Component {
+    render() {
+        return (
+            <Switch>
+                <Route exact path="/" component={ App } />
+                <Route path="/city" component={ City } />
+            </Switch>
+        );
+    }
+}
+
+export default Index;

+ 15 - 0
src/index.js

@@ -0,0 +1,15 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { BrowserRouter as Router, Route } from "react-router-dom";
+
+import './static/styles/index.css';
+import Container from './container';
+import registerServiceWorker from './static/scripts/registerServiceWorker';
+
+ReactDOM.render(
+    <Router>
+        <Route component={ Container } />
+    </Router>, 
+    document.getElementById('root')
+);
+registerServiceWorker();

+ 0 - 0
src/pages/app/App.css


+ 45 - 0
src/pages/app/App.js

@@ -0,0 +1,45 @@
+import React, { Component } from 'react';
+import { NavBar, List } from 'antd-mobile';
+import './App.css';
+
+const Item = List.Item;
+
+class App extends Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      city1: '',
+      city2: ''
+    };
+  }
+
+  componentDidMount = () => {
+    const { location } = this.props;
+    const state = location && location.state;
+    state && this.setState(state);
+  }
+
+  gotoChooseCity = (cityCode) => {
+    const { history } = this.props;
+    history.push({
+      pathname: '/city',
+      state: { cityCode, ...this.state }
+    });
+  }
+
+  render() {
+    const { city1, city2 } = this.state;
+
+    return (
+      <div className="App">
+        <NavBar mode="dark" className="navbar">城市选择</NavBar>
+        <List renderHeader={ () => '选择城市demo' } className="select-city-list">
+          <Item extra={ city1 } arrow="horizontal" onClick={ () => this.gotoChooseCity('1') }>城市选择1</Item>
+          <Item extra={ city2 } arrow="horizontal" onClick={ () => this.gotoChooseCity('2') }>城市选择2</Item>
+        </List>
+      </div>
+    );
+  }
+}
+
+export default App;

+ 0 - 0
src/pages/app/App.scss


+ 204 - 0
src/pages/city/City.js

@@ -0,0 +1,204 @@
+import React, { Component } from 'react';
+import { NavBar, SearchBar, WhiteSpace, List, Toast, Icon } from 'antd-mobile';
+import { SearchMask, IndexNav, Indicator, PositionCity, SearchArea } from '../../components';
+import { getLocalCity } from '../../services/locationServices';
+import { 
+    LASTEST_CITY,
+    getAllCities, 
+    saveLocalStorageCity, 
+    getLocalStorageCity,
+    searchCityByName,
+    transformCityMenuData
+} from '../../services/cityServices';
+import { throttle } from '../../utils';
+import './city.css';
+
+const Item = List.Item;
+const searchCity = throttle(searchCityByName);
+
+class City extends Component {
+    constructor(props) {
+        super(props);
+        this.state = {
+            localCity: '北京',
+            hotCity: [],
+            latestCity: [],
+            city: {},
+            labels: [],
+            searchCities: [],
+
+            moving: false,
+            indicator: '',
+            loading: false,
+            searchArea: false
+        };
+    }
+
+    componentDidMount = () => {
+        this.setState(() => {
+            Toast.loading('Loading...');
+            return { loading: true };
+        });
+
+        Promise.all([getLocalCity(), getAllCities(), getLocalStorageCity(LASTEST_CITY)]).then(result => {
+            const localCity = result[0] && result[0].replace(/市/, '');
+            const city = result[1].afterFilterAllCity.allCity;
+            const labels = result[1].afterFilterAllCity.validateLetters;
+            const hotCity = result[1].hotCity;
+            const latestCity = result[2];
+            this.setState(() => {
+                Toast.hide();
+                return {
+                    localCity,
+                    city,
+                    hotCity,
+                    labels,
+                    latestCity,
+                    loading: false
+                };
+            });
+        });
+    }
+
+    onSearchInput = async value => {
+        if (!value) {
+            this.hideMenuDialog();
+            return;
+        }
+
+        const { labels, city } = this.state;
+        const cities = await searchCity(value, labels, city);
+        this.setState({  
+            searchArea: true, 
+            searchCities: transformCityMenuData(cities) 
+        });
+    }
+
+    hideMenuDialog = () => {
+        this.setState({
+            searchArea: false,
+            searchCities: []
+        });
+    }
+
+    renderLocalCity = () => {
+        const { localCity, latestCity } = this.state;
+        return <PositionCity 
+            title={ '定位/最近访问' } 
+            position 
+            positionCity={ localCity } 
+            city={ latestCity } 
+            onSelectCity={ this.onSelectCity } />
+    }
+
+    renderHotCity = () => {
+        const { hotCity } = this.state;
+        return <PositionCity 
+            title={ '热门城市' } 
+            position={ false } 
+            city={ hotCity } 
+            onSelectCity={ this.onSelectCity } />
+    }
+
+    renderCities = () => {
+        const { city } = this.state;
+        return Object.keys(city).map(letter => {
+            const cList = city[letter];
+            return (
+                <div key={ letter } ref={ element => this[`section${letter}`] = element }>
+                    <List renderHeader={ () => letter } className="select-city-list">
+                        {
+                            cList.length > 0 && cList.map(c => {
+                                return <Item key={ c.id } onClick={ () => this.onSelectCity(c) }>{ c.city }</Item>
+                            })
+                        }
+                    </List>
+                </div>
+            );
+        });
+    }
+
+    renderSearchCity = () => {
+        const { searchCities } = this.state;
+        return <SearchArea city={ searchCities } onChange={ this.onChangeMenu } />
+    }
+
+    onChangeMenu = value => {
+        this.hideMenuDialog();
+        this.onSelectCity(value[0]);
+    }
+
+    onSelectCity = (city) => {
+        if (this.state.moving) {
+            return;
+        }
+
+        const { history, location } = this.props;
+        const { cityCode, ...rest } = location.state;
+        const backCity = typeof city === 'string' ? city : city.city;
+
+        rest[`city${cityCode}`] = backCity;
+        saveLocalStorageCity(LASTEST_CITY, backCity);
+        history.push({
+            pathname: '/',
+            state: { ...rest }
+        });
+    }
+
+    onNavChange = (nav) => {
+        this.setState(() => {
+            const label = nav.label ? nav.label : this.state.label;
+            const moving = nav.moving ? nav.moving : this.state.moving;
+            this[`section${label}`] && this[`section${label}`].scrollIntoView();
+            return {
+                moving,
+                indicator: label
+            }
+        });
+    }
+
+    render() {
+        const { labels, indicator, moving, loading, searchArea } = this.state;
+
+        if (loading) {
+            return null;
+        }
+
+        return (
+            <div className="city">
+                <div className="city-top">
+                    <NavBar
+                        className="navbar"
+                        mode="dark"
+                        icon={ <Icon type="left" /> }
+                        leftContent="返回"
+                        onLeftClick={ () => this.props.history.goBack() }
+                        >选择城市</NavBar>
+                    <SearchBar 
+                        placeholder="请输入你要选择的城市" 
+                        maxLength={ 8 } 
+                        onChange={ this.onSearchInput } /> 
+                </div>
+                <div className="city-list">
+                    <div className="city-search-area">
+                        { searchArea && this.renderSearchCity() }
+                    </div> 
+                    <SearchMask show={ searchArea } city={ this.state.searchCities } />
+                    <div className="city-list-content">
+                        { this.renderLocalCity() }
+                        <WhiteSpace size="md" />
+                        { this.renderHotCity() }
+                        { this.renderCities() }
+                        <Indicator indicator={ indicator } />
+                    </div>
+                    <div className="city-label">
+                        <IndexNav labels={ labels } moving={ moving } onNavChange={ this.onNavChange } />
+                    </div>
+                </div>
+            </div>
+                
+        );
+    }
+}
+
+export default City;

+ 84 - 0
src/pages/city/city.css

@@ -0,0 +1,84 @@
+.city {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column; }
+  .city-top {
+    min-height: 1px; }
+  .city-list {
+    width: 100%;
+    flex: 1;
+    display: flex;
+    position: relative; }
+  .city-search-area {
+    width: 100%;
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: 3; }
+  .city-local, .city-hot {
+    background-color: #fff; }
+    .city-local p, .city-hot p {
+      padding: 15px 20px;
+      font-size: 16px;
+      font-weight: 900; }
+    .city-local ul, .city-hot ul {
+      padding: 0 30px 5px; }
+  .city-item {
+    display: inline-block;
+    border: 1px solid #ccc;
+    padding: 5px 10px 3px 10px;
+    border-radius: 3px;
+    margin-right: 10px;
+    text-align: center;
+    margin-bottom: 10px; }
+  .city-location {
+    width: 15px;
+    height: 15px;
+    display: inline-block;
+    background: url(../../static/images/location.svg) center no-repeat;
+    background-size: 15px 15px; }
+  .city-local-text, .city-hot-text {
+    display: inline-block;
+    vertical-align: 3px;
+    margin-left: 3px; }
+  .city-list-content {
+    flex: 1;
+    overflow-y: auto; }
+  .city-label {
+    width: 20px;
+    height: 100%;
+    border-left: 1px solid #ddd;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: #fff; }
+  .city-label-wrapper {
+    display: flex;
+    flex-direction: column; }
+  .city-label-item {
+    text-align: center;
+    width: 100%;
+    flex: 1;
+    padding: 2px 0; }
+  .city-indicator {
+    position: absolute;
+    padding: 20px 25px;
+    background-color: rgba(0, 0, 0, 0.7);
+    color: #fff;
+    font-size: 18px;
+    border-radius: 5px;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1; }
+  .city-search-menu .am-list-item {
+    border-bottom: 1px solid #ddd !important; }
+  .city-menu-mask {
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    top: 0;
+    left: 0;
+    background-color: rgba(0, 0, 0, 0.3);
+    z-index: 2; }

+ 103 - 0
src/pages/city/city.scss

@@ -0,0 +1,103 @@
+.city {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    &-top {
+        min-height: 1px;
+    }
+    &-list {
+       width: 100%;
+       flex: 1;
+       display: flex;
+       position: relative;
+    }  
+    &-search-area {
+        width: 100%;
+        position: absolute;
+        top: 0;
+        left: 0;
+        z-index: 3;
+    }
+    &-local, &-hot {
+        background-color: #fff;
+        p {
+            padding: 15px 20px;
+            font-size: 16px;
+            font-weight: 900;
+        }
+        ul {    
+            padding: 0 30px 5px;
+        }
+    }
+    &-item {    
+        display: inline-block;
+        border: 1px solid #ccc;
+        padding: 5px 10px 3px 10px;
+        border-radius: 3px;
+        margin-right: 10px;
+        text-align: center;
+        margin-bottom: 10px;
+    }
+    &-location {
+        width: 15px;
+        height: 15px;
+        display: inline-block;
+        background: url(../../static/images/location.svg) center no-repeat;
+        background-size: 15px 15px;
+    }
+    &-local-text, &-hot-text {
+        display: inline-block;
+        vertical-align: 3px;
+        margin-left: 3px;
+    }
+    &-list-content {
+        flex: 1;
+        overflow-y: auto; 
+    }
+    &-label {
+        width: 20px;
+        height: 100%;
+        border-left: 1px solid #ddd;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background-color: #fff;
+    }
+    &-label-wrapper {
+        display: flex;
+        flex-direction: column;
+    }
+    &-label-item {
+        text-align: center;
+        width: 100%;
+        flex: 1;
+        padding: 2px 0;
+    }
+    &-indicator {
+        position: absolute;
+        padding:  20px 25px;
+        background-color: rgba(0, 0, 0, 0.7);
+        color: #fff;
+        font-size: 18px;
+        border-radius: 5px;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        z-index: 1;
+    }
+    &-search-menu {
+        .am-list-item {
+            border-bottom: 1px solid #ddd!important;
+        }
+    }
+    &-menu-mask {
+        width: 100%;
+        height: 100%;
+        position: absolute;
+        top: 0;
+        left: 0;
+        background-color: rgba(0, 0, 0, 0.3);
+        z-index: 2;
+    }
+}

+ 163 - 0
src/services/cityServices.js

@@ -0,0 +1,163 @@
+import axios from 'axios';
+import { 
+    isEnglishString, 
+    isChineseString,
+    saveLocalStorage,
+    getLocalStorage 
+} from '../utils';
+
+const hotCityLabels = ['北京', '上海', '深圳', '广州', '武汉', '成都'];
+const LASTEST_CITY = 'LASTEST_CITY';
+const CITY_API = '/waether/upload/weather/json/NationalUrbanData.min.json';
+
+function initialAllCity() {
+    const city = {};
+    for (let i = 0; i < 26; i++) {
+        city[String.fromCharCode(65 + i)] = [];
+    }
+    return city;
+}
+
+function filterCity(city) {
+    const validateLetters = [];
+    const allCity = {};
+    Object.keys(city).forEach(c => {
+        const cityCollectoin = city[c];
+        if (cityCollectoin.length > 0) {
+            validateLetters.push(c);
+            allCity[c] = city[c];
+        }
+    });
+
+    return {
+        validateLetters,
+        allCity
+    }
+}
+
+function formatCites(json) {
+    const beforeFilterAllCity = initialAllCity();
+    const hotCity = [];
+    const city = json.data.city;
+
+    Object.keys(city).forEach(c => {
+        const cityCollection = city[c] || [];
+        cityCollection.forEach(cc => {
+            const firstLetter = cc.en && cc.en[0].toUpperCase();
+            if (cc.en === 'hongkong') {
+                cc.en = 'xianggang';
+                beforeFilterAllCity['X'].push(cc);
+            } else {
+                beforeFilterAllCity[firstLetter].push(cc);
+            }
+            
+            if (hotCityLabels.includes(cc.city)) {
+                hotCity.push(cc);
+            }
+        })
+    });
+
+    const afterFilterAllCity = filterCity(beforeFilterAllCity);
+
+    return {
+        hotCity,
+        afterFilterAllCity
+    };
+}
+
+function hasInCityResultList(city, result) {
+    return result.some(r => {
+        return r.id === city.id;
+    });
+}
+
+function searchCityListByChineseKey(key, allCity) {
+    const result = [];
+
+    Object.keys(allCity).forEach(cityCode => {
+        const cityCollection = allCity[cityCode];
+        cityCollection.forEach(city => {
+            if (city.city.includes(key) && !hasInCityResultList(city, result)) {
+                result.push(city);
+            }
+        });
+    });
+
+    return result;
+}
+
+function searchCityListByEnglishKey(key, labels, allCity) {
+    let result = [];
+    const firstLetter = key.toUpperCase()[0];
+
+    if (!labels.includes(firstLetter)) {
+        return result;
+    }
+    
+    allCity[firstLetter].forEach(city => {
+        if (city.en.includes(key) && !hasInCityResultList(city, result)) {
+            result.push(city);
+        }
+    });
+
+    return result;
+}
+
+function searchCityByName(key, labels, allCity) {
+    let result = [];
+
+    if (!key.length) {
+        return result;
+    }
+    
+    if (isChineseString(key)) {
+        result = searchCityListByChineseKey(key, allCity);
+    } else if (isEnglishString(key)) {
+        result = searchCityListByEnglishKey(key, labels, allCity);
+    }
+
+    return result;
+}
+
+async function getAllCities() {
+    const json = await axios.get(CITY_API);
+    return formatCites(json);
+}
+
+function saveLocalStorageCity(key, city) {
+    const lastestCity = getLocalStorageCity(key);
+
+    if (!lastestCity.includes(city)) {
+        if (lastestCity.length >= 2) {
+            lastestCity.splice(0, 1);
+        }
+        lastestCity.push(city);
+        saveLocalStorage(key, lastestCity.join(':'));
+    }
+
+    return lastestCity;
+}
+
+function getLocalStorageCity(key) {
+    const cityInfo = getLocalStorage(key);
+    return cityInfo ? cityInfo.split(':') : []; 
+}
+
+function transformCityMenuData(city) {
+    return city.map(c => ({ value: c.city, label: c.city }));
+}
+
+export {
+    LASTEST_CITY,
+    getAllCities,
+    searchCityByName,
+    initialAllCity,
+    filterCity,
+    formatCites,
+    hasInCityResultList,
+    searchCityListByChineseKey,
+    searchCityListByEnglishKey,
+    saveLocalStorageCity,
+    getLocalStorageCity,
+    transformCityMenuData
+}

+ 12 - 0
src/services/locationServices.js

@@ -0,0 +1,12 @@
+async function getLocalCity() {
+    return new Promise(resolve => {
+        var myCity = new window.BMap.LocalCity();
+        myCity.get(result => {
+            resolve(result.name);
+        });
+    }); 
+}
+
+export {
+    getLocalCity
+};

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
src/static/images/location.svg


Fichier diff supprimé car celui-ci est trop grand
+ 7 - 0
src/static/images/logo.svg


+ 117 - 0
src/static/scripts/registerServiceWorker.js

@@ -0,0 +1,117 @@
+// In production, we register a service worker to serve assets from local cache.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on the "N+1" visit to a page, since previously
+// cached resources are updated in the background.
+
+// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
+// This link also includes instructions on opting out of this behavior.
+
+const isLocalhost = Boolean(
+  window.location.hostname === 'localhost' ||
+    // [::1] is the IPv6 localhost address.
+    window.location.hostname === '[::1]' ||
+    // 127.0.0.1/8 is considered localhost for IPv4.
+    window.location.hostname.match(
+      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+    )
+);
+
+export default function register() {
+  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+    // The URL constructor is available in all browsers that support SW.
+    const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
+    if (publicUrl.origin !== window.location.origin) {
+      // Our service worker won't work if PUBLIC_URL is on a different origin
+      // from what our page is served on. This might happen if a CDN is used to
+      // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
+      return;
+    }
+
+    window.addEventListener('load', () => {
+      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+      if (isLocalhost) {
+        // This is running on localhost. Lets check if a service worker still exists or not.
+        checkValidServiceWorker(swUrl);
+
+        // Add some additional logging to localhost, pointing developers to the
+        // service worker/PWA documentation.
+        navigator.serviceWorker.ready.then(() => {
+          console.log(
+            'This web app is being served cache-first by a service ' +
+              'worker. To learn more, visit https://goo.gl/SC7cgQ'
+          );
+        });
+      } else {
+        // Is not local host. Just register service worker
+        registerValidSW(swUrl);
+      }
+    });
+  }
+}
+
+function registerValidSW(swUrl) {
+  navigator.serviceWorker
+    .register(swUrl)
+    .then(registration => {
+      registration.onupdatefound = () => {
+        const installingWorker = registration.installing;
+        installingWorker.onstatechange = () => {
+          if (installingWorker.state === 'installed') {
+            if (navigator.serviceWorker.controller) {
+              // At this point, the old content will have been purged and
+              // the fresh content will have been added to the cache.
+              // It's the perfect time to display a "New content is
+              // available; please refresh." message in your web app.
+              console.log('New content is available; please refresh.');
+            } else {
+              // At this point, everything has been precached.
+              // It's the perfect time to display a
+              // "Content is cached for offline use." message.
+              console.log('Content is cached for offline use.');
+            }
+          }
+        };
+      };
+    })
+    .catch(error => {
+      console.error('Error during service worker registration:', error);
+    });
+}
+
+function checkValidServiceWorker(swUrl) {
+  // Check if the service worker can be found. If it can't reload the page.
+  fetch(swUrl)
+    .then(response => {
+      // Ensure service worker exists, and that we really are getting a JS file.
+      if (
+        response.status === 404 ||
+        response.headers.get('content-type').indexOf('javascript') === -1
+      ) {
+        // No service worker found. Probably a different app. Reload the page.
+        navigator.serviceWorker.ready.then(registration => {
+          registration.unregister().then(() => {
+            window.location.reload();
+          });
+        });
+      } else {
+        // Service worker found. Proceed as normal.
+        registerValidSW(swUrl);
+      }
+    })
+    .catch(() => {
+      console.log(
+        'No internet connection found. App is running in offline mode.'
+      );
+    });
+}
+
+export function unregister() {
+  if ('serviceWorker' in navigator) {
+    navigator.serviceWorker.ready.then(registration => {
+      registration.unregister();
+    });
+  }
+}

+ 18 - 0
src/static/styles/index.css

@@ -0,0 +1,18 @@
+body {
+  margin: 0;
+  padding: 0;
+  font-family: sans-serif; }
+
+html, body, #root {
+  width: 100%;
+  height: 100%; }
+
+html, body, div, ul, li, p {
+  margin: 0;
+  padding: 0; }
+
+ul, li {
+  list-style: none; }
+
+.navbar {
+  background-color: #2f3542 !important; }

+ 19 - 0
src/static/styles/index.scss

@@ -0,0 +1,19 @@
+body {
+  margin: 0;
+  padding: 0;
+  font-family: sans-serif;
+}
+html, body, #root {
+  width: 100%;
+  height: 100%;
+}
+html, body, div, ul, li, p {
+  margin: 0;
+  padding: 0;
+}
+ul, li {
+  list-style: none;
+}
+.navbar {
+  background-color: #2f3542!important;
+}

+ 9 - 0
src/test/App.test.js

@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import App from '../pages/app/App';
+
+it('renders without crashing', () => {
+  const div = document.createElement('div');
+  ReactDOM.render(<App />, div);
+  ReactDOM.unmountComponentAtNode(div);
+});

+ 85 - 0
src/test/city-service.test.js

@@ -0,0 +1,85 @@
+import 'jest-localstorage-mock';
+import {
+    initialAllCity,
+    searchCityByName,
+    hasInCityResultList,
+    searchCityListByChineseKey,
+    searchCityListByEnglishKey,
+    saveLocalStorageCity,
+    transformCityMenuData
+} from '../services/cityServices';
+
+const allCity = {
+    'A': [
+        { id: '111', city: '鞍山', en: 'anshan' },
+        { id: '222', city: '安康', en: 'ankang' }
+    ],
+    'B': [
+        { id: '333', city: '包头', en: 'baotou' }
+    ],
+    'H': [
+        { id: '444', city: '洪湖', en: 'honghu' },
+        { id: '666', city: '黄山', en: 'huangshan' }
+    ],
+    'X': [
+        { id: '555', city: '香港', en: 'xianggang' }
+    ]
+};
+
+const labels = ['A', 'B', 'H', 'X'];
+
+const result = [
+    { id: '333', city: '包头', en: 'baotou' },
+    { id: '444', city: '洪湖', en: 'honghu' }
+];
+
+it('city list length should be 26', () => {
+    expect(Object.keys(initialAllCity()).length).toBe(26);
+}); 
+
+it('has in city result list', () => {
+    const city1 = { id: '333', city: '包头', en: 'baotou' };
+    const city2 = { id: '555', city: '香港', en: 'hongkong' };
+    expect(hasInCityResultList(city1, result)).toBeTruthy();
+    expect(hasInCityResultList(city2, result)).not.toBeTruthy();
+});
+
+it('search city list by chinese key', () => {
+    expect(searchCityListByChineseKey('包', allCity).length).toBe(1);
+    expect(searchCityListByChineseKey('黄', allCity).length).toBe(1);
+    expect(searchCityListByChineseKey('山', allCity).length).toBe(2);
+    expect(searchCityListByChineseKey('香', allCity).length).toBe(1);
+    expect(searchCityListByChineseKey('北', allCity).length).toBe(0);
+});
+
+it('search city list by english key', () => {
+    expect(searchCityListByEnglishKey('h', labels, allCity).length).toBe(2);
+    expect(searchCityListByEnglishKey('x', labels, allCity).length).toBe(1);
+    expect(searchCityListByEnglishKey('s', labels, allCity).length).toBe(0);
+});
+
+it('search city by name', () => {
+    expect(searchCityByName('h', labels, allCity).length).toBe(2);
+    expect(searchCityByName('s', labels, allCity).length).toBe(0);
+    expect(searchCityByName('北', labels, allCity).length).toBe(0);
+    expect(searchCityByName('山', labels, allCity).length).toBe(2);
+});
+
+it('test save lastest city', () => {
+    let lastestCity = saveLocalStorageCity('lastest', '成都');
+    expect(lastestCity[0]).toBe('成都');
+
+    lastestCity = saveLocalStorageCity('lastest', '成都');
+    expect(lastestCity[0]).toBe('成都');
+
+    lastestCity = saveLocalStorageCity('lastest', '武汉');
+    expect(lastestCity[1]).toBe('武汉');
+
+    lastestCity = saveLocalStorageCity('lastest', '深圳');
+    expect(lastestCity[0]).toBe('武汉');
+});
+
+it('transform city data', () => {
+    const tCity = transformCityMenuData(result);
+    expect(tCity[0].label).toBe('包头');
+});

+ 67 - 0
src/test/utils.test.js

@@ -0,0 +1,67 @@
+import 'jest-localstorage-mock';
+import { 
+    isChineseString, 
+    isEnglishString,
+    isJSONString,
+    saveLocalStorage,
+    getLocalStorage 
+} from '../utils';
+
+it('中文字符', () => {
+    const c1 = 'A';
+    const c2 = 'a';
+    const c3 = 'abA';
+    const c4 = '中';
+    const c5 = '中文';
+    const c6 = '-';
+    expect(isChineseString(c1)).not.toBeTruthy();
+    expect(isChineseString(c2)).not.toBeTruthy();
+    expect(isChineseString(c3)).not.toBeTruthy();
+    expect(isChineseString(c4)).toBeTruthy();
+    expect(isChineseString(c5)).toBeTruthy();
+    expect(isChineseString(c6)).not.toBeTruthy();
+});
+
+it('英文字符串', () => {
+    const c1 = 'A';
+    const c2 = 'a';
+    const c3 = 'abA';
+    const c4 = '中';
+    const c5 = '中文';
+    const c6 = '-';
+    expect(isEnglishString(c1)).toBeTruthy();
+    expect(isEnglishString(c2)).toBeTruthy();
+    expect(isEnglishString(c3)).toBeTruthy();
+    expect(isEnglishString(c4)).not.toBeTruthy();
+    expect(isEnglishString(c5)).not.toBeTruthy();
+    expect(isEnglishString(c6)).not.toBeTruthy();
+});
+
+it('获取localstorage', () => {
+    const key = 'testSaveLocalStorage';
+    const value1 = 'test1';
+    const value2 =  '{ "a": 1 }';
+
+    saveLocalStorage(key, value1);
+    const result1 = getLocalStorage(key);
+    expect(result1).toBe(value1);
+
+    saveLocalStorage(key, value2);
+    const result2 = getLocalStorage(key);
+    expect(result2.a).toBe(1);
+});
+
+it('是否是JSON字符串', () => {
+    const value1 = 'test';
+    const value2 = null;
+    const value3 = undefined;
+    const value4 = {};
+    const value5 = 1;
+    const value6 = '{ "a": 1 }';
+    expect(isJSONString(value1)).not.toBeTruthy();
+    expect(isJSONString(value2)).toBeTruthy();
+    expect(isJSONString(value3)).not.toBeTruthy();
+    expect(isJSONString(value4)).not.toBeTruthy();
+    expect(isJSONString(value5)).toBeTruthy();
+    expect(isJSONString(value6)).toBeTruthy();
+});

+ 63 - 0
src/utils/index.js

@@ -0,0 +1,63 @@
+function isChineseString(c) {
+    const reg = /^[\u4E00-\u9FA5\uf900-\ufa2d]/;
+    return reg.test(c);
+}
+
+function isEnglishString(c) {
+    const reg = /^[a-zA-Z]/;
+    return reg.test(c);
+}
+
+function isJSONString(string) {
+    try {
+        JSON.parse(string);
+        return true;
+    } catch (e) {
+        return false;
+    }
+}
+
+function saveLocalStorage(key, obj) {
+    if (typeof obj === 'string') {
+        window.localStorage.setItem(key, obj);
+    } else {
+        window.localStorage.setItem(key, JSON.stringify(obj));
+    }
+}
+
+function getLocalStorage(key) {
+    const result = window.localStorage.getItem(key);
+    if (isJSONString(result)) {
+        return JSON.parse(result);
+    }
+
+    return result;
+}
+
+function throttle(fn, wait = 500, period = 1000) {
+    let startTime = new Date().getTime();
+    let timeout;
+    return (...args) => {
+        return new Promise(resolve => {
+            const now = new Date().getTime();
+            if (now - startTime >= period) {
+                startTime = now;
+                resolve(fn.apply(null, args));
+            } else {
+                timeout && clearTimeout(timeout);
+                timeout = setTimeout(() => {
+                    resolve(fn.apply(null, args));
+                }, wait);
+            }
+        }); 
+    }
+}
+
+export {
+    isChineseString,
+    isEnglishString,
+    saveLocalStorage,
+    getLocalStorage,
+    isJSONString,
+    throttle
+}