Kaynağa Gözat

add campus manage module

zhanghe 7 yıl önce
ebeveyn
işleme
374ff8e213

+ 68 - 0
.roadhogrc.mock.js

@@ -1,8 +1,76 @@
 import mockjs from 'mockjs';
 import { format, delay } from 'roadhog-api-doc';
+import { campusList } from './mock/campus';
+import * as api from './src/utils/api';
+
+// mock数据持久化
+global.campusList = campusList;
+
+// 操作成功响应内容
+const successMsg = { code: 200, success: true, message: null };
+
+// 查询
+const query = (dataset, params) => {
+  const pageSize = parseInt(params.pageSize) || 10;
+  const pageNo = parseInt(params.pageNo) || 1;
+  delete params['pageSize'];
+  delete params['pageNo'];
+  const queryKeys = Object.keys(params);
+  const newDataset = dataset.filter(item => {
+    let flag = true;
+    for (let key of queryKeys) {
+      if (item[key] !== params[key]) {
+        flag = false;
+        break;
+      }
+    }
+    return flag;
+  });
+  const totalSize = newDataset.length || 0;
+  const list = newDataset.slice(pageSize * (pageNo - 1), pageSize * pageNo);
+  return {
+    ...successMsg,
+    data: { pageSize, pageNo, totalSize: newDataset.length, list }
+  };
+}
+
+// 添加
+const create = (dataset, params) => {
+  const last = dataset[dataset.length - 1];
+  const data = { ...params, id: last.id + 1 };
+  dataset.push(data);
+  return {
+    ...successMsg,
+    data,
+  };
+}
+
+// 更新
+const update = (dataset, params) => {
+  for (let index in dataset) {
+    if (dataset[index].id === params.id) {
+      dataset[index] = { ...dataset[index], ...params };
+    }
+  }
+  return {
+    ...successMsg,
+  }
+}
 
 // mock数据
 const proxy = {
+  [`GET ${api.campuses}`]: (req, res) => {
+    console.log('[CampusList]', req.query);
+    res.send(query(global.campusList, req.query));
+  },
+  [`POST ${api.campus.replace('/:id', '')}`]: (req, res) => {
+    console.log('[CampusCreate]', req.body);
+    res.send(create(global.campusList, req.body));
+  },
+  [`PUT ${api.campus.replace('/:id', '')}`]: (req, res) => {
+    console.log('[CampusUpdate]', req.body);
+    res.send(update(global.campusList, req.body));
+  },
 };
 
 // 是否禁用代理

+ 20 - 0
mock/campus.js

@@ -0,0 +1,20 @@
+const campusList = [];
+for (let i = 1; i <= 20; i++) {
+  campusList.push({
+    id: 'd513401461284feea555a3e0abc5974a' + i,
+    code: 'campus-code-' + i,
+    name: '湖南-长沙 天心区-育新校区',
+    zoneName: '育新校区',
+    cityName: '长沙市 天心区',
+    provinceCode: '43',
+    contactName: '张三',
+    mobile: '18830997718',
+    bankAccount: '222 4301 2220 0983 098',
+    depositBank: '中国工商银行北京支行',
+    address: '湖南省长沙市北太平庄街道38号',
+    gmtCreated: 1512960192010,
+    gmtModified: 1512960192010,
+  });
+}
+
+module.exports = { campusList };

+ 25 - 6
package.json

@@ -36,6 +36,7 @@
     "numeral": "^2.0.6",
     "prop-types": "^15.5.10",
     "qs": "^6.5.0",
+    "query-string": "^5.0.1",
     "rc-drawer-menu": "^0.5.0",
     "react": "^16.0.0",
     "react-container-query": "^0.9.1",
@@ -55,6 +56,7 @@
     "babel-preset-react": "^6.24.1",
     "cross-env": "^5.1.1",
     "cross-port-killer": "^1.0.1",
+    "dva-model-extend": "^0.1.2",
     "enzyme": "^3.1.0",
     "enzyme-adapter-react-16": "^1.0.2",
     "eslint": "^4.8.0",
@@ -84,14 +86,27 @@
     "nightmare": "^2.10.0"
   },
   "babel": {
-    "presets": ["env", "react"],
-    "plugins": ["transform-decorators-legacy", "transform-class-properties"]
+    "presets": [
+      "env",
+      "react"
+    ],
+    "plugins": [
+      "transform-decorators-legacy",
+      "transform-class-properties"
+    ]
   },
   "jest": {
-    "setupFiles": ["<rootDir>/tests/setupTests.js"],
-    "testMatch": ["**/?(*.)(spec|test|e2e).js?(x)"],
+    "setupFiles": [
+      "<rootDir>/tests/setupTests.js"
+    ],
+    "testMatch": [
+      "**/?(*.)(spec|test|e2e).js?(x)"
+    ],
     "setupTestFrameworkScriptFile": "<rootDir>/tests/jasmine.js",
-    "moduleFileExtensions": ["js", "jsx"],
+    "moduleFileExtensions": [
+      "js",
+      "jsx"
+    ],
     "moduleNameMapper": {
       "\\.(css|less)$": "<rootDir>/tests/styleMock.js"
     }
@@ -100,5 +115,9 @@
     "**/*.{js,jsx}": "lint-staged:js",
     "**/*.less": "stylelint --syntax less"
   },
-  "browserslist": ["> 1%", "last 2 versions", "not ie <= 10"]
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not ie <= 10"
+  ]
 }

+ 3 - 0
src/common/router.js

