├── src ├── store │ ├── actionTypes │ │ ├── tab.js │ │ ├── user.js │ │ └── houseList.js │ ├── reducers │ │ ├── index.js │ │ ├── tab.js │ │ ├── user.js │ │ └── houseList.js │ ├── actions │ │ ├── tab.js │ │ ├── user.js │ │ └── houseList.js │ └── index.js ├── images │ └── default.png ├── history.js ├── components │ ├── House │ │ ├── index.js │ │ ├── List.jsx │ │ └── Item.jsx │ ├── Tabs │ │ ├── index.js │ │ ├── Mine.jsx │ │ └── HouseList.jsx │ ├── Empty.jsx │ ├── SvgIcon.jsx │ ├── WarnTips.jsx │ ├── BackTop.jsx │ ├── Pagination.jsx │ ├── Button.jsx │ ├── Header.jsx │ ├── LazyImage.jsx │ ├── CanvasBg.jsx │ └── Filters.jsx ├── styles │ ├── variables.scss │ ├── index.scss │ └── mixins.scss ├── index.js ├── router │ ├── index.js │ ├── AuthRouteContainer.js │ └── routes.js ├── util │ ├── request.js │ ├── index.js │ ├── canvasBg.js │ └── filterMenuItem.js ├── api │ └── index.js ├── views │ ├── About.jsx │ ├── UserLikes.jsx │ ├── Tabs.jsx │ ├── HouseSearch.jsx │ ├── Login.jsx │ └── HouseDetail.jsx └── registerServiceWorker.js ├── public ├── favicon.ico ├── manifest.json └── index.html ├── server ├── config.js ├── controllers │ ├── index.js │ ├── User.js │ └── House.js ├── index.js ├── middlewares │ └── verify.js ├── route.js ├── db.js ├── util.js └── robot.js ├── README.md ├── .gitignore ├── postcss.config.js ├── config-overrides.js └── package.json /src/store/actionTypes/tab.js: -------------------------------------------------------------------------------- 1 | export const SET_ACTIVE_TAB = 'set_active_tab'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeojongki/douban-house/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/images/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeojongki/douban-house/HEAD/src/images/default.png -------------------------------------------------------------------------------- /src/store/actionTypes/user.js: -------------------------------------------------------------------------------- 1 | export const SET_USER = 'set_user'; 2 | export const LOG_OUT = 'log_out'; 3 | -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export default createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /src/components/House/index.js: -------------------------------------------------------------------------------- 1 | import Item from './Item'; 2 | import List from './List'; 3 | 4 | export { Item, List }; 5 | -------------------------------------------------------------------------------- /src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $main-color: #108ee9; 2 | $db-color: #00B51D; 3 | $gray-bg: #f5f5f9; 4 | $placeholder: #b5b5b5; -------------------------------------------------------------------------------- /src/components/Tabs/index.js: -------------------------------------------------------------------------------- 1 | import HouseList from './HouseList'; 2 | import Mine from './Mine'; 3 | 4 | export { HouseList, Mine }; 5 | -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mongoUrl: 'mongodb://localhost:27017/douban-new', 3 | secret: 'yeojongki', 4 | expiresIn: '2h' 5 | }; 6 | -------------------------------------------------------------------------------- /server/controllers/index.js: -------------------------------------------------------------------------------- 1 | const User = require('./User'); 2 | const House = require('./House'); 3 | 4 | module.exports = { 5 | House, 6 | User 7 | }; 8 | -------------------------------------------------------------------------------- /src/store/reducers/index.js: -------------------------------------------------------------------------------- 1 | import houseList from './houseList'; 2 | import tab from './tab'; 3 | import user from './user'; 4 | 5 | export { houseList, tab, user }; 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 豆瓣租房 2 | ```js 3 | // 你可以使用npm或yarn 4 | yarn install 5 | 运行你的数据库 // 必须!!! 6 | yarn server // 运行服务器, 连接的数据库在server目录下的config.js里配置 7 | yarn start // 运行项目 8 | ``` 9 | -------------------------------------------------------------------------------- /src/store/actions/tab.js: -------------------------------------------------------------------------------- 1 | import * as types from '../actionTypes/tab'; 2 | 3 | // 设置当前tab 4 | export const setActiveTab = tab => ({ 5 | type: types.SET_ACTIVE_TAB, 6 | tab 7 | }); 8 | -------------------------------------------------------------------------------- /src/store/actions/user.js: -------------------------------------------------------------------------------- 1 | import * as types from '../actionTypes/user'; 2 | 3 | // 设置user信息 4 | export const setUser = user => ({ 5 | type: types.SET_USER, 6 | user: user 7 | }); 8 | 9 | // 登出 10 | export const logout = () => ({ 11 | type: types.LOG_OUT 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/Empty.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default props => ( 4 |
5 | {props.text} 6 | 13 |
14 | ); 15 | -------------------------------------------------------------------------------- /src/store/reducers/tab.js: -------------------------------------------------------------------------------- 1 | import * as types from '../actionTypes/tab'; 2 | 3 | const defaultState = { 4 | tab: 'listTab' 5 | }; 6 | 7 | export default (state = defaultState, action) => { 8 | switch (action.type) { 9 | case types.SET_ACTIVE_TAB: 10 | return { ...state, tab: action.tab }; 11 | default: 12 | return state; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const app = new Koa(); 3 | const bodyParser = require('koa-bodyparser'); 4 | const router = require('./route'); 5 | const Robot = require('./robot'); 6 | 7 | app.use(bodyParser()); 8 | new Robot(); 9 | 10 | app.use(router.routes()).listen(3003, () => { 11 | console.log(`server start at http://localhost:3003`); 12 | }); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/store/actionTypes/houseList.js: -------------------------------------------------------------------------------- 1 | export const SET_HOUSE_LIST = 'set_house_list'; 2 | export const SET_SCROLL_HEIGHT = 'set_scroll_height'; 3 | export const SET_LOADING = 'set_loading'; 4 | export const CHANGE_PAGE = 'change_page'; 5 | export const REFRESH_LIST = 'refresh_list'; 6 | export const LOADMORE_LIST = 'loadmore_list'; 7 | export const SET_SCROLL_TOP = 'set_scroll_top'; 8 | export const SET_QUERY = 'set_query'; 9 | export const SET_SELECTED_MENU = 'set_selected_menu'; 10 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './variables.scss'; 2 | @import './mixins.scss'; 3 | 4 | body { 5 | font-family: sans-serif; 6 | color: #333; 7 | margin:0; 8 | padding:0; 9 | } 10 | 11 | a { 12 | color: #333; 13 | } 14 | 15 | img { 16 | content: normal !important; 17 | } 18 | 19 | .flexbox { 20 | display: flex; 21 | &.ac { 22 | align-items: center; 23 | } 24 | &.jc { 25 | justify-content: center; 26 | } 27 | } 28 | 29 | .border1px { 30 | @include border1px(); 31 | } 32 | -------------------------------------------------------------------------------- /server/middlewares/verify.js: -------------------------------------------------------------------------------- 1 | const { errorRet, verifyToken } = require('../util'); 2 | 3 | module.exports = async (ctx, next) => { 4 | // get header token 5 | const token = ctx.header['x-token']; 6 | if (token) { 7 | try { 8 | let decode = verifyToken(token); 9 | ctx.username = decode.username; 10 | await next(); 11 | } catch (error) { 12 | ctx.body = errorRet(`${error.message}`); 13 | } 14 | } else { 15 | ctx.body = errorRet(`Token is required`); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/store/reducers/user.js: -------------------------------------------------------------------------------- 1 | import * as types from '../actionTypes/user'; 2 | import Cookie from 'js-cookie'; 3 | 4 | const defaultState = { 5 | username: Cookie.get('username') || '', 6 | token: Cookie.get('token') || '' 7 | }; 8 | 9 | export default (state = defaultState, action) => { 10 | switch (action.type) { 11 | case types.SET_USER: 12 | return { ...state, ...action.user }; 13 | case types.LOG_OUT: 14 | return { ...state, username: '', token: '' }; 15 | default: 16 | return state; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/SvgIcon.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | 3 | export default props => ( 4 | 5 | 11 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /src/components/WarnTips.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
5 |
- 温馨提示 -
6 |

7 | 房源信息仅供参考,实际信息以原贴为准。此应用仅提供房源信息展示空间,不保证房源信息的真实、合法、有效。再次提醒您:在看房及租房时注意人身安全与财产安全,建议您与房东签订书面租赁合同。 8 |

9 | 24 |
25 | ); 26 | -------------------------------------------------------------------------------- /server/route.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const verifyMiddleware = require('./middlewares/verify'); 3 | const { User, House } = require('./controllers'); 4 | 5 | const router = new Router({ 6 | prefix: '/api' 7 | }); 8 | 9 | // get house list 10 | router.post('/list', House.GetList); 11 | // get house detail 12 | router.get('/house/:tid', House.GetDetail); 13 | // handle login 14 | router.post('/login', User.Login); 15 | // handle user like 16 | router.post('/like', verifyMiddleware, User.Like); 17 | // get user like list 18 | router.post('/likes', verifyMiddleware, User.LikeList); 19 | 20 | module.exports = router; 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { ConnectedRouter } from 'connected-react-router'; 5 | import store from './store'; 6 | import router from './router'; 7 | import history from './history'; 8 | // import registerServiceWorker from './registerServiceWorker'; 9 | 10 | const render = () => { 11 | ReactDOM.render( 12 | 13 | 14 | {router} 15 | 18 | 19 | , 20 | document.getElementById('root') 21 | ); 22 | }; 23 | 24 | render(); 25 | // registerServiceWorker(); 26 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import routes from './routes'; 3 | import { Route, Switch } from 'react-router-dom'; 4 | import AuthRouteContainer from './AuthRouteContainer'; 5 | 6 | export default ( 7 | 8 | 9 | {routes.map(route => { 10 | return route.auth ? ( 11 | 17 | ) : ( 18 | 24 | ); 25 | })} 26 | 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /src/components/BackTop.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Icon } from 'antd-mobile'; 3 | 4 | export default props => ( 5 | 6 | 9 | 25 | ) 26 | -------------------------------------------------------------------------------- /src/components/Pagination.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const styles = { 5 | root: { 6 | position: 'absolute', 7 | bottom: 8, 8 | right: 8, 9 | display: 'flex', 10 | flexDirection: 'row' 11 | }, 12 | inner: { 13 | backgroundColor: 'rgba(0,0,0,.6)', 14 | padding: '5px 10px', 15 | borderRadius: 5, 16 | color: '#fff' 17 | } 18 | }; 19 | 20 | class Pagination extends React.Component { 21 | render() { 22 | const { index, dots } = this.props; 23 | return ( 24 |
25 | 26 | {index + 1}/{dots} 27 | 28 |
29 | ); 30 | } 31 | } 32 | 33 | Pagination.propTypes = { 34 | dots: PropTypes.number.isRequired, 35 | index: PropTypes.number.isRequired 36 | }; 37 | 38 | export default Pagination; 39 | -------------------------------------------------------------------------------- /src/router/AuthRouteContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | 5 | class AuthRouteContainer extends React.Component { 6 | render() { 7 | const { isLogin, component: Component, ...props } = this.props; 8 | 9 | return ( 10 | 13 | isLogin ? ( 14 | 15 | ) : ( 16 | 22 | ) 23 | } 24 | /> 25 | ); 26 | } 27 | } 28 | 29 | const mapStateToProps = state => ({ 30 | isLogin: Boolean(state.user.token) 31 | }); 32 | 33 | export default connect(mapStateToProps)(AuthRouteContainer); 34 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const postcssPxToViewport = require('postcss-px-to-viewport'); 2 | const postcssViewportUnits = require('postcss-viewport-units'); 3 | const postcssWriteSvg = require('postcss-write-svg'); 4 | 5 | module.exports = () => ({ 6 | plugins: [ 7 | postcssWriteSvg(), 8 | postcssPxToViewport({ 9 | viewportWidth: 750, // (Number) The width of the viewport. 10 | viewportHeight: 1334, // (Number) The height of the viewport. 11 | unitPrecision: 3, // (Number) The decimal numbers to allow the REM units to grow to. 12 | viewportUnit: 'vw', // (String) Expected units. 13 | selectorBlackList: ['.ignore', '.hairlines'], // (Array) The selectors to ignore and leave as px. 14 | minPixelValue: 1, // (Number) Set the minimum pixel value to replace. 15 | mediaQuery: false // (Boolean) Allow px to be converted in media queries. 16 | }), 17 | postcssViewportUnits() 18 | ] 19 | }); 20 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, combineReducers, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { connectRouter, routerMiddleware } from 'connected-react-router'; 4 | import * as reducers from './reducers'; 5 | import history from '../history'; 6 | 7 | const isDev = process.env.NODE_ENV === 'development'; 8 | 9 | const middleware = [routerMiddleware(history), thunk]; 10 | 11 | const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 12 | 13 | const rootReducer = combineReducers({ ...reducers }); 14 | 15 | const store = createStore( 16 | connectRouter(history)(rootReducer), 17 | isDev 18 | ? composeEnhancer(applyMiddleware(...middleware)) 19 | : applyMiddleware(...middleware) 20 | ); 21 | 22 | if (module.hot) { 23 | // Reload reducers 24 | module.hot.accept('./reducers', () => { 25 | store.replaceReducer(connectRouter(history)(rootReducer)); 26 | }); 27 | } 28 | 29 | export default store; 30 | -------------------------------------------------------------------------------- /src/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | export default props => ( 3 | 30 | ); 31 | -------------------------------------------------------------------------------- /src/util/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Toast } from 'antd-mobile'; 3 | import Cookie from 'js-cookie'; 4 | const BASE_URL = '/api'; 5 | 6 | // 创建axios实例 7 | const service = axios.create({ 8 | baseURL: BASE_URL, 9 | timeout: 1000 * 15 // 请求超时时间 10 | }); 11 | 12 | // request拦截器 13 | service.interceptors.request.use( 14 | config => { 15 | config.withCredentials = false; 16 | let token = Cookie.get('token'); 17 | if (token) { 18 | config.headers['X-Token'] = token; 19 | } 20 | return config; 21 | }, 22 | error => { 23 | Promise.reject(error); 24 | } 25 | ); 26 | 27 | // respone拦截器 28 | service.interceptors.response.use( 29 | response => { 30 | const res = response.data; 31 | if (res.code === 0) { 32 | Toast.show(res.msg); 33 | } else { 34 | return response.data; 35 | } 36 | }, 37 | error => { 38 | if (error.message === 'Request failed with status code 500') { 39 | Toast.show('服务器出错'); 40 | } else { 41 | Toast.show(error.message); 42 | } 43 | return Promise.reject(error); 44 | } 45 | ); 46 | 47 | export default service; 48 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const { getBabelLoader, injectBabelPlugin } = require('react-app-rewired'); 2 | const path = require('path'); 3 | 4 | function resolve(dir) { 5 | return path.resolve(__dirname, dir); 6 | } 7 | 8 | module.exports = function rewire(config) { 9 | // add antd-mobile 10 | config = injectBabelPlugin( 11 | ['import', { libraryName: 'antd-mobile', style: 'css' }], 12 | config 13 | ); 14 | 15 | // add styled-jsx plugin 16 | const babelOptions = getBabelLoader( 17 | config.module.rules, 18 | rule => String(rule.test) === String(/\.(js|jsx|mjs)$/) 19 | ).options; 20 | let babelrc = require(babelOptions.presets[0]); 21 | babelrc.plugins = [ 22 | [ 23 | 'styled-jsx/babel', 24 | { 25 | plugins: ['styled-jsx-plugin-sass', 'styled-jsx-plugin-postcss'] 26 | } 27 | ] 28 | ].concat(babelrc.plugins || []); 29 | babelOptions.presets = babelrc; 30 | 31 | // add alias 32 | let originAlias = config.resolve.alias; 33 | config.resolve.alias = Object.assign(originAlias, { 34 | '@': resolve('src'), 35 | 'comp': resolve('src/components') 36 | }); 37 | 38 | return config; 39 | }; 40 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | // style-jsx method 2 | export function resolveScopedStyles(scope) { 3 | return { 4 | className: scope.props.className, // 就是被styled-jsx添加的独特className 5 | styles: scope.props.children // 就是style,需要注入到组件中 6 | }; 7 | } 8 | 9 | // set sessionStorage/localStorge 10 | export const setStorageByKey = (key, value, type = 's') => { 11 | let t = type === 's' ? 'sessionStorage' : 'localStorage'; 12 | try { 13 | value = JSON.stringify(value); 14 | window[t].setItem(key, value); 15 | } catch (e) { 16 | console.error(`JSON stringify storage error, key is <${key}>`); 17 | } 18 | }; 19 | 20 | // get sessionStorage/localStorge by key 21 | export const getStorageByKey = (key, type = 's') => { 22 | let t = type === 's' ? 'sessionStorage' : 'localStorage'; 23 | let result = window[t].getItem(key); 24 | if (result) { 25 | try { 26 | result = JSON.parse(result); 27 | } catch (e) { 28 | console.error(`JSON parse storage error, key is <${key}>`); 29 | } 30 | } 31 | return result; 32 | }; 33 | 34 | // crypto value 35 | export const sha1 = val => 36 | require('crypto') 37 | .createHash('sha1') 38 | .update(val) 39 | .digest('hex'); 40 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import request from '@/util/request'; 2 | 3 | /** 4 | * @desc根据房源tid获取详细信息 5 | * @param {Number/String} tid 6 | */ 7 | export const GetHouseById = tid => { 8 | return request({ 9 | url: `/house/${tid}` 10 | }); 11 | }; 12 | 13 | /** 14 | * @desc 获取房源列表(带搜索) 15 | * @param {Number} page 第几页 16 | * @param {Number} size 每页几条 17 | * @param {Array} filter 搜索参数 18 | */ 19 | export const GetHouseList = (page = 1, size = 30, filter = []) => { 20 | return request({ 21 | method: 'POST', 22 | data: { page, size, filter }, 23 | url: '/list' 24 | }); 25 | }; 26 | 27 | /** 28 | * @desc 登录 29 | * @param {Object} user {usename:'',password:''} 30 | */ 31 | export const AjaxLogin = user => { 32 | return request({ 33 | method: 'POST', 34 | data: user, 35 | url: '/login' 36 | }); 37 | }; 38 | 39 | /** 40 | * 用户点击喜欢/取消喜欢 41 | * @param {String/Number} tid 42 | * @param {Boolean} unlike 是否为不喜欢 43 | */ 44 | export const UserLikeHouse = (tid, unlike) => { 45 | return request({ 46 | method: 'POST', 47 | data: { tid, unlike }, 48 | url: '/like' 49 | }); 50 | }; 51 | 52 | /** 53 | * 用户喜欢列表 54 | */ 55 | export const GetUserLikeList = () => { 56 | return request({ 57 | method: 'POST', 58 | url: '/likes' 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icon } from 'antd-mobile'; 3 | import SvgIcon from 'comp/SvgIcon'; 4 | 5 | export default props => { 6 | const placeholder = props.placeholder; 7 | return ( 8 |
9 |
10 | 11 | 广州 12 |
13 |
14 | 15 | {placeholder ? placeholder : '请输入关键词'} 16 |
17 | 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /server/db.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const config = require('./config'); 3 | 4 | // connect 5 | mongoose.connect(config.mongoUrl); 6 | const db = mongoose.connection; 7 | db.once('error', () => { 8 | console.error('mongodb connect error'); 9 | }); 10 | db.once('open', () => { 11 | console.error('mongodb connect success'); 12 | }); 13 | 14 | // create house schema 15 | const houseSchema = new mongoose.Schema({ 16 | tid: String, 17 | author: String, 18 | title: String, 19 | content: String, 20 | ctime: String, 21 | ltime: String, 22 | price: Number, 23 | contact: Object, // 联系方式 24 | size: Number, // 面积 25 | model: String, // 房型 26 | subway: String, 27 | area: String, // 地区 28 | imgs: [String], 29 | userface: String // 头像 30 | }); 31 | 32 | // set tid index & unique 33 | houseSchema.index({ tid: 1 }, { unique: true }); 34 | 35 | const House = mongoose.model('House', houseSchema); 36 | 37 | House.on('index', error => { 38 | if (error) { 39 | console.log(`House collection create index error:${error}`); 40 | } 41 | }); 42 | 43 | // create user schema 44 | const usersSchema = new mongoose.Schema({ 45 | username: String, 46 | password: String, 47 | ctime: String, 48 | ltime: String, 49 | likes: [String] 50 | }); 51 | const User = mongoose.model('User', usersSchema); 52 | 53 | module.exports = { House, User }; 54 | -------------------------------------------------------------------------------- /src/components/LazyImage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import LazyLoad from 'vanilla-lazyload'; 3 | import DefaultImg from '@/images/default.png'; 4 | const HTTPS_REG = /^(https:\/\/)/; 5 | 6 | if (!document.lazyLoadInstance) { 7 | document.lazyLoadInstance = new LazyLoad({ 8 | element_selector: '.lazy', 9 | callback_error: e => (e.src = DefaultImg) 10 | }); 11 | } 12 | 13 | export class LazyImage extends Component { 14 | // Update lazyLoad after first rendering of every image 15 | componentDidMount() { 16 | document.lazyLoadInstance.update(); 17 | } 18 | 19 | // Update lazyLoad after rerendering of every image 20 | componentDidUpdate() { 21 | document.lazyLoadInstance.update(); 22 | } 23 | 24 | // Just render the image with data-src 25 | render() { 26 | const { alt, src, className } = this.props; 27 | return ( 28 | 29 | {alt}Error!; 9 | } else if (pastDelay) { 10 | return
Loading...
; 11 | } else { 12 | return null; 13 | } 14 | } 15 | 16 | const Login = Loadable({ 17 | loader: () => import('@/views/Login'), 18 | loading: LoadingComponent 19 | }); 20 | 21 | const HouseDetail = Loadable({ 22 | loader: () => import('@/views/HouseDetail'), 23 | loading: LoadingComponent 24 | }); 25 | 26 | const About = Loadable({ 27 | loader: () => import('@/views/About'), 28 | loading: LoadingComponent 29 | }); 30 | 31 | // 懒加载会导致搜索页面input的`placeholder`显示不全的问题,暂时直接加载 32 | // const HouseSearch = Loadable({ 33 | // loader: () => import('@/views/HouseSearch'), 34 | // loading: LoadingComponent 35 | // }); 36 | 37 | const UserLikes = Loadable({ 38 | loader: () => import('@/views/UserLikes'), 39 | loading: LoadingComponent 40 | }); 41 | 42 | export default [ 43 | { 44 | path: '/', 45 | exact: true, 46 | component: Tabs 47 | }, 48 | { 49 | path: '/login', 50 | component: Login 51 | }, 52 | { 53 | path: '/detail/:id', 54 | component: HouseDetail 55 | }, 56 | { 57 | path: '/about', 58 | component: About 59 | }, 60 | { 61 | path: '/search', 62 | component: HouseSearch 63 | }, 64 | { 65 | path: '/likes', 66 | component: UserLikes, 67 | auth: true 68 | } 69 | ]; 70 | -------------------------------------------------------------------------------- /src/views/About.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CanvasBg from 'comp/CanvasBg'; 3 | 4 | export default class About extends React.Component { 5 | render() { 6 | return ( 7 |
8 | 9 |
17 |
    18 |
  • 关于项目:一个租房小应用(仅供学习使用)
  • 19 |
  • 20 | 关于技术栈: 21 |
      22 |
    • 前端:React
    • 23 |
    • 后端:Koa
    • 24 |
    • 数据库:MongoDB
    • 25 |
    26 |
  • 27 |
  • 关于我:一个96年前端小菜鸟
  • 28 |
29 |
30 | 53 |
54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "douban-house", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "antd-mobile": "^2.2.3", 7 | "axios": "^0.18.0", 8 | "babel-plugin-import": "^1.8.0", 9 | "cheerio": "^1.0.0-rc.2", 10 | "connected-react-router": "^4.5.0", 11 | "history": "^4.7.2", 12 | "js-cookie": "^2.2.0", 13 | "jsonwebtoken": "^8.3.0", 14 | "koa": "^2.5.2", 15 | "koa-bodyparser": "^4.2.1", 16 | "koa-router": "^7.4.0", 17 | "mongoose": "^5.2.7", 18 | "node-sass": "^4.9.3", 19 | "node-schedule": "^1.3.0", 20 | "postcss-preset-env": "^5.3.0", 21 | "postcss-px-to-viewport": "^0.0.3", 22 | "postcss-viewport-units": "^0.1.4", 23 | "postcss-write-svg": "^3.0.1", 24 | "react": "^16.4.2", 25 | "react-app-rewired": "^1.5.2", 26 | "react-dom": "^16.4.2", 27 | "react-loadable": "^5.5.0", 28 | "react-redux": "^5.0.7", 29 | "react-router-dom": "^4.3.1", 30 | "react-scripts": "1.1.4", 31 | "react-swipeable-views": "^0.12.17", 32 | "redux": "^4.0.0", 33 | "redux-thunk": "^2.3.0", 34 | "sass-loader": "^7.1.0", 35 | "styled-jsx": "^3.0.2", 36 | "styled-jsx-plugin-postcss": "^0.1.3", 37 | "styled-jsx-plugin-sass": "^0.2.4", 38 | "vanilla-lazyload": "^10.17.0" 39 | }, 40 | "scripts": { 41 | "start": "react-app-rewired start", 42 | "server": "node server/index.js", 43 | "build": "react-app-rewired build", 44 | "test": "react-app-rewired test --env=jsdom", 45 | "eject": "react-scripts eject" 46 | }, 47 | "proxy": { 48 | "/api": { 49 | "target": "http://localhost:3003" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/store/reducers/houseList.js: -------------------------------------------------------------------------------- 1 | import * as types from '../actionTypes/houseList'; 2 | import { Toast } from 'antd-mobile'; 3 | 4 | const defaultState = { 5 | list: null, 6 | page: 1, 7 | size: 30, 8 | scrollHeight: null, 9 | scrollTop: null, 10 | loading: { 11 | hasMore: true, 12 | refreshing: false, 13 | isLoading: false 14 | }, 15 | query: {}, 16 | selectedMenu: [] 17 | }; 18 | 19 | export default (state = defaultState, action) => { 20 | switch (action.type) { 21 | // 设置loading 22 | case types.SET_LOADING: 23 | if (action.loading.isLoading) { 24 | Toast.loading('加载中', 0); 25 | } 26 | if (action.loading.isLoading === false) { 27 | Toast.hide(); 28 | } 29 | return { ...state, loading: { ...state.loading, ...action.loading } }; 30 | // 设置房源列表 31 | case types.SET_HOUSE_LIST: 32 | return { ...state, list: action.list }; 33 | // 设置滚动高度 34 | case types.SET_SCROLL_HEIGHT: 35 | return { ...state, scrollHeight: action.scrollHeight }; 36 | // 设置页码 37 | case types.CHANGE_PAGE: 38 | return { ...state, page: action.page }; 39 | // 加载更多房源 40 | case types.LOADMORE_LIST: 41 | return { 42 | ...state, 43 | list: state.list.concat(action.list) 44 | }; 45 | // 设置滚动条当前位置 46 | case types.SET_SCROLL_TOP: 47 | return { ...state, scrollTop: action.scrollTop }; 48 | // 设置查询参数 49 | case types.SET_QUERY: 50 | return { ...state, query: action.query }; 51 | // 设置搜索菜单选择项 52 | case types.SET_SELECTED_MENU: 53 | return { ...state, selectedMenu: action.selectedMenu }; 54 | 55 | default: 56 | return state; 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/House/List.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PullToRefresh, ListView, ActivityIndicator } from 'antd-mobile'; 3 | import HouseItem from './Item'; 4 | import Empty from '../Empty'; 5 | 6 | const NoMoreText = '没有更多了哦~'; 7 | const ds = new ListView.DataSource({ 8 | rowHasChanged: (r1, r2) => r1 !== r2 9 | }); 10 | const row = rowData => ; 11 | 12 | const EmptyHandle = list => 13 | list && list.length === 0 ? ( 14 | 15 | ) : null; 16 | 17 | export default props => { 18 | const { list, height } = props; 19 | return list && list.length ? ( 20 |
21 | { 28 | return props.hasMore ? ( 29 |
30 | 31 |
32 | ) : ( 33 |
{NoMoreText}
34 | ); 35 | }} 36 | renderRow={row} 37 | pullToRefresh={ 38 | 42 | } 43 | onScroll={e => { 44 | props.onScroll(e); 45 | }} 46 | initialListSize={props.scrollTop ? list.length : 20} 47 | scrollEventThrottle={500} 48 | scrollRenderAheadDistance={1000} 49 | onEndReached={props.onEndReached} 50 | pageSize={20} 51 | /> 52 |
53 | ) : ( 54 |
{EmptyHandle(list)}
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/CanvasBg.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { currentCirle, Circle } from '@/util/canvasBg'; 3 | 4 | class CanvasBg extends React.Component { 5 | componentDidMount() { 6 | window.requestAnimationFrame = 7 | window.requestAnimationFrame || 8 | window.mozRequestAnimationFrame || 9 | window.webkitRequestAnimationFrame || 10 | window.msRequestAnimationFrame; 11 | 12 | let canvas = document.getElementById('canvas'); 13 | let ctx = canvas.getContext('2d'); 14 | let w = (canvas.width = canvas.offsetWidth); 15 | let h = (canvas.height = canvas.offsetHeight); 16 | let circles = []; 17 | let current_circle = new currentCirle(); 18 | 19 | const draw = () => { 20 | ctx.clearRect(0, 0, w, h); 21 | for (let i = 0; i < circles.length; i++) { 22 | circles[i].move(w, h); 23 | circles[i].drawCircle(ctx); 24 | for (let j = i + 1; j < circles.length; j++) { 25 | circles[i].drawLine(ctx, circles[j]); 26 | } 27 | } 28 | if (current_circle.x) { 29 | current_circle.drawCircle(ctx); 30 | for (var k = 1; k < circles.length; k++) { 31 | current_circle.drawLine(ctx, circles[k]); 32 | } 33 | } 34 | requestAnimationFrame(draw); 35 | }; 36 | 37 | const initCanvas = num => { 38 | for (var i = 0; i < num; i++) { 39 | circles.push(new Circle(Math.random() * w, Math.random() * h)); 40 | } 41 | draw(); 42 | }; 43 | initCanvas(30); 44 | } 45 | render() { 46 | return ( 47 | 51 | ); 52 | } 53 | } 54 | 55 | export default CanvasBg; 56 | -------------------------------------------------------------------------------- /src/views/UserLikes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SwipeAction, Toast } from 'antd-mobile'; 3 | import { Item as HouseItem } from 'comp/House'; 4 | import Empty from 'comp/Empty'; 5 | import { GetUserLikeList, UserLikeHouse } from '@/api'; 6 | 7 | class UserLikeList extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | list: [] 12 | }; 13 | } 14 | 15 | componentDidMount() { 16 | GetUserLikeList().then(res => { 17 | if (res && res.code === 1) { 18 | this.setState({ list: res.data }); 19 | } 20 | }); 21 | } 22 | 23 | // 用户删除喜欢 24 | handleUnlike = tid => { 25 | UserLikeHouse(tid, true).then(res => { 26 | if (res && res.code) { 27 | Toast.show(res.msg, 1); 28 | this.updateList(tid); 29 | } 30 | }); 31 | }; 32 | 33 | // 操作完后setState 34 | updateList(tid) { 35 | let list = this.state.list.slice(); 36 | let index = list.findIndex(item => item.tid === tid); 37 | if (index > -1) { 38 | list.splice(index, 1); 39 | } 40 | this.setState({ list }); 41 | } 42 | 43 | render() { 44 | const { list } = this.state; 45 | const ListItem = house => ( 46 | this.handleUnlike(house.tid), 57 | style: { backgroundColor: '#F4333C', color: 'white' } 58 | } 59 | ]} 60 | > 61 | 62 | 63 | ); 64 | 65 | return list && list.length ? ( 66 | list.map(item => ListItem(item)) 67 | ) : ( 68 | 69 | ); 70 | } 71 | } 72 | 73 | export default UserLikeList; 74 | -------------------------------------------------------------------------------- /src/components/House/Item.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import LazyImage from 'comp/LazyImage'; 4 | import { resolveScopedStyles } from '@/util'; 5 | 6 | const scoped = resolveScopedStyles( 7 | 8 | 15 | 16 | ); 17 | 18 | export default props => { 19 | const { house } = props; 20 | return ( 21 | 22 |
23 |
24 | 29 |
30 |
31 |

{house.title}

32 |

{`价格:${ 33 | house.price ? house.price + '元' : '暂无' 34 | }`}

35 |
36 | 65 |
66 | {scoped.styles} 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/store/actions/houseList.js: -------------------------------------------------------------------------------- 1 | import { GetHouseList } from '@/api'; 2 | import * as types from '../actionTypes/houseList'; 3 | 4 | // 请求房源 5 | export const fetchHouseList = ( 6 | page = 1, 7 | size = 30, 8 | filter = [], 9 | loadmore 10 | ) => dispatch => { 11 | // 设置loding打开 12 | dispatch({ 13 | type: types.SET_LOADING, 14 | loading: { isLoading: true } 15 | }); 16 | GetHouseList(page, size, filter) 17 | .then(res => { 18 | if (res && res.code === 1) { 19 | // 如果请求数 { 47 | console.error(err); 48 | dispatch({ 49 | type: types.SET_LOADING, 50 | loading: { 51 | isLoading: false 52 | } 53 | }); 54 | }); 55 | }; 56 | 57 | // 设置滚动列表高度 58 | export const setScrollHeight = scrollHeight => ({ 59 | type: types.SET_SCROLL_HEIGHT, 60 | scrollHeight 61 | }); 62 | 63 | // 列表页数更改 64 | export const changePage = page => ({ 65 | type: types.CHANGE_PAGE, 66 | page 67 | }); 68 | 69 | // 设置当前滚动条位置 70 | export const setScrollTop = scrollTop => ({ 71 | type: types.SET_SCROLL_TOP, 72 | scrollTop 73 | }); 74 | 75 | // 设置搜索参数 76 | export const setQuery = query => ({ 77 | type: types.SET_QUERY, 78 | query 79 | }); 80 | 81 | // 设置搜索菜单选择项 82 | export const setSelectedMenu = selectedMenu => ({ 83 | type: types.SET_SELECTED_MENU, 84 | selectedMenu 85 | }); 86 | -------------------------------------------------------------------------------- /src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | // triangle 2 | @mixin triangle($direction: top, $size: 7px, $borderColor: darkgray) { 3 | content: ''; 4 | height: 0; 5 | width: 0; 6 | @if $direction == top { 7 | border-bottom: $size solid $borderColor; 8 | border-left: $size solid transparent; 9 | border-right: $size solid transparent; 10 | } @else if $direction == right { 11 | border-left: $size solid $borderColor; 12 | border-top: $size solid transparent; 13 | border-bottom: $size solid transparent; 14 | } @else if $direction == bottom { 15 | border-top: $size solid $borderColor; 16 | border-left: $size solid transparent; 17 | border-right: $size solid transparent; 18 | } @else if $direction == left { 19 | border-right: $size solid $borderColor; 20 | border-top: $size solid transparent; 21 | border-bottom: $size solid transparent; 22 | } 23 | } 24 | 25 | // text ellipsis 26 | @mixin ellipsis($line: 1) { 27 | @if ($line == 1) { 28 | white-space: nowrap; 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | } @else { 32 | display: -webkit-box; 33 | -webkit-box-orient: vertical; 34 | -webkit-line-clamp: $line; 35 | overflow: hidden; 36 | } 37 | } 38 | 39 | @svg 1px-ratio3 { 40 | height: 1px; 41 | @rect { 42 | fill: var(--color, black); 43 | width: 100%; 44 | height: 33.33%; 45 | } 46 | } 47 | @svg 1px-ratio2 { 48 | height: 1px; 49 | @rect { 50 | fill: var(--color, black); 51 | width: 100%; 52 | height: 50%; 53 | } 54 | } 55 | 56 | // border 1px by `postcss-write-svg` 57 | @mixin border1px($color: #ddd) { 58 | position: absolute; 59 | top: 0; 60 | left: 0; 61 | width: 100%; 62 | height: 1px; 63 | @media (-webkit-min-device-pixel-ratio: 2), (min-device-pixel-ratio: 2) { 64 | background-image: svg(1px-ratio2 param(--color $color)); 65 | } 66 | @media (-webkit-min-device-pixel-ratio: 3), (min-device-pixel-ratio: 3) { 67 | background-image: svg(1px-ratio3 param(--color $color)); 68 | } 69 | } 70 | 71 | @mixin lheihgt($h:90px) { 72 | height:$h; 73 | line-height:$h; 74 | } -------------------------------------------------------------------------------- /src/util/canvasBg.js: -------------------------------------------------------------------------------- 1 | // fork from https://github.com/sunshine940326/canvas-nest 2 | class Circle { 3 | //创建对象 4 | //以一个圆为对象 5 | //设置随机的 x,y坐标,r半径,_mx,_my移动的距离 6 | //this.r是创建圆的半径,参数越大半径越大 7 | //this._mx,this._my是移动的距离,参数越大移动 8 | constructor(x, y) { 9 | this.x = x; 10 | this.y = y; 11 | this.r = Math.random() * 10; 12 | this._mx = Math.random(); 13 | this._my = Math.random(); 14 | } 15 | 16 | //canvas 画圆和画直线 17 | //画圆就是正常的用canvas画一个圆 18 | //画直线是两个圆连线,为了避免直线过多,给圆圈距离设置了一个值,距离很远的圆圈,就不做连线处理 19 | drawCircle(ctx) { 20 | ctx.beginPath(); 21 | //arc() 方法使用一个中心点和半径,为一个画布的当前子路径添加一条弧。 22 | ctx.arc(this.x, this.y, this.r, 0, 360); 23 | ctx.closePath(); 24 | ctx.fillStyle = 'rgba(204, 204, 204, 0.3)'; 25 | ctx.fill(); 26 | } 27 | 28 | drawLine(ctx, _circle) { 29 | let dx = this.x - _circle.x; 30 | let dy = this.y - _circle.y; 31 | let d = Math.sqrt(dx * dx + dy * dy); 32 | if (d < 150) { 33 | ctx.beginPath(); 34 | //开始一条路径,移动到位置 this.x,this.y。创建到达位置 _circle.x,_circle.y 的一条线: 35 | ctx.moveTo(this.x, this.y); //起始点 36 | ctx.lineTo(_circle.x, _circle.y); //终点 37 | ctx.closePath(); 38 | ctx.strokeStyle = 'rgba(204, 204, 204, 0.3)'; 39 | ctx.stroke(); 40 | } 41 | } 42 | 43 | // 圆圈移动 44 | // 圆圈移动的距离必须在屏幕范围内 45 | move(w, h) { 46 | this._mx = this.x < w && this.x > 0 ? this._mx : -this._mx; 47 | this._my = this.y < h && this.y > 0 ? this._my : -this._my; 48 | this.x += this._mx / 2; 49 | this.y += this._my / 2; 50 | } 51 | } 52 | 53 | //鼠标点画圆闪烁变动 54 | class currentCirle extends Circle { 55 | 56 | drawCircle(ctx) { 57 | ctx.beginPath(); 58 | //注释内容为鼠标焦点的地方圆圈半径变化 59 | //this.r = (this.r < 14 && this.r > 1) ? this.r + (Math.random() * 2 - 1) : 2; 60 | this.r = 8; 61 | ctx.arc(this.x, this.y, this.r, 0, 360); 62 | ctx.closePath(); 63 | //ctx.fillStyle = 'rgba(0,0,0,' + (parseInt(Math.random() * 100) / 100) + ')' 64 | ctx.fillStyle = 'rgba(255, 77, 54, 0.6)'; 65 | ctx.fill(); 66 | } 67 | } 68 | 69 | export { Circle, currentCirle }; 70 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | 豆瓣租房 23 | 24 | 25 | 26 | 27 | 37 | 38 | 39 | 40 | 43 |
44 | 54 | 55 | 62 | 63 | -------------------------------------------------------------------------------- /src/views/Tabs.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { setActiveTab } from '@/store/actions/tab'; 4 | import { logout } from '@/store/actions/user'; 5 | import { TabBar } from 'antd-mobile'; 6 | import { HouseList, Mine } from 'comp/Tabs'; 7 | import SvgIcon from 'comp/SvgIcon'; 8 | import { setStorageByKey } from '@/util'; 9 | import Cookie from 'js-cookie'; 10 | 11 | class Tabs extends React.Component { 12 | navToSearch = () => { 13 | this.props.history.push('/search'); 14 | }; 15 | 16 | // tab`我的` 跳转事件 17 | handleNavto = (type, url) => { 18 | if (type === 'route') { 19 | this.props.history.push(url); 20 | } else { 21 | window.location.href = url; 22 | } 23 | }; 24 | 25 | // 设置当前tab 26 | handleSetActiveTab = tab => { 27 | const { dispatch } = this.props; 28 | setStorageByKey('activeTab', tab); 29 | dispatch(setActiveTab(tab)); 30 | }; 31 | 32 | // 登出 33 | handleLogout = () => { 34 | Cookie.remove('token'); 35 | const { dispatch } = this.props; 36 | dispatch(logout()); 37 | }; 38 | 39 | render() { 40 | const { activeTab, isLogin, username } = this.props; 41 | return ( 42 |
43 | 48 | } 52 | selectedIcon={} 53 | selected={activeTab === 'listTab'} 54 | onPress={() => { 55 | this.handleSetActiveTab('listTab'); 56 | }} 57 | > 58 | 59 | 60 | } 62 | selectedIcon={} 63 | title="我的" 64 | key="mineTab" 65 | selected={activeTab === 'mineTab'} 66 | onPress={() => { 67 | this.handleSetActiveTab('mineTab'); 68 | }} 69 | > 70 | 77 | 78 | 79 | 87 |
88 | ); 89 | } 90 | } 91 | 92 | const mapStateToProps = state => ({ 93 | activeTab: state.tab.tab, 94 | isLogin: Boolean(state.user.token), 95 | username: state.user.username 96 | }); 97 | 98 | export default connect(mapStateToProps)(Tabs); 99 | -------------------------------------------------------------------------------- /server/controllers/User.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | const { successRet, errorRet, createToken } = require('../util'); 3 | 4 | /** 5 | * user login 6 | * @param {Object} user {username,password} 7 | */ 8 | const Login = async ctx => { 9 | const user = ctx.request.body; 10 | const { username, password } = user; 11 | // no username or password 12 | if (!username) { 13 | ctx.body = errorRet(`请输入用户名`); 14 | return; 15 | } 16 | if (!password) { 17 | ctx.body = errorRet(`请输入密码`); 18 | return; 19 | } 20 | // check user if exist 21 | try { 22 | const userRet = await db.User.findOne({ username }, { _id: 0, __v: 0 }); 23 | 24 | if (userRet) { 25 | // check password 26 | if (userRet.password === password) { 27 | await db.User.findOneAndUpdate( 28 | { username }, 29 | { ltime: +new Date() + '' } 30 | ); 31 | ctx.body = successRet({ 32 | token: createToken({ username }), 33 | username: username 34 | }); 35 | } else { 36 | // password no match 37 | ctx.body = errorRet(`密码不正确`); 38 | } 39 | } else { 40 | // register & login 41 | let t = +new Date() + ''; 42 | await new db.User({ ...user, ctime: t, ltime: t }).save(); 43 | ctx.body = successRet({ 44 | token: createToken({ username }), 45 | username: username 46 | }); 47 | } 48 | } catch (e) { 49 | ctx.body = errorRet(`login error: ${e.message || e}`); 50 | } 51 | }; 52 | 53 | /** 54 | * 用户点击喜欢房子 55 | * @param {String} tid 贴子id 56 | * @param {Boolean} unlike 是否为不喜欢 57 | */ 58 | const Like = async ctx => { 59 | const { username } = ctx; 60 | const { tid, unlike } = ctx.request.body; 61 | try { 62 | const user = await db.User.findOne({ username }); 63 | if (user) { 64 | let originLikes = user.likes; 65 | // 是否在喜欢列表里面 66 | const tidIndex = originLikes.findIndex(v => v === tid); 67 | const isInclude = tidIndex > -1; 68 | // 取消喜欢 69 | if (unlike) { 70 | if (!isInclude) { 71 | ctx.body = errorRet(`非法操作`); 72 | } else { 73 | // update like list 74 | originLikes.splice(tidIndex, 1); 75 | await db.User.findOneAndUpdate({ username }, { likes: originLikes }); 76 | ctx.body = successRet(null, '已取消'); 77 | } 78 | } else { 79 | // 喜欢 80 | if (isInclude) { 81 | ctx.body = errorRet(`不能喜欢多次`); 82 | } else { 83 | // update like list 84 | originLikes.push(tid); 85 | if (~~originLikes.length) { 86 | await db.User.findOneAndUpdate( 87 | { username }, 88 | { likes: originLikes } 89 | ); 90 | ctx.body = successRet(null, '已喜欢'); 91 | } 92 | } 93 | } 94 | } 95 | } catch (e) { 96 | ctx.body = errorRet(`user like error: ${e.message || e}`); 97 | } 98 | }; 99 | 100 | /** 101 | * 用户喜欢房源列表 102 | */ 103 | const LikeList = async ctx => { 104 | const { username } = ctx; 105 | try { 106 | const user = await db.User.findOne({ username }); 107 | if (user) { 108 | let houses = await db.House.find({ tid: { $in: user.likes } }); 109 | ctx.body = successRet(houses); 110 | } 111 | } catch (e) { 112 | ctx.body = errorRet(`user like error: ${e.message || e}`); 113 | } 114 | }; 115 | 116 | module.exports = { 117 | Login, 118 | Like, 119 | LikeList 120 | }; 121 | -------------------------------------------------------------------------------- /src/util/filterMenuItem.js: -------------------------------------------------------------------------------- 1 | export const menu1 = [ 2 | { 3 | value: 'area', 4 | label: '区域', 5 | children: [ 6 | { 7 | label: '不限', 8 | value: 0 9 | }, 10 | { 11 | label: '天河', 12 | value: '天河' 13 | }, 14 | { 15 | label: '越秀', 16 | value: '越秀' 17 | }, 18 | { 19 | label: '荔湾', 20 | value: '荔湾' 21 | }, 22 | { 23 | label: '海珠', 24 | value: '海珠' 25 | }, 26 | { 27 | label: '番禺', 28 | value: '番禺' 29 | }, 30 | { 31 | label: '白云', 32 | value: '白云' 33 | }, 34 | { 35 | label: '黄埔', 36 | value: '黄埔' 37 | }, 38 | { 39 | label: '从化', 40 | value: '从化' 41 | }, 42 | { 43 | label: '增城', 44 | value: '增城' 45 | }, 46 | { 47 | label: '花都', 48 | value: '花都' 49 | }, 50 | { 51 | label: '南沙', 52 | value: '南沙' 53 | } 54 | ] 55 | }, 56 | { 57 | value: 'subway', 58 | label: '地铁', 59 | children: [ 60 | { 61 | label: '不限', 62 | value: 0 63 | }, 64 | { 65 | label: '1号线', 66 | value: '1' 67 | }, 68 | { 69 | label: '2号线', 70 | value: '2' 71 | }, 72 | { 73 | label: '3号线', 74 | value: '3' 75 | }, 76 | { 77 | label: '4号线', 78 | value: '4' 79 | }, 80 | { 81 | label: '5号线', 82 | value: '5' 83 | }, 84 | { 85 | label: '6号线', 86 | value: '6' 87 | }, 88 | { 89 | label: '7号线', 90 | value: '7' 91 | }, 92 | { 93 | label: '8号线', 94 | value: '8' 95 | }, 96 | { 97 | label: '9号线', 98 | value: '9' 99 | }, 100 | { 101 | label: '13号线', 102 | value: '13' 103 | }, 104 | { 105 | label: '14号线', 106 | value: '14' 107 | }, 108 | { 109 | label: 'apm线', 110 | value: 'APM' 111 | }, 112 | { 113 | label: '广佛线', 114 | value: '广佛' 115 | } 116 | ] 117 | } 118 | ]; 119 | 120 | export const menu2 = [ 121 | { 122 | label: '不限', 123 | value: 0 124 | }, 125 | { 126 | label: '一室', 127 | value: '(一|1)(室|房)' 128 | }, 129 | { 130 | label: '二室', 131 | value: '(二|2|两)(室|房)' 132 | }, 133 | { 134 | label: '三室', 135 | value: '(三|3)(室|房)' 136 | }, 137 | { 138 | label: '四室', 139 | value: '(四|4)(室|房)' 140 | } 141 | ]; 142 | 143 | export const menu3 = [ 144 | { 145 | label: '不限', 146 | value: 0 147 | }, 148 | { 149 | label: '1000元以下', 150 | value: '0,1000' 151 | }, 152 | { 153 | label: '1000-2000元', 154 | value: '1000,2000' 155 | }, 156 | { 157 | label: '2000-3000元', 158 | value: '2000,3000' 159 | }, 160 | { 161 | label: '3000-4000元', 162 | value: '3000,4000' 163 | }, 164 | { 165 | label: '5000元以上', 166 | value: '5000' 167 | } 168 | ]; 169 | 170 | export const menu4 = [ 171 | { 172 | label: '默认排序', 173 | value: null 174 | }, 175 | { 176 | label: '有图片', 177 | value: 'imgs' 178 | }, 179 | { 180 | label: '最近发布', 181 | value: 'ctime' 182 | }, 183 | { 184 | label: '有联系方式', 185 | value: 'contact' 186 | }, 187 | { 188 | label: '租金由低到高', 189 | value: 'price_desc' 190 | }, 191 | { 192 | label: '租金由高到低', 193 | value: 'price_asc' 194 | } 195 | ]; 196 | -------------------------------------------------------------------------------- /src/components/Tabs/Mine.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { List } from 'antd-mobile'; 3 | import Button from 'comp/Button'; 4 | import SvgIcon from 'comp/SvgIcon'; 5 | import LazyImage from 'comp/LazyImage'; 6 | import { resolveScopedStyles } from '@/util'; 7 | 8 | const scoped = resolveScopedStyles( 9 | 10 | 19 | 20 | ); 21 | export default props => { 22 | let { isLogin, username } = props; 23 | return ( 24 | 25 |
26 |
27 | 28 | {isLogin ? ( 29 |
30 | Hello, 31 | {username} 32 | 40 |
41 | ) : ( 42 | 50 | )} 51 |
52 |
53 |
props.handleSetActiveTab('listTab')} 56 | > 57 | 58 | 房源 59 |
60 |
props.navTo('route', '/likes')} 63 | > 64 | 65 | 喜欢 66 |
67 |
68 | 69 | props.navTo('route', '/about')} 72 | > 73 | 关于 74 | 75 | 78 | props.navTo( 79 | 'outside', 80 | 'https://github.com/yeojongki/douban-house' 81 | ) 82 | } 83 | > 84 | 源码 85 | 86 | 89 | props.navTo( 90 | 'outside', 91 | 'http://ssr.yeojongki.cn/article/5b7687e8b7afb75b61ec0a0e' 92 | ) 93 | } 94 | > 95 | 博客 96 | 97 | 98 |
99 | 122 | {scoped.styles} 123 |
124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /server/controllers/House.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | const { successRet, errorRet, verifyToken } = require('../util'); 3 | 4 | /** 5 | * get house detail 6 | * @param {Number} tid topic id 7 | */ 8 | const GetDetail = async ctx => { 9 | const { 10 | params: { tid } 11 | } = ctx; 12 | const token = ctx.header['x-token']; 13 | // if user is login 14 | let isLike; 15 | if (token) { 16 | try { 17 | let decode = verifyToken(token); 18 | let { username } = decode; 19 | const user = await db.User.findOne({ username }, { _id: 0, __v: 0 }); 20 | if (user) { 21 | if (user.likes.includes(tid)) { 22 | isLike = true; 23 | } 24 | } 25 | } catch (e) { 26 | ctx.body = errorRet(`house detail getUser error: ${e.message || e}`); 27 | } 28 | } 29 | 30 | // find house 31 | try { 32 | const house = await db.House.findOne({ tid }, { _id: 0, __v: 0 }); 33 | ctx.body = successRet({ house, isLike }); 34 | } catch (e) { 35 | ctx.body = errorRet(`get house id[${tid}] error: ${e.message || e}`); 36 | } 37 | }; 38 | 39 | /** 40 | * @description get/search house list 41 | * @param {Number} page page number 42 | * @param {Number} size the size length of every page 43 | * @param {Object} filter search query 44 | * @param {Boolean} imgs have imgs ? 45 | * @param {Boolean} contact have contact way ? 46 | * @param {String} area area like '天河','越秀' 47 | * @param {Number/String} subway subway line like 'APM',1,'广佛' 48 | * @param {String} model house model like '一房一厅','4房2卫' 49 | * @param {Number} size_gt gt house size 50 | * @param {Number} size_lt lt house size 51 | * @param {Number} price_gt gt house price 52 | * @param {Number} price_lt lt house price 53 | */ 54 | const GetList = async ctx => { 55 | let body = ctx.request.body, 56 | sort = { ctime: -1 }, 57 | query = {}; 58 | const { page = 1, size = 10 } = body; 59 | 60 | // if filter 61 | if (body.filter && body.filter.length) { 62 | Object.values(body.filter).forEach(item => { 63 | query[item.key] = item.value; 64 | }); 65 | // if object has own key 66 | const h = (key, o = query) => o.hasOwnProperty(key); 67 | // other keys need formatted 68 | if (h('price_gt') && !h('price_lt')) { 69 | query.price = { $gt: +query.price_gt }; 70 | delete query.price_gt; 71 | } 72 | if (h('price_lt') && !h('price_gt')) { 73 | query.price = { $lt: +query.price_lt }; 74 | delete query.price_lt; 75 | } 76 | if (h('price_gt') && h('price_lt')) { 77 | query.price = { $lt: +query.price_lt, $gt: +query.price_gt }; 78 | delete query.price_gt; 79 | delete query.price_lt; 80 | } 81 | if (h('size_gt') && !h('size_lt')) { 82 | query.size = { $gt: +query.size_gt }; 83 | delete query.size_gt; 84 | } 85 | if (h('size_lt') && !h('size_gt')) { 86 | query.size = { $lt: +query.size_lt }; 87 | delete query.size_lt; 88 | } 89 | if (h('size_gt') && h('size_lt')) { 90 | query.size = { $lt: +query.size_lt, $gt: +query.size_gt }; 91 | delete query.size_gt; 92 | delete query.size_lt; 93 | } 94 | if (h('model')) { 95 | if (query.model) { 96 | query.model = { $regex: query.model }; 97 | } else { 98 | delete query.model; 99 | } 100 | } 101 | // sort 102 | if (h('sort')) { 103 | if (query.sort) { 104 | let key = query.sort; 105 | if (key === 'imgs') { 106 | query['imgs.0'] = { $exists: 1 }; 107 | } else if (key === 'ctime') { 108 | sort = { ctime: -1 }; 109 | } else if (key === 'contact') { 110 | query.contact = { $ne: null }; 111 | } else if (key === 'price_desc') { 112 | sort = { price: 1 }; 113 | query.price = { $ne: null }; 114 | } else if (key === 'price_asc') { 115 | sort = { price: -1 }; 116 | query.price = { $ne: null }; 117 | } 118 | } 119 | delete query.sort; 120 | } 121 | // title 122 | if (h('title')) { 123 | query.title = { $regex: query.title }; 124 | } 125 | } 126 | 127 | try { 128 | const houses = await db.House.find(query, { _id: 0, __v: 0 }) 129 | .sort(sort) 130 | .limit(+size) 131 | .skip((page - 1) * size); 132 | ctx.body = successRet(houses); 133 | } catch (e) { 134 | ctx.body = errorRet(`search house error: ${e.message || e}`); 135 | } 136 | }; 137 | 138 | module.exports = { 139 | GetList, 140 | GetDetail 141 | }; 142 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/views/HouseSearch.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { SearchBar } from 'antd-mobile'; 4 | import SvgIcon from 'comp/SvgIcon'; 5 | import { setStorageByKey, getStorageByKey } from '@/util'; 6 | import { setQuery } from '@/store/actions/houseList'; 7 | 8 | // localstorage search key 9 | const historyKey = 'search_history'; 10 | 11 | class SearchPage extends Component { 12 | constructor() { 13 | super(); 14 | 15 | // 从localstorage获取搜索历史 16 | const history = getStorageByKey(historyKey, 'local'); 17 | 18 | this.state = { 19 | searchHistory: history || [] 20 | }; 21 | } 22 | 23 | componentDidMount() { 24 | this.autoFocusInst.focus(); 25 | } 26 | 27 | // 搜索提交 28 | handleSubmit = val => { 29 | if (val) { 30 | // 从localStorge获取历史搜索记录 31 | let searchHistory = getStorageByKey(historyKey, 'local'); 32 | // 如果有历史记录 33 | if (searchHistory) { 34 | // 最多设置7个,大于就移除最开始的 35 | if (searchHistory.length > 7) { 36 | searchHistory.pop(); 37 | } 38 | // 如果历史记录不存在当前搜索内容,则添加 39 | if (!searchHistory.includes(val)) { 40 | searchHistory.unshift(val); 41 | } 42 | } else { 43 | searchHistory = [val]; 44 | } 45 | 46 | // 搜索历史添加到localStorage 47 | setStorageByKey(historyKey, searchHistory, 'local'); 48 | // 搜索参数添加到redux {"title":"天河"} 49 | const { dispatch } = this.props; 50 | // 从redux中获取原来的query 51 | const query = this.props.query; 52 | dispatch(setQuery({ ...query, title: val })); 53 | // 返回首页 54 | this.props.history.replace({ 55 | pathname: '/', 56 | query: { fromSearch: true } 57 | }); 58 | } else { 59 | this.handleCancel(); 60 | } 61 | }; 62 | 63 | // 点击取消 64 | handleCancel = () => { 65 | this.props.history.replace('/'); 66 | }; 67 | 68 | // 删除历史记录 69 | removeHistory = () => { 70 | window.localStorage.removeItem(historyKey); 71 | this.setState({ 72 | searchHistory: [] 73 | }); 74 | this.autoFocusInst.focus(); 75 | }; 76 | 77 | // 点击历史记录的项 78 | historyClick(value) { 79 | this.handleSubmit(value); 80 | } 81 | 82 | render() { 83 | const { searchHistory } = this.state; 84 | const { query } = this.props; 85 | return ( 86 |
87 | (this.autoFocusInst = ref)} 93 | onSubmit={val => this.handleSubmit(val)} 94 | onCancel={this.handleCancel} 95 | onClear={this.handleClear} 96 | /> 97 | {searchHistory.length ? ( 98 |
99 |
100 |

搜索历史

101 |
102 |
103 | {searchHistory.map((item, index) => ( 104 |
this.historyClick(item)} 108 | > 109 | 110 | {item} 111 |
112 |
113 | ))} 114 |
115 |
116 |

清除历史记录

117 |
118 |
119 | ) : null} 120 | 121 | 156 |
157 | ); 158 | } 159 | } 160 | 161 | const mapStateToProps = state => ({ query: state.houseList.query }); 162 | export default connect(mapStateToProps)(SearchPage); 163 | -------------------------------------------------------------------------------- /server/util.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const config = require('./config'); 3 | const fs = require('fs'); 4 | const schedule = require('node-schedule'); 5 | 6 | // write file 7 | function write(path = 'file.txt', txt = 'this is the default text') { 8 | fs.writeFile(path, txt, function(err) { 9 | if (err) { 10 | console.error('fail to write'); 11 | } else { 12 | console.log(`success to write file: ${path}`); 13 | } 14 | }); 15 | } 16 | 17 | // sleep 18 | function sleep(time = 0) { 19 | return new Promise(resolve => { 20 | setTimeout(resolve, time); 21 | }); 22 | } 23 | 24 | // schedule job 25 | function scheduleJob(time, fn) { 26 | return schedule.scheduleJob(time, fn); 27 | } 28 | 29 | // extract house infomations 30 | function extractHouse(text) { 31 | const pricesPatt1 = /\d{3,5}(?=元|\/月|每月|一个月|每个月)/g; 32 | const pricesPatt2 = /(月租|租金|价钱|价格|房租)(:|:| )*(\d{3,5})/g; 33 | const contactPatt = /((手机|电话|微信|v|qq|QQ)号?(:|:|\s)?(\s)?([\d\w_一二两三四五六七八九零]{5,}))/; 34 | const sizePatt = /(\d{1,3})(多)?[平|㎡]/; 35 | const modelPatt = /(([\d一二两三四五六七八九])[居室房]([123一二两三]厅)?([12一二两]厨)?([1234一二两三四]卫)?([12一二两]厨)?)/; 36 | const subwayPatt = /[\d一二两三四五六七八九十(十一)(十二)(十三)(十四)(apm)(APM)(广佛)]号线/; 37 | const areaPatt = /(天河|越秀|荔湾|海珠|番禺|白云|黄埔|从化|增城|花都|南沙)/; 38 | const result = {}; 39 | 40 | let price = ''; 41 | // price 42 | let resPricesPatt1 = pricesPatt1.exec(text); 43 | let resPricesPatt2; 44 | resPricesPatt1 ? (price = +resPricesPatt1[0]) : ''; 45 | 46 | // no match pricesPatt1 47 | price ? '' : (resPricesPatt2 = pricesPatt2.exec(text)); 48 | resPricesPatt2 && price ? (price = +resPricesPatt2[3]) : ''; 49 | 50 | result.price = price; 51 | // contact way 52 | const contactTypeMap = { 53 | 手机: 'mobile', 54 | 电话: 'phone', 55 | 微信: 'wechat', 56 | v: 'wechat', 57 | qq: 'qq', 58 | QQ: 'qq' 59 | }; 60 | const contactResult = contactPatt.exec(text); 61 | if (contactResult) { 62 | const contactType = contactTypeMap[contactResult[2]]; 63 | const contactValue = contactResult[5]; 64 | result.contact = { type: contactType, value: contactValue }; 65 | } else { 66 | result.contact = null; 67 | } 68 | 69 | // size 70 | const resSize = sizePatt.exec(text); 71 | result.size = resSize ? +resSize[0].replace(/\D/g, '') : null; 72 | 73 | // model 74 | const resModel = modelPatt.exec(text); 75 | result.model = resModel ? resModel[0] : null; 76 | 77 | // subway 78 | const subwayMap = { 79 | 一: 1, 80 | 二: 2, 81 | 三: 3, 82 | 四: 4, 83 | 五: 5, 84 | 六: 6, 85 | 七: 7, 86 | 八: 8, 87 | 九: 9, 88 | 十: 10, 89 | 十一: 11, 90 | 十二: 12, 91 | 十三: 13, 92 | 十四: 14, 93 | 广佛: '广佛', 94 | apm: 'APM', 95 | APM: 'APM' 96 | }; 97 | // '1'/'一' 98 | const resSubway = subwayPatt.exec(text); 99 | if (resSubway) { 100 | let line = subwayPatt.exec(text)[0].replace('号线', ''); 101 | result.subway = isNaN(+line) ? subwayMap[line] : +line; 102 | } 103 | 104 | // area 105 | const resArea = areaPatt.exec(text); 106 | result.area = resArea ? resArea[0] : null; 107 | 108 | return result; 109 | } 110 | 111 | // User-agent 112 | const userAgents = [ 113 | 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.0.12) Gecko/20070731 Ubuntu/dapper-security Firefox/1.5.0.12', 114 | 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; Acoo Browser; SLCC1; .NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.0.04506)', 115 | 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11', 116 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/535.20 (KHTML, like Gecko) Chrome/19.0.1036.7 Safari/535.20', 117 | 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.8) Gecko Fedora/1.9.0.8-1.fc10 Kazehakase/0.5.6', 118 | 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.71 Safari/537.1 LBBROWSER', 119 | 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 2.0.50727; Media Center PC 6.0) ,Lynx/2.8.5rel.1 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/1.2.9', 120 | 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727)', 121 | 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; QQBrowser/7.0.3698.400)', 122 | 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; QQDownload 732; .NET4.0C; .NET4.0E)', 123 | 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:2.0b13pre) Gecko/20110307 Firefox/4.0b13pre', 124 | 'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; fr) Presto/2.9.168 Version/11.52', 125 | 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.0.12) Gecko/20070731 Ubuntu/dapper-security Firefox/1.5.0.12', 126 | 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; LBBROWSER)', 127 | 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.8) Gecko Fedora/1.9.0.8-1.fc10 Kazehakase/0.5.6', 128 | 'Mozilla/5.0 (X11; U; Linux; en-US) AppleWebKit/527+ (KHTML, like Gecko, Safari/419.3) Arora/0.6', 129 | 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; QQBrowser/7.0.3698.400)', 130 | 'Opera/9.25 (Windows NT 5.1; U; en), Lynx/2.8.5rel.1 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/1.2.9', 131 | 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36' 132 | ]; 133 | 134 | const easyArrDiff = (arr1, arr2) => { 135 | let hash = {}; 136 | let result = []; 137 | const handle = (a, b) => { 138 | a.map(item => { 139 | if (!b.includes(item)) { 140 | hash[item] = true; 141 | result.push(item); 142 | } 143 | }); 144 | }; 145 | if (arr1.length > arr2.length) { 146 | handle(arr1, arr2); 147 | } else { 148 | handle(arr2, arr1); 149 | } 150 | return result; 151 | }; 152 | 153 | // ctx success return 154 | const successRet = (data, msg = 'success', code = 1) => ({ 155 | code, 156 | data, 157 | msg 158 | }); 159 | 160 | // ctx error return 161 | const errorRet = (msg = 'error', code = 0) => ({ 162 | code, 163 | msg 164 | }); 165 | 166 | // verify token by jsonwebtoken 167 | const verifyToken = token => jwt.verify(token, config.secret); 168 | 169 | // create token by jsonwebtoken 170 | const createToken = (payload, secret = config.secret, t = config.expiresIn) => 171 | jwt.sign(payload, secret, { 172 | expiresIn: t 173 | }); 174 | 175 | module.exports = { 176 | write, 177 | sleep, 178 | scheduleJob, 179 | extractHouse, 180 | userAgents, 181 | easyArrDiff, 182 | successRet, 183 | errorRet, 184 | verifyToken, 185 | createToken 186 | }; 187 | -------------------------------------------------------------------------------- /src/components/Tabs/HouseList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { connect } from 'react-redux'; 4 | import { 5 | fetchHouseList, 6 | setScrollHeight, 7 | changePage, 8 | setScrollTop, 9 | setQuery 10 | } from '@/store/actions/houseList'; 11 | import FilterMenu from 'comp/Filters'; 12 | import BackTop from 'comp/BackTop'; 13 | import { List as HouseList } from '../House'; 14 | import Header from 'comp/Header'; 15 | 16 | class TabHouseList extends Component { 17 | constructor(props) { 18 | super(props); 19 | 20 | this.state = { 21 | showBackTop: false 22 | }; 23 | } 24 | 25 | componentDidMount() { 26 | // 初始化 27 | const { list, scrollTop, routeQuery, scrollHeight } = this.props; 28 | // 是否从搜索页返回 29 | const fromSearch = routeQuery && routeQuery.fromSearch; 30 | if (list && list.length && !fromSearch) { 31 | // 恢复滚动条位置 32 | if (scrollTop && this.lv) { 33 | this.lv.scrollTo(0, scrollTop); 34 | } 35 | } else { 36 | // redux中没有List才去请求 37 | this.handleGetList(); 38 | } 39 | if (!scrollHeight) { 40 | // 搜索和下拉菜单的高度 41 | let top_h = this.filterRef.getBoundingClientRect().bottom; 42 | // 底部tabs高度 43 | const bot_h = 50; 44 | // 设置滚动高度 45 | this.props.dispatch( 46 | setScrollHeight(document.documentElement.clientHeight - top_h - bot_h) 47 | ); 48 | } 49 | } 50 | 51 | //组件卸载时存储滚动条位置 52 | componentWillUnmount() { 53 | this.saveScroll(); 54 | } 55 | 56 | // 存储滚动条位置 57 | saveScroll() { 58 | if (this.lv) { 59 | let scrollTop = this.lv.listviewRef.ListViewRef.ScrollViewRef.scrollTop; 60 | this.props.dispatch(setScrollTop(scrollTop)); 61 | } 62 | } 63 | 64 | // 获取房源列表 65 | handleGetList(isLoadmore) { 66 | const { dispatch, page, size, query } = this.props; 67 | let queryArr = []; 68 | for (let i in query) { 69 | queryArr.push({ key: i, value: query[i] }); 70 | } 71 | dispatch(fetchHouseList(page, size, queryArr, isLoadmore)); 72 | } 73 | 74 | // 下拉刷新事件 75 | onRefresh = () => { 76 | this.props.dispatch(changePage(1)); 77 | this.handleGetList(); 78 | }; 79 | 80 | // 上拉加载更多事件 81 | onEndReached = () => { 82 | const { isLoading, hasMore, dispatch, page } = this.props; 83 | if (isLoading || !hasMore) { 84 | return; 85 | } 86 | dispatch(changePage(page + 1)); 87 | this.handleGetList(true); 88 | }; 89 | 90 | // 列表滚动到顶部 91 | handleBackTop = () => { 92 | this.lv && this.lv.scrollTo(0, 0); 93 | }; 94 | 95 | // 是否显示回顶部按钮 96 | handleShowBackTop = e => { 97 | let scrollTop = e.target.scrollTop; 98 | if (scrollTop > document.documentElement.clientHeight * 0.5) { 99 | this.setState({ showBackTop: true }); 100 | } else { 101 | this.setState({ showBackTop: false }); 102 | } 103 | }; 104 | 105 | // 打开菜单设置列表不能滚动 106 | handleFilterOpen = () => { 107 | if (this.lv) { 108 | ReactDOM.findDOMNode(this.lv).style.overflow = 'hidden'; 109 | } 110 | }; 111 | 112 | // 关闭菜单设置列表恢复滚动 113 | handleFilterClose = () => { 114 | if (this.lv) { 115 | ReactDOM.findDOMNode(this.lv).style.overflow = 'auto'; 116 | } 117 | }; 118 | 119 | // 构建新的搜索参数 120 | buildNewQuery(key, exclude, delKey, value, customQuery, q) { 121 | const { dispatch, query } = this.props; 122 | let curQuery = Object.assign({}, query); 123 | let newQuery; 124 | // 如果需要排除 125 | if (exclude) { 126 | const handleDelKey = k => { 127 | if (curQuery.hasOwnProperty(k)) { 128 | delete curQuery[k]; 129 | } 130 | }; 131 | // 如果`delkey`是string类型 132 | if (typeof delKey === 'string') { 133 | handleDelKey(delKey); 134 | } else if (Array.isArray(delKey)) { 135 | // Array类型 136 | delKey.forEach(k => { 137 | handleDelKey(k); 138 | }); 139 | } 140 | } 141 | // 如果需要自定义参数 142 | if (customQuery) { 143 | newQuery = q; 144 | } else { 145 | newQuery = { [key]: value ? value : null }; 146 | } 147 | // set redux 148 | dispatch(setQuery({ ...curQuery, ...newQuery })); 149 | this.handleGetList(); 150 | } 151 | 152 | // 下拉菜单选择后的事件 153 | handleFilterChange = (type, v) => { 154 | // 重置到第一页 155 | this.props.dispatch(changePage(1)); 156 | // 回到顶部 157 | this.handleBackTop(); 158 | // 设置可以滚动 159 | this.handleFilterClose(); 160 | switch (type) { 161 | // 区域 162 | case 'area': 163 | if (v[0] === 'area') { 164 | this.buildNewQuery('area', true, 'subway', v[1]); 165 | } else { 166 | this.buildNewQuery('subway', true, 'area', v[1]); 167 | } 168 | break; 169 | // 出租类型 170 | case 'type': 171 | this.buildNewQuery('model', false, '', v[0]); 172 | break; 173 | // 租金 174 | case 'money': 175 | let arr; 176 | if (v[0]) { 177 | arr = v[0].split(','); 178 | } else { 179 | // 选择菜单为第一个`不限`时 180 | this.buildNewQuery('price_gt', true, ['price_gt', 'price_lt'], v[0]); 181 | } 182 | if (arr) { 183 | // 说明值是区间 如[0,2000] => '{price_lt:1000,price_gt:2000}' 184 | if (arr.length === 2) { 185 | let oj = {}; 186 | oj.price_gt = arr[0]; 187 | oj.price_lt = arr[1]; 188 | this.buildNewQuery( 189 | '', 190 | true, 191 | ['price_gt', 'price_lt'], 192 | '', 193 | true, 194 | oj 195 | ); 196 | } else { 197 | // 说明是大于值 如[5000] => '{price_gt:5000}' 198 | this.buildNewQuery('price_gt', false, '', v[0]); 199 | } 200 | } 201 | break; 202 | // 排序 203 | case 'sort': 204 | this.buildNewQuery('sort', false, '', v[0]); 205 | break; 206 | 207 | default: 208 | break; 209 | } 210 | }; 211 | 212 | render() { 213 | const { 214 | list, 215 | scrollHeight, 216 | hasMore, 217 | refreshing, 218 | scrollTop, 219 | query 220 | } = this.props; 221 | return ( 222 | 223 |
227 | (this.filterRef = ref)} 229 | open={this.handleFilterOpen} 230 | close={this.handleFilterClose} 231 | change={this.handleFilterChange} 232 | /> 233 | (this.lv = ref)} 236 | list={list} 237 | hasMore={hasMore} 238 | refreshing={refreshing} 239 | scrollTop={scrollTop} 240 | onScroll={this.handleShowBackTop} 241 | onEndReached={this.onEndReached} 242 | onRefresh={this.onRefresh} 243 | /> 244 | 245 | 246 | ); 247 | } 248 | } 249 | 250 | const mapStateToProps = state => ({ 251 | list: state.houseList.list, 252 | size: state.houseList.size, 253 | page: state.houseList.page, 254 | query: state.houseList.query, 255 | scrollHeight: state.houseList.scrollHeight, 256 | scrollTop: state.houseList.scrollTop, 257 | isLoading: state.houseList.loading.isLoading, 258 | hasMore: state.houseList.loading.hasMore, 259 | refreshing: state.houseList.loading.refreshing, 260 | routeQuery: state.router.location.query 261 | }); 262 | 263 | export default connect(mapStateToProps)(TabHouseList); 264 | -------------------------------------------------------------------------------- /src/views/Login.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Toast } from 'antd-mobile'; 4 | import Cookie from 'js-cookie'; 5 | import SvgIcon from 'comp/SvgIcon'; 6 | import { resolveScopedStyles } from '@/util'; 7 | import { setUser } from '@/store/actions/user'; 8 | import { AjaxLogin } from '@/api'; 9 | 10 | const scoped = resolveScopedStyles( 11 | 12 | 19 | 20 | ); 21 | 22 | class Login extends Component { 23 | constructor() { 24 | super(); 25 | this.state = { 26 | activeItem: null, 27 | form: {} 28 | }; 29 | } 30 | 31 | componentDidMount() { 32 | this.nameRef.focus(); 33 | } 34 | 35 | // input focus 36 | handleFocus = field => { 37 | this.setState({ activeItem: field }); 38 | }; 39 | 40 | // input blur 41 | handleBlur = () => { 42 | this.setState({ activeItem: null }); 43 | }; 44 | 45 | // input change 46 | handleChange = (field, e) => { 47 | this.setState({ 48 | form: { ...this.state.form, [field]: e.target.value } 49 | }); 50 | }; 51 | 52 | // form submit 53 | handleSubmit = e => { 54 | e.preventDefault(); 55 | const { username, password } = this.state.form; 56 | const { dispatch } = this.props; 57 | if (!username) { 58 | Toast.show('请输入用户名'); 59 | return; 60 | } 61 | if (!password) { 62 | Toast.show('请输入密码'); 63 | return; 64 | } 65 | Toast.loading('登录中...', 0); 66 | // ajax 67 | AjaxLogin(this.state.form) 68 | .then(res => { 69 | if (res && res.code === 1) { 70 | let user = res.data; 71 | // dispatch 72 | dispatch(setUser(user)); 73 | // set cookie 74 | let expiresTime = new Date(new Date().getTime() + 2 * 60 * 60 * 1000); //2h 75 | Cookie.set('token', user.token, { expires: expiresTime }); 76 | Cookie.set('username', user.username); 77 | Toast.info('登录成功', 1, this.props.history.goBack()); 78 | } 79 | }) 80 | .catch(() => { 81 | this.pwdRef.focus(); 82 | setTimeout(() => Toast.hide(), 1500); 83 | }); 84 | }; 85 | componentWillUnmount() { 86 | Toast.hide(); 87 | } 88 | render() { 89 | return ( 90 |
91 |

豆瓣租房

92 |
93 |
98 | 用户名 99 | 103 | (this.nameRef = ref)} 105 | type="text" 106 | placeholder="请输入用户名" 107 | onInput={e => this.handleChange('username', e)} 108 | onFocus={() => this.handleFocus('username')} 109 | onBlur={this.handleBlur} 110 | /> 111 |
112 |
113 |
118 | 密码 119 | 123 | (this.pwdRef = ref)} 125 | type="password" 126 | placeholder="请输入密码" 127 | onInput={e => this.handleChange('password', e)} 128 | onFocus={() => this.handleFocus('password')} 129 | onBlur={this.handleBlur} 130 | /> 131 |
132 |
133 |

