├── 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 |
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 |
39 |
46 |
47 | );
48 | }
49 | }
50 |
51 | export default LazyImage;
52 |
--------------------------------------------------------------------------------
/src/router/routes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Loadable from 'react-loadable';
3 | import Tabs from '@/views/Tabs';
4 | import HouseSearch from '@/views/HouseSearch';
5 |
6 | function LoadingComponent({ error, pastDelay }) {
7 | if (error) {
8 | return 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 |
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 |
102 |
103 | {searchHistory.map((item, index) => (
104 |
this.historyClick(item)}
108 | >
109 |
110 |
{item}
111 |
112 |
113 | ))}
114 |
115 |
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 |
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 |
253 |
254 |
267 |
268 |
281 |
282 |
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 |
159 |
160 |
161 |
162 |
163 |
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 |
--------------------------------------------------------------------------------