@@ -36,6 +36,9 @@ export const getRouterData = (app) => {
     '/': {
       component: dynamicWrapper(app, ['user', 'login'], () => import('../layouts/BasicLayout')),
     },
+    '/terminal/campus': {
+      component: dynamicWrapper(app, ['campus'], () => import('../routes/Campus')),
+    },
     '/user': {
       component: dynamicWrapper(app, [], () => import('../layouts/UserLayout')),
     },

+ 67 - 0
src/components/Animation/AnimTableBody.js

@@ -0,0 +1,67 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { TweenOneGroup } from 'rc-tween-one'
+
+const enterAnim = [
+  {
+    opacity: 0,
+    x: 30,
+    backgroundColor: '#fffeee',
+    duration: 0,
+  }, {
+    height: 0,
+    duration: 200,
+    type: 'from',
+    delay: 250,
+    ease: 'easeOutQuad',
+    onComplete: (e) => {
+      e.target.style.height = 'auto'
+    },
+  }, {
+    opacity: 1,
+    x: 0,
+    duration: 250,
+    ease: 'easeOutQuad',
+  }, {
+    delay: 1000,
+    backgroundColor: '#fff',
+  },
+]
+
+const leaveAnim = [
+  {
+    duration: 250,
+    x: -30,
+    opacity: 0,
+  }, {
+    height: 0,
+    duration: 200,
+    ease: 'easeOutQuad',
+  },
+]
+
+const AnimTableBody = ({ body, page = 1, current }) => {
+  if (current !== +page) {
+    return body
+  }
+
+  return (
+    <TweenOneGroup
+      component="tbody"
+      className={body.props.className}
+      enter={enterAnim}
+      leave={leaveAnim}
+      appear={false}
+    >
+      {body.props.children}
+    </TweenOneGroup>
+  )
+}
+
+AnimTableBody.propTypes = {
+  body: PropTypes.element,
+  page: PropTypes.any,
+  current: PropTypes.number.isRequired,
+}
+
+export default AnimTableBody

+ 62 - 0
src/components/DataSearch/index.js

@@ -0,0 +1,62 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { Input, Select, Button, Icon } from 'antd';
+import styles from './index.less';
+
+export default class DataSearch extends PureComponent {
+  static propTypes = {
+    size: PropTypes.string,
+    select: PropTypes.bool,
+    selectProps: PropTypes.object,
+    onSearch: PropTypes.func,
+    selectOptions: PropTypes.array,
+    style: PropTypes.object,
+    keyword: PropTypes.string,
+  }
+  constructor(props) {
+    super(props);
+    const { select, selectProps, keyword } = this.props;
+    this.state = {
+      selectValue: select && selectProps ? selectProps.defaultValue : '',
+      inputValue: keyword ? keyword : '',
+    }
+  }
+  handleSearch = () => {
+    const query = { keyword: this.state.inputValue };
+    if (this.props.select) {
+      query.field = this.state.selectValue;
+    }
+    this.props.onSearch && this.props.onSearch(query);
+  }
+  handleSelectChange = (value) => {
+    this.setState({
+      ...this.state,
+      selectValue: value,
+    });
+  }
+  handleInputChange = (e) => {
+    this.setState({
+      ...this.state,
+      inputValue: e.target.value,
+    });
+  }
+  handleClearInput = () => {
+    this.setState({
+      inputValue: '',
+    }, () => this.handleSearch());
+  }
+  render() {
+    const { size, select, selectOptions, selectProps, style } = this.props;
+    const { inputValue } = this.state;
+    const suffix = inputValue ? <Icon type="close-circle" onClick={this.handleClearInput} /> : null;
+    return (
+      <Input.Group compact size={size} className={styles.search} style={style}>
+        {select && <Select onChange={this.handleSelectChange} size={size} {...selectProps}>
+          {selectOptions && selectOptions.map((item, key) => <Select.Option value={item.value} key={key}>{item.name || item.value}</Select.Option>)}
+        </Select>}
+        <Input placeholder="请输入" suffix={suffix} onChange={this.handleInputChange} onPressEnter={this.handleSearch} size={size} value={inputValue}/>
+        <Button onClick={this.handleSearch} size={size} type="primary" icon="search">搜索</Button>
+      </Input.Group>
+    );
+  }
+}

+ 55 - 0
src/components/DataSearch/index.less

@@ -0,0 +1,55 @@
+.no-highlight() {
+  border-color: #e5e5e5;
+  box-shadow: none;
+}
+
+.search {
+  display: flex !important;
+  width: 100%;
+  position: relative;
+
+  :global {
+    .anticon-cross {
+      position: absolute;
+      width: 18px;
+      height: 18px;
+      right: 94px;
+      cursor: pointer;
+      color: #fff;
+      line-height: 18px;
+      border-radius: 50%!important;
+      background-color: rgba(0, 0, 0, .16);
+      top: 7px;
+      // opacity:
+    }
+
+    .ant-select {
+      width: 100px;
+      flex-shrink: 0;
+      flex-grow: 0;
+
+      &.ant-select-focused .ant-select-selection,
+      &.ant-select-open .ant-select-selection,
+      .ant-select-selection:active,
+      .ant-select-selection:focus,
+      .ant-select-selection:hover {
+        .no-highlight();
+      }
+    }
+
+    .ant-input {
+      &:focus,
+      &:hover {
+        .no-highlight();
+      }
+      flex-shrink: 1;
+      flex-grow: 1;
+    }
+
+    .ant-btn {
+      width: 80px;
+      flex-shrink: 0;
+      flex-grow: 0;
+    }
+  }
+}

+ 84 - 0
src/components/DataTable/DataTable.js

@@ -0,0 +1,84 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'dva';
+import { Table } from 'antd';
+import classnames from 'classnames';
+import TableBodyWrapper from './TableBodyWrapper'
+
+class DataTable extends PureComponent {
+  static propTypes = {
+    location: PropTypes.object,
+    animate: PropTypes.bool,
+    rowKey: PropTypes.oneOfType([
+      PropTypes.string,
+      PropTypes.func,
+    ]).isRequired,
+    pagination: PropTypes.oneOfType([
+      PropTypes.bool,
+      PropTypes.object,
+    ]).isRequired,
+    columns: PropTypes.array.isRequired,
+    dataSource: PropTypes.array.isRequired,
+    className: PropTypes.string,
+    onPageChange: PropTypes.func,
+  };
+
+  static defaultProps = {
+    animate: true,
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      prevPage: 1,
+      currentPage: 1,
+    };
+  }
+
+  handlePageChange = (page) => {
+    const { location, pagination, onPageChange } = this.props;
+    const { query } = location;
+    this.setState({
+      prevPage: page.current,
+      pageSize: page.pageSize,
+    });
+    onPageChange({
+      ...query,
+      pageNo: page.current,
+      pageSize: page.pageSize,
+    });
+  }
+
+  handleChangePrevPage = () => {
+    this.setState({
+      prevPage: this.state.currentPage,
+    });
+  }
+
+  render() {
+    const { location, className, pagination, onPageChange, animate, ...props } = this.props;
+    const { prevPage, currentPage } = this.state;
+    const getBodyWrapperProps = {
+      prevPage,
+      currentPage,
+      onChangePrevPage: this.handleChangePrevPage,
+    };
+    const getBodyWrapper = body => (<TableBodyWrapper {...getBodyWrapperProps} body={body} />);
+    let tableProps = {
+      simple: true,
+      bordered: true,
+      onChange: this.handlePageChange,
+      pagination: !!pagination && { ...pagination, showSizeChanger: true, showQuickJumper: true, showTotal: total => `共 ${total} 条`},
+      ...props,
+    };
+    if (animate) {
+      tableProps.getBodyWrapper = getBodyWrapper;
+      tableProps.className = classnames(className, 'table-motion');
+    }
+    return (
+      <Table { ...tableProps } />
+    );
+  }
+}
+
+export default DataTable;