134 | 提示:输入帐号密码自动注册并登录哦 135 | 😉 136 |

137 |
138 |
139 |
140 | 146 |
147 |
148 | 149 | 247 | {scoped.styles} 248 |
249 | ); 250 | } 251 | } 252 | 253 | const mapStateToProps = state => ({ isLogin: Boolean(state.user.token) }); 254 | 255 | export default connect(mapStateToProps)(Login); 256 | -------------------------------------------------------------------------------- /server/robot.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const cheerio = require('cheerio'); 3 | const db = require('./db'); 4 | const { 5 | scheduleJob, 6 | extractHouse, 7 | userAgents, 8 | sleep, 9 | easyArrDiff, 10 | write 11 | } = require('./util'); 12 | 13 | // create random userAgent 14 | const createUserAgent = () => ({ 15 | headers: { 16 | 'User-Agent': userAgents[parseInt(Math.random() * userAgents.length)] 17 | } 18 | }); 19 | 20 | // axios.interceptors.request.use(config => { 21 | // // console.log(config); 22 | // return config; 23 | // }); 24 | 25 | // config axios 26 | axios.defaults.timeout = 1000 * 7; 27 | 28 | axios.interceptors.response.use( 29 | response => { 30 | return response.data; 31 | }, 32 | error => { 33 | return Promise.reject(error); 34 | } 35 | ); 36 | 37 | /** 38 | * @param {any} cycle schedule job cycle detail: https://www.npmjs.com/package/node-schedule 39 | * @param {Number} maxPage one schedule job fetch max pages 40 | * @param {Number} maxNum db houses max number 41 | * @param {Number} delNum if over db houses max number, delete how many numbers 42 | */ 43 | class Robot { 44 | constructor( 45 | cycle = { second: 0, minute: 0, hour: 0 }, 46 | maxPage = 10, 47 | maxNum = 5000, 48 | delNum = 500 49 | ) { 50 | // group list url 51 | this.groupPrefixUrl = 52 | 'https://www.douban.com/group/gz020/discussion?start='; 53 | // group topic detail url 54 | this.topicUrl = 'https://www.douban.com/group/topic/'; 55 | this.page = 0; 56 | this.waitTime = () => { 57 | return Math.ceil(Math.random() * 10 * 1000 + Math.random() * 777); 58 | }; 59 | this.maxPage = maxPage; 60 | this.maxNum = maxNum; 61 | this.delNum = delNum; 62 | this.cycle = cycle; 63 | // this.cycle = { 64 | // second: new Date().getSeconds() + 3, 65 | // minute: new Date().getMinutes() 66 | // }; 67 | this.timer = null; 68 | 69 | this.init(); 70 | } 71 | 72 | // init 73 | async init() { 74 | // if need delete 75 | try { 76 | await this.handleDelete(this.maxNum, this.delNum); 77 | } catch (e) { 78 | console.error(e); 79 | } 80 | 81 | // everyday at 0:00am 82 | scheduleJob(this.cycle, () => { 83 | console.log(`start scheduleJob, time: ${new Date()}`); 84 | 85 | this.page = 0; 86 | 87 | const handleFetchComplete = () => { 88 | this.page++; 89 | // if maxPages clear timer 90 | if (this.page === this.maxPage) { 91 | this.timer = null; 92 | return; 93 | } 94 | this.timer = setTimeout(fetchAndInsert, this.waitTime()); 95 | }; 96 | 97 | const fetchAndInsert = async () => { 98 | console.log(`start fetchList, current page: ${this.page}`); 99 | 100 | clearTimeout(this.timer); 101 | // fetch & insert 102 | let data = await this.fetchList(); 103 | 104 | try { 105 | await this.insertToDB(data); 106 | handleFetchComplete(); 107 | } catch (e) { 108 | handleFetchComplete(); 109 | } 110 | }; 111 | 112 | // start 113 | fetchAndInsert(); 114 | }); 115 | } 116 | 117 | // fetch list 118 | fetchList() { 119 | const that = this; 120 | return new Promise((resolve, reject) => { 121 | axios 122 | .get(that.groupPrefixUrl + that.page * 25, createUserAgent()) 123 | // .get(`http://localhost:3003/${that.page}.html`) 124 | .then(res => { 125 | resolve(that.handleListData(res)); 126 | }) 127 | .catch(err => { 128 | console.error('fetch list error'); 129 | reject(err); 130 | }); 131 | }); 132 | } 133 | 134 | // transform useful list data 135 | handleListData(html) { 136 | const result = []; 137 | const curYear = new Date().getFullYear(); 138 | const $ = cheerio.load(html); 139 | // const $trs = $('table.olt tr').eq(1); 140 | const $trs = $('table.olt tr'); 141 | $trs.each(function(i) { 142 | // if (i >= 0) { 143 | if (i > 0) { 144 | let line = {}; 145 | const $tds = $(this).children('td'); 146 | $tds.each(function() { 147 | let $td = $(this); 148 | // only need `title` & `time` & `author` td 149 | const isTitleTd = $td.hasClass('title'); 150 | const isTimeTd = $td.hasClass('time'); 151 | const isAuthorTd = $td.index() == 1; 152 | if (isTitleTd || isAuthorTd || isTimeTd) { 153 | // title & url 154 | if (isTitleTd) { 155 | const $a = $td.children('a'); 156 | line.title = $a.attr('title'); 157 | line.tid = $a.attr('href').match(/[1-9]\d*/)[0]; 158 | } 159 | // time 160 | if (isTimeTd) { 161 | line.ltime = `${curYear}-${$td.text()}`; 162 | } 163 | // author 164 | if (isAuthorTd) { 165 | line.author = $td.text(); 166 | } 167 | } 168 | }); 169 | result.push(line); 170 | } 171 | }); 172 | return result; 173 | } 174 | 175 | // fetch detail info 176 | fetchDetail(tid) { 177 | const that = this; 178 | return new Promise((resolve, reject) => { 179 | console.log(`start fetchDetail at ${new Date()}`); 180 | axios 181 | .get(that.topicUrl + tid, createUserAgent()) 182 | .then(res => { 183 | resolve(that.handleDetailData(res)); 184 | }) 185 | .catch(err => { 186 | console.error(`fetch id[${tid}] detail error`); 187 | reject(err); 188 | }); 189 | }); 190 | } 191 | 192 | // transform useful detail data 193 | handleDetailData(html) { 194 | const $ = cheerio.load(html); 195 | const text = $('#link-report .topic-richtext') 196 | .text() 197 | .trim(); 198 | const cTime = $('#content h3 .color-green').text(); 199 | const userface = $('.user-face img').attr('src'); 200 | // extract useful infomations 201 | let houseInfo = extractHouse(text); 202 | houseInfo.content = text; 203 | houseInfo.ctime = cTime; 204 | houseInfo.userface = userface; 205 | 206 | //if have images, add to infomations 207 | const $imgs = $('#link-report img'); 208 | if ($imgs.length) { 209 | let imgArr = []; 210 | $imgs.each(function() { 211 | imgArr.push($(this).attr('src')); 212 | }); 213 | houseInfo.imgs = imgArr; 214 | } 215 | return houseInfo; 216 | } 217 | 218 | // update detail info 219 | async updateTopic(tid, resolve, reject) { 220 | // sleep 221 | await sleep(Math.ceil(Math.random() * 50 * 1000) + 5000); 222 | // fetch and update 223 | await this.fetchDetail(tid).then(houseInfo => { 224 | db.House.findOneAndUpdate({ tid: tid }, houseInfo, null) 225 | .then(() => { 226 | console.log(`success update tid ${tid}`); 227 | resolve(); 228 | }) 229 | .catch(err => { 230 | console.error( 231 | `findOneAndUpdate error, at ${new Date()}:${err.message}` 232 | ); 233 | reject(err); 234 | }); 235 | }); 236 | } 237 | // write to mongodb 238 | insertToDB(data) { 239 | let that = this; 240 | return new Promise((resolve, reject) => { 241 | if (data.length) { 242 | db.House.insertMany(data, { ordered: false }) 243 | .then(doc => { 244 | console.log(`success insert ${data.length} data at ${new Date()}`); 245 | // avoid fetch duplicate tids, so after insert and fetch detail 246 | doc.map(item => { 247 | that.updateTopic(item.tid, resolve, reject); 248 | }); 249 | }) 250 | .catch(err => { 251 | if (data.length) { 252 | let errTids = []; 253 | // extract tid form data 254 | let originTids = []; 255 | data.map(item => { 256 | originTids.push(item.tid); 257 | }); 258 | 259 | // error tids 260 | err.writeErrors.map(item => { 261 | let tidRes = item.errmsg.match(/(dup key\: \{).*(\d)+" \}/g); 262 | if (tidRes) { 263 | let tid = tidRes[0].replace(/\D/g, ''); 264 | errTids.push(tid); 265 | } 266 | }); 267 | console.log(`insert db fail tids:${JSON.stringify(errTids)}`); 268 | 269 | // save success ids for fetch detail 270 | let successTids = easyArrDiff(originTids, errTids); 271 | console.log(`success tids: ${JSON.stringify(successTids)}`); 272 | successTids.map(item => { 273 | that.updateTopic(item, resolve, reject); 274 | }); 275 | reject(err); 276 | } else { 277 | reject('no data'); 278 | console.error('have no data'); 279 | } 280 | }); 281 | } 282 | }); 283 | } 284 | 285 | // judge if need delete 286 | handleDelete(maxNum, delNum) { 287 | db.House.countDocuments('tid').then(len => { 288 | return len >= maxNum ? Promise.resolve(this.deleteDB(delNum)) : false; 289 | }); 290 | } 291 | 292 | // delete data from db 293 | deleteDB(num) { 294 | return new Promise(resolve => { 295 | const ids = []; 296 | 297 | // [{_id:123}, {_id:456}] => [123,456] 298 | db.House.find() 299 | .sort({ _id: 1 }) 300 | .select('tid') 301 | .limit(num) 302 | .exec() 303 | .then(doc => { 304 | if (doc.length) { 305 | doc.map(e => { 306 | ids.push(e.tid); 307 | }); 308 | db.House.remove({ tid: { $in: ids } }, (err, success) => { 309 | if (err) { 310 | console.error(err); 311 | } else { 312 | resolve(); 313 | console.log( 314 | `success delete ${success.n} data at ${new Date()}` 315 | ); 316 | } 317 | }); 318 | } 319 | }); 320 | }); 321 | } 322 | } 323 | 324 | module.exports = Robot; 325 | -------------------------------------------------------------------------------- /src/components/Filters.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Menu, Icon } from 'antd-mobile'; 4 | import { setSelectedMenu } from '@/store/actions/houseList'; 5 | import { resolveScopedStyles } from '@/util'; 6 | import { menu1, menu2, menu3, menu4 } from '@/util/filterMenuItem'; 7 | import SvgIcon from 'comp/SvgIcon'; 8 | 9 | // 用于还原状态 10 | const initShow = { 11 | area: false, 12 | type: false, 13 | money: false, 14 | sort: false 15 | }; 16 | 17 | class Filters extends Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | show: { 22 | area: false, 23 | type: false, 24 | money: false, 25 | sort: false 26 | }, 27 | areaText: props.selectedMenu[0] && props.selectedMenu[0].label, 28 | typeText: props.selectedMenu[1] && props.selectedMenu[1].label, 29 | moneyText: props.selectedMenu[2] && props.selectedMenu[2].label, 30 | sortText: props.selectedMenu[3] && props.selectedMenu[3].label 31 | }; 32 | } 33 | 34 | // 筛选菜单点击 35 | handleFilterClick(type) { 36 | let { show } = this.state; 37 | for (let key in show) { 38 | if (key !== type) { 39 | show[key] = false; 40 | } 41 | } 42 | this.props.open(); 43 | this.setState({ 44 | show: Object.assign(show, { [type]: true }) 45 | }); 46 | } 47 | 48 | // 重置还原状态为false 49 | reset() { 50 | this.setState({ 51 | show: Object.assign({}, initShow) 52 | }); 53 | } 54 | 55 | // 菜单下拉选择事件 56 | onChange = (type, v) => { 57 | let label; 58 | switch (type) { 59 | case 'area': 60 | menu1.forEach(item => { 61 | // 处理区域 62 | if (item.value === 'area') { 63 | item.children.forEach(cItem => { 64 | if (v[1] === cItem.label) { 65 | label = cItem.label; 66 | return; 67 | } 68 | }); 69 | return; 70 | } else if (item.value === 'subway') { 71 | item.children.forEach(cItem => { 72 | if (v[1] === cItem.value) { 73 | label = cItem.label; 74 | return; 75 | } 76 | }); 77 | } 78 | }); 79 | label === '不限' 80 | ? this.setState({ areaText: '区域' }) 81 | : this.setState({ areaText: label }); 82 | this.handleSetMenu(0, { label: label, value: v }); 83 | break; 84 | case 'type': 85 | menu2.forEach(item => { 86 | // 处理出租类型 87 | if (item.value === v[0]) { 88 | label = item.label; 89 | return; 90 | } 91 | }); 92 | label === '不限' 93 | ? this.setState({ typeText: '出租类型' }) 94 | : this.setState({ typeText: label }); 95 | this.handleSetMenu(1, { label: label, value: v }); 96 | break; 97 | case 'money': 98 | menu3.forEach(item => { 99 | // 处理租金 100 | if (item.value === v[0]) { 101 | label = item.label; 102 | return; 103 | } 104 | }); 105 | label === '不限' 106 | ? this.setState({ moneyText: '租金' }) 107 | : this.setState({ moneyText: label }); 108 | this.handleSetMenu(2, { label: label, value: v }); 109 | break; 110 | case 'sort': 111 | this.sortText = 'select'; 112 | this.handleSetMenu(3, { label: 'sortText', value: v }); 113 | break; 114 | 115 | default: 116 | break; 117 | } 118 | this.props.change(type, v); 119 | this.reset(); 120 | }; 121 | 122 | // 下拉菜单mask点击关闭 123 | onMaskClick = () => { 124 | this.reset(); 125 | this.props.close(); 126 | }; 127 | 128 | // 检查是否有下拉菜单打开 129 | checkShow(type) { 130 | let { show } = this.state; 131 | // if no type it indicates is the mask 132 | if (type) { 133 | return show[type] === true ? true : false; 134 | } else { 135 | for (let key in show) { 136 | if (show[key] === true) { 137 | return true; 138 | } 139 | } 140 | return false; 141 | } 142 | } 143 | 144 | // 设置redux下拉菜单 145 | handleSetMenu(index, item) { 146 | const { dispatch, selectedMenu } = this.props; 147 | let curMenu = selectedMenu.slice(); 148 | curMenu[index] = item; 149 | dispatch(setSelectedMenu(curMenu)); 150 | } 151 | 152 | render() { 153 | const scoped = resolveScopedStyles( 154 | 155 | 177 | 178 | ); 179 | const { 180 | show: { area, type, money, sort }, 181 | areaText, 182 | typeText, 183 | moneyText, 184 | sortText 185 | } = this.state; 186 | 187 | const { selectedMenu } = this.props; 188 | return ( 189 |
190 |
191 |
this.handleFilterClick('area')} 194 | > 195 | {this.state.areaText || '区域'} 196 | 202 |
203 |
this.handleFilterClick('type')} 206 | > 207 | {this.state.typeText || '出租类型'} 208 | 214 |
215 |
this.handleFilterClick('money')} 218 | > 219 | {this.state.moneyText || '租金'} 220 | 226 |
227 |
this.handleFilterClick('sort')} 230 | > 231 | 237 |
238 |
239 |
240 |
241 | this.onChange('area', v)} 248 | onOk={this.onOk} 249 | onCancel={this.onCancel} 250 | height={document.documentElement.clientHeight * 0.55} 251 | /> 252 |
253 |
254 | this.onChange('type', v)} 262 | onOk={this.onOk} 263 | onCancel={this.onCancel} 264 | height="auto" 265 | /> 266 |
267 |
268 | this.onChange('money', v)} 276 | onOk={this.onOk} 277 | onCancel={this.onCancel} 278 | height="auto" 279 | /> 280 |
281 |
282 | this.onChange('sort', v)} 290 | onOk={this.onOk} 291 | onCancel={this.onCancel} 292 | height="auto" 293 | /> 294 |
295 |
296 |
300 | 343 | {scoped.styles} 344 |
345 | ); 346 | } 347 | } 348 | 349 | const mapStateToProps = state => ({ 350 | selectedMenu: state.houseList.selectedMenu 351 | }); 352 | export default connect(mapStateToProps)(Filters); 353 | -------------------------------------------------------------------------------- /src/views/HouseDetail.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { List, Toast, Modal } from 'antd-mobile'; 4 | import SwipeableViews from 'react-swipeable-views'; 5 | import { autoPlay } from 'react-swipeable-views-utils'; 6 | import Pagination from 'comp/Pagination'; 7 | import Button from 'comp/Button'; 8 | import WarnTips from 'comp/WarnTips'; 9 | import { GetHouseById, UserLikeHouse } from '@/api'; 10 | import LazyImage from 'comp/LazyImage'; 11 | import SvgIcon from 'comp/SvgIcon'; 12 | import { resolveScopedStyles } from '@/util'; 13 | 14 | const douban_prefix = `https://www.douban.com/group/topic/`; 15 | const AutoPlaySwipeableViews = autoPlay(SwipeableViews); 16 | 17 | const scoped = resolveScopedStyles( 18 | 19 | 44 | 45 | ); 46 | 47 | class HouseDetail extends Component { 48 | constructor(props) { 49 | super(props); 50 | this.state = { 51 | index: 0, 52 | house: { 53 | imgs: [] 54 | }, 55 | isLike: false 56 | }; 57 | } 58 | 59 | handleChangeIndex = index => { 60 | this.setState({ 61 | index 62 | }); 63 | }; 64 | 65 | componentDidMount() { 66 | GetHouseById(this.props.match.params.id).then(res => { 67 | if (res && res.code === 1) { 68 | this.setState({ 69 | house: res.data.house, 70 | isLike: res.data.isLike 71 | }); 72 | } 73 | }); 74 | } 75 | 76 | // 点击喜欢获取取消喜欢 77 | handleLike = () => { 78 | const { isLogin, history } = this.props; 79 | if (isLogin) { 80 | const tid = this.props.match.params.id; 81 | let { isLike } = this.state; 82 | UserLikeHouse(tid, isLike).then(res => { 83 | if (res && res.code === 1) { 84 | this.setState({ isLike: !isLike }, () => Toast.show(res.msg, 1)); 85 | } 86 | }); 87 | } else { 88 | history.push('/login'); 89 | } 90 | }; 91 | 92 | render() { 93 | let { house, index, isLike } = this.state; 94 | const Alert = Modal.alert; 95 | return ( 96 |
97 |
98 | {house.imgs.length ? ( 99 | 100 | 105 | {house.imgs.map(i => ( 106 | 107 | ))} 108 | 109 | 114 | 115 | ) : ( 116 | 117 | )} 118 |
119 | 120 |
121 |

122 | {house.title} 123 |
124 |

125 | 126 | 127 | 128 | 132 |
133 | {house.author} 134 | {house.ctime} 135 |
136 |
137 |
138 | 139 |
140 |
141 | {house.model ? ( 142 |

Toast.show(house.model)}>{house.model}

143 | ) : ( 144 |

暂无

145 | )} 146 | 房型 147 |
148 |
149 |

{house.size || '暂无'}

150 | 总面积 151 |
152 |
153 |

{house.price || '暂无'}

154 | 价格 155 |
156 |
157 |
158 |
{house.content}
159 | 160 |
161 | 162 | 163 |
164 |
165 |
166 |
167 | 173 |

{ 175 | if ( 176 | house.contact && 177 | house.contact.type === 'wechat' && 178 | house.contact.value 179 | ) { 180 | return Alert('微信', house.contact.value); 181 | } else { 182 | return Toast.show('暂无'); 183 | } 184 | }} 185 | > 186 | 微信 187 |

188 |
189 |
190 | 196 |

197 | {house.contact && 198 | house.contact.type && 199 | (house.contact.type === 'phone' || 200 | house.contact.type === 'mobile') ? ( 201 | 电话 202 | ) : ( 203 | Toast.show('暂无')}>电话 204 | )} 205 |

206 |
207 |
208 |
209 | 224 | 239 |
240 |
241 | 337 | {scoped.styles} 338 |
339 | ); 340 | } 341 | } 342 | 343 | const mapStateToProps = state => ({ isLogin: Boolean(state.user.token) }); 344 | 345 | export default connect(mapStateToProps)(HouseDetail); 346 | --------------------------------------------------------------------------------