+ 78 - 0
src/components/DataTable/TableBodyWrapper.js

@@ -0,0 +1,78 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { TweenOneGroup } from 'rc-tween-one';
+import './TableBodyWrapper.less';
+
+const enterAnim = [
+  {
+    opacity: 0,
+    x: 30,
+    backgroundColor: '#fffeee',
+    duration: 0,
+  },{
+    height: 0,
+    duration: 200,
+    type: 'from',
+    delay: 250,
+    ease: 'easeOutQuad',
+    onComplete:(e) => {
+      e.target.style.height = 'auto';
+    }
+  },{
+    opacity: 1,
+    x: 0,
+    duration: 250,
+    ease: 'easeOutQuad',
+  },{
+    delay: 1000,
+    backgroundColor: '#fff',
+  }
+];
+const leaveAnim = [
+  {
+    duration: 250,
+    x: -30,
+    opacity: 0,
+  },{
+    height: 0,
+    duration: 200,
+    ease: 'easeOutQuad',
+  }
+];
+
+class TableBodyWrapper extends PureComponent {
+  static propTypes = {
+    body: PropTypes.element,
+    prevPage: PropTypes.number.isRequired,
+    currentPage: PropTypes.number.isRequired,
+    onChangePrevPage: PropTypes.func.isRequired,
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.currentPage !== nextProps.currentPage) {
+      // 等待动画执行完成后修正prevPage
+      setTimeout(() => { this.props.onChangePrevPage() }, 300)
+    }
+  }
+
+  render () {
+    const { body, prevPage, currentPage } = this.props
+    // 切换分页去除动画;
+    if (prevPage !== currentPage) {
+      return body
+    }
+    return (
+      <TweenOneGroup
+        component="tbody"
+        className={body.props.className}
+        enter={enterAnim}
+        leave={leaveAnim}
+        appear={false}
+      >
+        {body.props.children}
+      </TweenOneGroup>
+    )
+  }
+}
+
+export default TableBodyWrapper;

+ 40 - 0
src/components/DataTable/TableBodyWrapper.less

@@ -0,0 +1,40 @@
+:global {
+  .table-motion {
+    .ant-table-tbody > tr > td,
+    .ant-table-thead > tr > th {
+      height: 60px;
+    }
+
+    .ant-table-thead {
+      & > tr {
+        transition: none;
+        display: block;
+
+        & > th {
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+        }
+      }
+    }
+
+    .ant-table-tbody {
+      & > tr {
+        transition: none;
+        display: block;
+        border-bottom: 1px solid #f5f5f5;
+
+        & > td {
+          border-bottom: none;
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+        }
+
+        &.ant-table-expanded-row-level-1 > td {
+          height: auto;
+        }
+      }
+    }
+  }
+}

+ 1 - 0
src/components/DataTable/index.js

@@ -0,0 +1 @@
+export DataTable './DataTable';

+ 6 - 0
src/components/DataTable/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "DataTable",
+  "version": "0.0.1",
+  "private": true,
+  "main": "./DataTable.js"
+}

+ 1 - 1
src/components/GlobalFooter/index.less

@@ -2,7 +2,7 @@
 
 .globalFooter {
   padding: 0 16px;
-  margin: 48px 0 24px 0;
+  margin: 24px 0 24px 0;
   text-align: center;
 
   .links {

+ 1 - 1
src/components/GlobalHeader/index.js

@@ -88,7 +88,7 @@ export default class GlobalHeader extends PureComponent {
     const {
       collapsed, fetchingNotices, isMobile,
     } = this.props;
-    const currentUser = getLocalUser();
+    const currentUser = getLocalUser() || {};
     const menu = (
       <Menu className={styles.menu} selectedKeys={[]} onClick={this.handleMenuClick}>
         <Menu.Item disabled><Icon type="user" />个人中心</Menu.Item>

+ 2 - 1
src/components/GlobalHeader/index.less

@@ -32,7 +32,8 @@
     margin-right: 8px;
   }
   :global(.ant-dropdown-menu-item) {
-    width: 100px;
+    text-align: center;
+    width: 100%;
   }
 }
 

+ 4 - 5
src/index.js

@@ -8,16 +8,15 @@ import './g2';
 import browserHistory from 'history/createBrowserHistory';
 import './index.less';
 
-const expiredErrorHandler = (err) => {
+const globalErrorHandler = (err) => {
   if (err.response && err.response.code === 10004) {
     message.error('登录失效,请重新登录!');
     app._store.dispatch(routerRedux.push(router.login));
   }
   else {
-    console.log('[ERROR]:', err);
     notification.error({
-      message: '未知错误',
-      description: '发生未知错误,请查看日志或联系管理员!',
+      message: '程序错误',
+      description: '应用内部发生未知错误,请查看日志或联系管理员!',
     });
   }
 }
@@ -25,7 +24,7 @@ const expiredErrorHandler = (err) => {
 // 1. Initialize
 const app = dva({
   history: browserHistory(),
-  onError: expiredErrorHandler,
+  onError: globalErrorHandler,
 });
 
 // 2. Plugins

+ 128 - 0
src/index.less

@@ -8,6 +8,134 @@ body {
   -moz-osx-font-smoothing: grayscale;
 }
 
+::-webkit-scrollbar-thumb {
+  background-color: #e6e6e6;
+}
+
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+:global {
+  .ant-breadcrumb {
+    & > span {
+      &:last-child {
+        color: #999;
+        font-weight: normal;
+      }
+    }
+  }
+
+  .ant-breadcrumb-link {
+    .anticon + span {
+      margin-left: 4px;
+    }
+  }
+
+  .ant-table {
+    .ant-table-thead > tr > th {
+      text-align: center;
+    }
+
+    .ant-table-tbody > tr > td {
+      text-align: center;
+    }
+
+    &.ant-table-small {
+      .ant-table-thead > tr > th {
+        background: #f7f7f7;
+      }
+
+      .ant-table-body > table {
+        padding: 0;
+      }
+    }
+  }
+
+  .ant-table-pagination {
+    float: none!important;
+    display: table;
+    margin: 16px auto !important;
+  }
+
+  .ant-popover-inner {
+    border: none;
+    border-radius: 0;
+    box-shadow: 0 0 20px rgba(100, 100, 100, 0.2);
+  }
+
+  .vertical-center-modal {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .ant-modal {
+      top: 0;
+
+      .ant-modal-body {
+        max-height: 80vh;
+        overflow-y: auto;
+      }
+    }
+  }
+
+  .ant-form-item-control {
+    vertical-align: middle;
+  }
+
+  .ant-modal-mask {
+    background-color: rgba(55, 55, 55, 0.2);
+  }
+
+  .ant-modal-content {
+    box-shadow: none;
+  }
+
+  .ant-select-dropdown-menu-item {
+    padding: 12px 16px !important;
+  }
+
+  .margin-right {
+    margin-right: 16px;
+  }
+
+  a:focus {
+    text-decoration: none;
+  }
+}
+@media (min-width: 1600px) {
+  :global {
+    .ant-col-xl-48 {
+      width: 20%;
+    }
+
+    .ant-col-xl-96 {
+      width: 40%;
+    }
+  }
+}
+@media (max-width: 767px) {
+  :global {
+    .ant-pagination-item,
+    .ant-pagination-next,
+    .ant-pagination-options,
+    .ant-pagination-prev {
+      margin-bottom: 8px;
+    }
+
+    .ant-card {
+      .ant-card-head {
+        padding: 0 12px;
+      }
+
+      .ant-card-body {
+        padding: 12px;
+      }
+    }
+  }
+}
+
 .globalSpin {
   width: 100%;
   margin: 40px 0 !important;

+ 81 - 0
src/models/campus.js

@@ -0,0 +1,81 @@
+import { query, create, update } from '../services/campus';
+import modelExtend from 'dva-model-extend';
+import queryString from 'query-string';
+import { pageModel } from './common';
+import config from '../utils/config';
+import { checkSearchParams } from '../utils/utils';
+
+export default modelExtend(pageModel, {
+  namespace: 'campus',
+
+  state: {
+    currentItem: {},
+    itemLoading: false,
+    listLoading: false,
+    modalVisible: false,
+    modalType: 'create',
+  },
+
+  subscriptions: {
+    setup({ dispatch, history }) {
+      history.listen((location) => {
+        if (location.pathname === '/terminal/campus') {
+          const payload = checkSearchParams(queryString.parse(location.search));
+          dispatch({
+            type: 'query',
+            payload,
+          });
+        }
+      });
+    }
+  },
+
+  effects: {
+    * query ({ payload = {} }, { call, put }) {
+      yield put({ type: 'changeLoading', payload: { listLoading: true }});
+      const { data, success } = yield call(query, payload);
+      if (success) {
+        yield put({
+          type: 'querySuccess',
+          payload: {
+            list: data.list,
+            pagination: {
+              current: Number(payload.pageNo) || 1,
+              pageSize: Number(payload.pageSize) || config.pageSize,
+              total: data.totalSize,
+            }
+          }
+        });
+      }
+      yield put({ type: 'changeLoading', payload: { listLoading: false }});
+    },
+    * create ({ payload }, { call, put }) {
+      const { data, success } = yield call(create, payload);
+      if (success) {
+        yield put({ type: 'hideModal' });
+        yield put({ type: 'query' }, payload: { pageNo: 1, pageSize: config.pageSize });
+      }
+    },
+    * update ({ payload }, { call, put }) {
+      const { data, success } = yield call(update, payload);
+      if (success) {
+        yield put({ type: 'hideModal' });
+        yield put({ type: 'query', payload: { pageNo: 1, pageSize: config.pageSize} });
+      }
+    }
+  },
+
+  reducers: {
+    changeLoading(state, { payload }) {
+      return { ...state, ...payload };
+    },
+
+    showModal(state, { payload }) {
+      return { ...state, ...payload, modalVisible: true };
+    },
+
+    hideModal(state) {
+      return { ...state, modalVisible: false };
+    },
+  }
+})

+ 47 - 0
src/models/common.js

@@ -0,0 +1,47 @@
+import modelExtend from 'dva-model-extend';
+
+const model = {
+  reducers: {
+    updateState (state, { payload }) {
+      return {
+        ...state,
+        ...payload,
+      }
+    },
+  },
+}
+
+const pageModel = modelExtend(model, {
+
+  state: {
+    list: [],
+    pagination: {
+      showSizeChanger: true,
+      showQuickJumper: true,
+      showTotal: total => `共 ${total} 条`,
+      current: 1,
+      total: 0,
+    },
+  },
+
+  reducers: {
+    querySuccess (state, { payload }) {
+      const { list, pagination } = payload
+      return {
+        ...state,
+        list,
+        pagination: {
+          ...state.pagination,
+          ...pagination,
+        },
+      }
+    },
+  },
+
+})
+
+
+module.exports = {
+  model,
+  pageModel,
+}

+ 2 - 3
src/models/login.js

@@ -1,4 +1,3 @@
-import { notification } from 'antd';
 import { routerRedux } from 'dva/router';
 import { login, logout } from '../services/login';
 import { routers } from '../utils/map';
@@ -12,7 +11,7 @@ export default {
   effects: {
     *login({ payload }, { put, call }) {
       yield put({ type: 'save', payload: { loading: true } });
-      const { res: { data, success } } = yield call(login, { ...payload });
+      const { data, success } = yield call(login, payload);
       if (success) {
         addLocalUser(data);
         yield put(routerRedux.push(routers.home));
@@ -20,7 +19,7 @@ export default {
       yield put({ type: 'save', payload: { loading: false } });
     },
     *logout(_, { put, call }) {
-      const { res: { success } } = yield call(logout);
+      const { success } = yield call(logout);
       if (success) {
         yield put(routerRedux.push(routers.login));
       }

+ 106 - 0
src/routes/Campus/index.js

@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import queryString from 'query-string';
+import { connect } from 'dva';
+import { routerRedux } from 'dva/router';
+import { Card } from 'antd';
+import TableList from './table';
+import ModalForm from './modal';
+import Search from './search';
+import PageHeaderLayout from '../../layouts/PageHeaderLayout';
+
+function Campus({ location, dispatch, campus }) {
+  location.query = queryString.parse(location.search);
+  const { query, pathname } = location;
+  const { field, keyword } = query;
+  const { list, listLoading, pagination, currentItem, itemLoading, modalVisible, modalType } = campus;
+
+  const modalProps = {
+    item: modalType === 'create' ? {} : currentItem,
+    visible: modalVisible,
+    maskClosable: false,
+    title: `${modalType === 'create' ? '添加校区' : '更新校区'}`,
+    wrapClassName: 'vertical-center-modal',
+    onOk (data) {
+      dispatch({
+        type: `campus/${modalType}`,
+        payload: data,
+      });
+    },
+    onCancel () {
+      dispatch({
+        type: 'campus/hideModal',
+      });
+    },
+  };
+
+  const searchProps = {
+    field,
+    keyword,
+    onSearch: (payload) => {
+      if (!payload.keyword.length) {
+        delete payload.field;
+        delete payload.keyword;
+      }
+      dispatch(routerRedux.push({
+        pathname,
+        search: queryString.stringify({
+          ...payload
+        })
+      }));
+    },
+    onAdd: () => {
+      dispatch({
+        type: 'campus/showModal',
+        payload: {
+          modalType: 'create',
+        },
+      });
+    }
+  };
+  const listProps = {
+    dataSource: list,
+    loading: listLoading,
+    pagination,
+    location,
+    onChange: (page) => {
+      dispatch(routerRedux.push({
+        pathname,
+        search: queryString.stringify({
+          ...query,
+          pageNo: page.current,
+          pageSize: page.pageSize,
+        }),
+      }));
+    },
+    onEditItem: (item) => {
+      dispatch({
+        type: 'campus/showModal',
+        payload: {
+          modalType: 'update',
+          currentItem: item,
+        },
+      })
+    },
+    onDeleteItem: () => {
+      //TODO: 暂不提供删除校区功能
+    }
+  };
+  return (
+    <PageHeaderLayout>
+      <Card>
+        <Search { ...searchProps } />
+        <TableList { ...listProps } />
+        {modalVisible && <ModalForm { ...modalProps } />}
+      </Card>
+    </PageHeaderLayout>
+  );
+}
+
+Campus.propTypes = {
+  campus: PropTypes.object,
+  location: PropTypes.object,
+  dispatch: PropTypes.func,
+}
+
+export default connect(({ campus }) => ({ campus }))(Campus);

+ 137 - 0
src/routes/Campus/modal.js

@@ -0,0 +1,137 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Form, Cascader, Input, Modal, Icon } from 'antd';
+import * as city from '../../utils/city';
+
+const FormItem = Form.Item
+
+const formItemLayout = {
+  labelCol: {
+    span: 7,
+  },
+  wrapperCol: {
+    span: 14,
+  },
+}
+
+const ModalForm = ({
+  item = {},
+  onOk,
+  form: {
+    getFieldDecorator,
+    getFieldsValue,
+    validateFields,
+  },
+  ...modalProps,
+}) => {
+  const handleOk = () => {
+    validateFields((errors) => {
+      if (errors) {
+        return
+      }
+      const data = {
+        ...getFieldsValue(),
+      };
+      data.provinceCode = city.provNameToCode(data.cascader[0]);
+      data.cityName = data.cascader.slice(1, 3).join(' ');
+      delete data.cascader;
+      item.id ? data.id = item.id : null;
+      onOk(data);
+    })
+  }
+
+  const modalOpts = {
+    ...modalProps,
+    onOk: handleOk,
+  }
+
+  return (
+    <Modal {...modalOpts}>
+      <Form layout="horizontal">
+        <FormItem label="校区编号:" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('code', {
+            initialValue: item.code,
+            rules: [
+              {
+                required: true,
+                message: '校区编号为必填项!',
+              },
+            ],
+          })(<Input disabled={item.code ? true : false}/>)}
+        </FormItem>
+        <FormItem label="校区地址" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('cascader', {
+            rules: [
+              {
+                required: true,
+                message: '校区地址为必选项!'
+              }
+            ],
+            initialValue: item.provinceCode && [city.provCodeToName(item.provinceCode), ...(item.cityName || '').split(' ')],
+          })(<Cascader
+            style={{ width: '100%' }}
+            options={city.DICT_FIXED}
+            placeholder="请选择地址"
+          />)}
+        </FormItem>
+        <FormItem label="校区名称:" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('zoneName', {
+            initialValue: item.zoneName,
+            rules: [
+              {
+                required: true,
+                message: '校区名称为必填项!',
+              },
+            ],
+          })(<Input placeholder="只填写学校名称即可"/>)}
+        </FormItem>
+        <FormItem label="联系人:" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('contactName', {
+            initialValue: item.contactName,
+            rules: [
+              {
+                required: true,
+                message: '联系人为必填项!',
+              },
+            ],
+          })(<Input />)}
+        </FormItem>
+        <FormItem label="联系电话:" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('mobile', {
+            initialValue: item.mobile,
+            rules: [
+              {
+                required: true,
+                message: '联系电话为必填项!',
+              },
+            ],
+          })(<Input />)}
+        </FormItem>
+        <FormItem label="收货地址" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('address', {
+            initialValue: item.address,
+          })(<Input />)}
+        </FormItem>
+        <FormItem label="银行账户" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('bankAccount', {
+            initialValue: item.bankAccount,
+          })(<Input />)}
+        </FormItem>
+        <FormItem label="开户行名称" hasFeedback {...formItemLayout}>
+          {getFieldDecorator('depositBank', {
+            initialValue: item.depositBank,
+          })(<Input />)}
+        </FormItem>
+      </Form>
+    </Modal>
+  )
+}
+
+ModalForm.propTypes = {
+  item: PropTypes.object,
+  form: PropTypes.object,
+  type: PropTypes.string,
+  onOk: PropTypes.func,
+}
+
+export default Form.create()(ModalForm);

+ 53 - 0
src/routes/Campus/search.js

@@ -0,0 +1,53 @@
+import react, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { Button, Form, Row, Col, Icon } from 'antd';
+import DataSearch from '../../components/DataSearch';
+
+const Search = ({
+  field,
+  keyword,
+  onSearch,
+  onAdd
+}) => {
+  const searchGroupProps = {
+    field,
+    keyword,
+    size: 'default',
+    select: true,
+    selectOptions: [{
+      value: 'name', name: '校区名称'
+    },{
+      value: 'code', name: '校区编号'
+    },{
+      value: 'contactName', name: '联系人'
+    },{
+      value: 'mobile', name: '手机号',
+    }],
+    selectProps: {
+      defaultValue: field || 'name',
+    },
+    onSearch: (value) => {
+      onSearch(value);
+    },
+  }
+  return (
+    <Row gutter={24}>
+      <Col lg={10} md={12} sm={16} xs={24} style={{ marginBottom: 16 }}>
+        <DataSearch { ...searchGroupProps } />
+      </Col>
+      <Col lg={{ offset: 7, span: 7 }} md={12} sm={8} xs={24} style={{ marginBottom: 16, textAlign: 'right' }}>
+        <Button type="ghost" onClick={onAdd}><Icon type="plus-circle" />添加</Button>
+      </Col>
+    </Row>
+  )
+}
+
+Search.propTypes = {
+  form: PropTypes.object.isRequired,
+  onSearch: PropTypes.func,
+  onAdd: PropTypes.func,
+  field: PropTypes.string,
+  keyword: PropTypes.string,
+}
+
+export default Form.create()(Search);

+ 87 - 0
src/routes/Campus/table.js

@@ -0,0 +1,87 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import moment from 'moment';
+import classnames from 'classnames';
+import queryString from 'query-string';
+import { Modal, Table, Menu, Icon } from 'antd';
+import AnimTableBody from '../../components/Animation/AnimTableBody';
+import styles from './table.less';
+
+const confirm = Modal.confirm;
+
+const TableList = ({ onDeleteItem, onEditItem, location, pagination, ...tableProps }) => {
+  // 从url中提取查询参数
+  location.query = queryString.parse(location.search);
+  // 暂不支持删除校区
+  const handleDeleteItem = (record) => {
+    confirm({
+      title: '您确定要删除这条记录吗?',
+      onOk () {
+        onDeleteItem(record.id)
+      },
+    })
+  }
+  const columns = [{
+    title: '校区编号',
+    dataIndex: 'code',
+    key: 'code',
+  },{
+    title: '校区名称',
+    dataIndex: 'name',
+    key: 'name',
+  },{
+    title: '联系人',
+    dataIndex: 'contactName',
+    key: 'contactName',
+  },{
+    title: '电话',
+    dataIndex: 'mobile',
+    key: 'mobile',
+  },{
+    title: '地址',
+    dataIndex: 'address',
+    key: 'address',
+  },{
+    title: '添加时间',
+    dataIndex: 'gmtCreated',
+    key: 'gmtCreated',
+    render: (text, record) => (
+      <div>{moment(text).format('YYYY-MM-DD')}</div>
+    )
+  },{
+    title: '操作',
+    dataIndex: 'operation',
+    key: 'operation',
+    render: (text, record) => (
+      <div>
+        <a onClick={() => onEditItem(record)}>编辑</a>
+      </div>
+    )
+  }];
+  tableProps.pagination = !!pagination && { ...pagination, showSizeChanger: true, showQuickJumper: true, showTotal: total => `共 ${total} 条`};
+  const getBodyWrapperProps = {
+    page: location.query.page,
+    current: tableProps.pagination.current,
+  };
+  const getBodyWrapper = (body) => (<AnimTableBody {...getBodyWrapperProps} body={body} />);
+  return (
+    <Table
+      simple
+      bordered
+      { ...tableProps }
+      columns={columns}
+      className={classnames({ [styles.table]: true, [styles.motion]: true })}
+      rowKey={record => record.id}
+      getBodyWrapper={getBodyWrapper}
+    />
+  );
+}
+
+TableList.propTypes = {
+  location: PropTypes.object,
+  onChange: PropTypes.func.isRequired,
+  onDeleteItem: PropTypes.func.isRequired,
+  onEditItem: PropTypes.func.isRequired,
+}
+
+export default TableList;

+ 78 - 0
src/routes/Campus/table.less

@@ -0,0 +1,78 @@
+@import "~antd/lib/style/themes/default.less";
+@import "../../utils/utils.less";
+
+.table {
+  :global {
+    .ant-table-tbody > tr > td,
+    .ant-table-thead > tr > th {
+      height: 62px;
+    }
+  }
+
+  &.motion {
+    :global {
+      .ant-table-tbody > tr > td,
+      .ant-table-thead > tr > th {
+        &:nth-child(1) {
+          width: 16%;
+        }
+
+        &:nth-child(2) {
+          width: 20%;
+        }
+
+        &:nth-child(3) {
+          width: 10%;
+        }
+
+        &:nth-child(4) {
+          width: 18%;
+        }
+
+        &:nth-child(5) {
+          width: 16%;
+        }
+
+        &:nth-child(6) {
+          width: 12%;
+        }
+
+        &:nth-child(7) {
+          width: 8%;
+        }
+      }
+
+      .ant-table-thead {
+        & > tr {
+          transition: none;
+          display: block;
+
+          & > th {
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+          }
+        }
+      }
+
+      .ant-table-tbody {
+        & > tr {
+          transition: none;
+          display: block;
+          border-bottom: 1px solid #f5f5f5;
+
+          & > td {
+            border-bottom: none;
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+          }
+
+          &.ant-table-expanded-row-level-1 > td {
+            height: auto;
+          }
+        }
+      }
+    }
+  }
+}

+ 10 - 0
src/routes/GlobalList.less

@@ -0,0 +1,10 @@
+@import "~antd/lib/style/themes/default.less";
+@import "../utils/utils.less";
+
+.splitLine {
+  background: @border-color-split;
+  display: inline-block;
+  margin: 0 8px;
+  width: 1px;
+  height: 12px;
+}

+ 137 - 0
src/routes/Terminal/CampusList.js

@@ -0,0 +1,137 @@
+import react, { PureComponent } from 'react';
+import { connect } from 'dva';
+import { routerRedux } from 'dva/router';
+import { Card, Row, Col, Table, Select, Input, Button, Form, Popconfirm } from 'antd';
+import moment from 'moment';
+import PageHeaderLayout from '../../layouts/PageHeaderLayout';
+import styles from '../GlobalList.less';
+
+@connect(state => ({
+  campus: state.campus,
+}))
+export default class CampusList extends PureComponent {
+  render() {
+    const { campus } = this.props;
+    const search = ({
+      field,
+      keyword,
+      addPower,
+      onSearch,
+      onAdd
+    }) => {
+      const searchGroupProps = {
+        field,
+        keyword,
+        size: 'large',
+        select: true,
+        selectOptions: [{
+          value: 'name', name: '校区名称'
+        },{
+          value: 'code', name: '校区编号'
+        },{
+          value: 'contactName', name: '联系人'
+        },{
+          value: 'mobile', name: '手机号',
+        },{
+          value: 'address', name: '联系地址'
+        }],
+        selectProps: {
+          defaultValue: field || 'name',
+        },
+        onSearch: (value) => {
+          onSearch(value);
+        },
+      }
+    }
+    const tablePagination = {
+      total: campus.totalSize,
+      current: campus.pageNo,
+      pageSize: campus.pageSize,
+      showSizeChanger: true,
+      showQuickJumper: true,
+      showTotal: (total) => { return `共 ${total} 条` }
+    };
+    const tableColumns = [{
+      title: '校区编号',
+      dataIndex: 'code',
+      key: 'code',
+    },{
+      title: '校区名称',
+      dataIndex: 'name',
+      key: 'name',
+    },{
+      title: '联系人',
+      dataIndex: 'contactName',
+      key: 'contactName',
+    },{
+      title: '电话',
+      dataIndex: 'mobile',
+      key: 'mobile',
+    },{
+      title: '地址',
+      dataIndex: 'address',
+      key: 'address',
+    },{
+      title: '添加时间',
+      dataIndex: 'gmtCreated',
+      key: 'gmtCreated',
+      render: (text, record) => (
+        <div>{moment(text).format('YYYY-MM-DD')}</div>
+      )
+    },{
+      title: '操作',
+      dataIndex: 'operation',
+      key: 'operation',
+      fixed: 'right',
+      render: (text, record) => (
+        <div>
+          <a href="#">详情</a>
+          <span className={styles.splitLine} />
+          <a href="#">编辑</a>
+          <span className={styles.splitLine} />
+          <Popconfirm
+            placement="top"
+            cancelText="取消"
+            okText="确定"
+            title="确定删除?"
+          >
+            <a href="#">删除</a>
+          </Popconfirm>
+        </div>
+      )
+    }];
+    return (
+      <PageHeaderLayout
+      >
+        <Card>
+          {/*
+          <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 32 }}>
+            <Col span={12} style={{ marginBottom: 16 }}>
+              <Input.Group compact>
+                <Select style={{ width: '25%' }} defaultValue="0">
+                  <Option value="0">校区编号</Option>
+                  <Option value="1">校区名称</Option>
+                </Select>
+                <Input style={{ width: '55%' }} placeholder="请输入" />
+                <Button style={{ width: '20%' }} type="primary" icon="search">搜索</Button>
+              </Input.Group>
+            </Col>
+            <Col span={4} offset={8} style={{ marginBottom: 16, textAlign: 'right' }}>
+              <Button type="ghost" icon="plus-circle">新建</Button>
+            </Col>
+          </Row>
+          */}
+          <Table
+            bordered={false}
+            rowKey={record => record.id}
+            loading={campus.listLoading}
+            columns={tableColumns}
+            dataSource={campus.list}
+            pagination={tablePagination}
+            scroll={{ x: 1000 }}
+          />
+        </Card>
+      </PageHeaderLayout>
+    );
+  }
+}

+ 23 - 0
src/services/campus.js

@@ -0,0 +1,23 @@
+import { stringify } from 'qs';
+import request from '../utils/request';
+import { campuses, campus } from '../utils/api';
+
+export async function query(params) {
+  return request(`${campuses}?${stringify(params)}`);
+}
+
+export async function create(params) {
+  const options = {
+    method: 'POST',
+    body: JSON.stringify(params),
+  };
+  return request(`${campus.replace('/:id', '')}`, options);
+}
+
+export async function update(params) {
+  const options = {
+    method: 'PUT',
+    body: JSON.stringify(params),
+  };
+  return request(`${campus.replace('/:id', '')}`, options);
+}

+ 2 - 0
src/utils/api.js

@@ -5,4 +5,6 @@ module.exports = {
   currentUser: `${config.apiHost}/cms/user`,
   userLogin: `${config.apiHost}/login`,
   userLogout: `${config.apiHost}/logout`,
+  campuses: `${config.apiHost}/campus/list`,
+  campus: `${config.apiHost}/campus/:id`,
 };

Dosya farkı çok büyük olduğundan ihmal edildi
+ 4092 - 0
src/utils/city.js


+ 3 - 1
src/utils/config.js

@@ -1,5 +1,7 @@
 /* 项目配置文件 */
 
 module.exports = {
-  apiHost: 'http://lj.dev.cms.api.com:9100',
+  // apiHost: 'http://lj.dev.cms.api.com:9100',
+  pageSize: 15,
+  apiHost: '',
 };

+ 0 - 2
src/utils/map.js

@@ -6,8 +6,6 @@ const actions = {
 const routers = {
   home: '/',
   login: '/user/login',
-  cpList: '/merchant/cp',
-  projectList: '/merchant/project',
 };
 
 module.exports = { actions, routers };

+ 42 - 13
src/utils/request.js

@@ -29,7 +29,7 @@ const customCodeMessage = {
 /**
  * 检查HTTP响应状态码,>=200 && < 300正常
  */
-function checkStatus(response) {
+function httpErrorHandler(response) {
   if (response.status >= 200 && response.status < 300) {
     return response;
   }
@@ -41,35 +41,63 @@ function checkStatus(response) {
   const error = new Error(errortext);
   error.response = response;
   throw error;
+  return { error };
 }
 
 /**
- * 拦截接口返回的数据状态,提示错误内容
+ * @desc 拦截接口返回的数据状态,提示错误内容
+ * @return {[object]} { res: data }
  */
-function checkAPIData(data) {
+function apiErrorHandler(data) {
   if (!data.success) {
     const errortext = customCodeMessage[data.code] || data.message;
     notification.error({
       message: `操作错误 ${data.code}`,
       description: errortext,
     });
+    // Token认证失败,错误继续外抛,让全局app对象捕获,跳转到登录界面
     if (data.code === 10004) {
       const error = new Error(errortext);
       error.response = data;
       throw error;
     }
   }
-  return { res: data };
+  return data;
 }
 
 /**
- * 处理超时错误
+ * @desc 处理未预知到的错误
+ * @return {[object]} {error}
  */
-function checkTimeOutError(err) {
-  notification.error({
-    message: `请求超时`,
-    description: '请求失败,请确认网络状态是否可用。',
-  });
+function unpredictableErrorHandler(error) {
+  if (!error.response) {
+    notification.error({
+      message: '网络请求错误',
+      description: '出现未预知的网络问题,请检查日志或联系管理员!',
+    });
+  }
+  if (error.response && error.response.code === 10004) {
+    throw error;
+  }
+  return { error };
+};
+
+/**
+ * @desc 处理请求超时错误
+ * @return {[object]} {error}
+ */
+function timeoutErrorHandler(error) {
+  // 这里只会捕获两种错误,一是超时错误,一是认证失效错误,认证失效错误继续外抛
+  if (!error.response) {
+    notification.error({
+      message: `请求超时`,
+      description: '请求失败,请确认网络状态是否可用。',
+    });
+  }
+  if (error && error.response.code === 10004) {
+    throw error;
+  }
+  return { error };
 }
 
 /**
@@ -119,8 +147,9 @@ export default function request(url, options) {
     }
   }
   const originRequest = fetch(url, newOptions)
-    .then(checkStatus)
+    .then(httpErrorHandler)
     .then(promise2Json)
-    .then(checkAPIData)
-  return _fetch(originRequest).catch(checkTimeOutError);
+    .then(apiErrorHandler)
+    .catch(unpredictableErrorHandler)
+  return _fetch(originRequest).catch(timeoutErrorHandler);
 }

+ 17 - 0
src/utils/utils.js

@@ -1,4 +1,5 @@
 import moment from 'moment';
+import config from './config';
 
 export function fixedZero(val) {
   return val * 1 < 10 ? `0${val}` : val;
@@ -132,3 +133,19 @@ export function getRoutes(path, routerData) {
   });
   return renderRoutes;
 }
+
+export function checkSearchParams(params) {
+  const payload = { ...params };
+  if (!payload.pageNo) {
+    payload.pageNo = 1;
+  }
+  if (!payload.pageSize) {
+    payload.pageSize = config.pageSize;
+  }
+  if (payload.field && payload.keyword) {
+    payload[payload.field] = payload.keyword;
+  }
+  delete payload.field;
+  delete payload.keyword;
+  return payload;
+}