Pārlūkot izejas kodu

:sparkles: 增加用户个性化配置界面

zhanghe 5 gadi atpakaļ
vecāks
revīzija
66523d01f8

+ 1 - 0
README.md

@@ -51,6 +51,7 @@
   - 栏目类型
   - 侧边栏目
   - 推荐内容
+  - 用户配置
 - 交易管理
   - 购物车
   - 订单列表

+ 2 - 2
package.json

@@ -54,7 +54,7 @@
   "devDependencies": {
     "babel-eslint": "^8.1.2",
     "babel-plugin-dva-hmr": "^0.4.1",
-    "babel-plugin-import": "^1.6.3",
+    "babel-plugin-import": "^1.6.7",
     "babel-plugin-transform-decorators-legacy": "^1.3.4",
     "cross-env": "^5.1.1",
     "cross-port-killer": "^1.0.1",
@@ -74,7 +74,7 @@
     "pro-download": "^1.0.1",
     "redbox-react": "^1.5.0",
     "regenerator-runtime": "^0.11.1",
-    "roadhog": "^2.1.0",
+    "roadhog": "^2.3.0",
     "roadhog-api-doc": "^0.3.4",
     "stylelint": "^8.4.0",
     "stylelint-config-standard": "^18.0.0"

+ 3 - 0
src/common/menu.js

@@ -93,6 +93,9 @@ const menuData = () => {
     }, {
       name: '推荐内容',
       path: 'recommend',
+    }, {
+      name: '个性化配置',
+      path: 'personalize',
     }],
     authority: ['admin', 'platform'],
   }, {

+ 9 - 1
src/common/router.js

@@ -359,7 +359,15 @@ export const getRouterData = (app) => {
     '/frontend/recommend/poster-edit/:id': {
       component: dynamicWrapper(app, ['merchant', 'shelves'], () => import('../routes/Frontend/Recommend/RecommendPoster')),
     },
-    // 交易管理相关路由注册
+    '/frontend/personalize': {
+      component: dynamicWrapper(app, ['terminal'], () => import('../routes/Frontend/Personalize')),
+    },
+    '/frontend/personalize/list': {
+      component: dynamicWrapper(app, ['terminal', 'campus', 'merchant'], () => import('../routes/Frontend/Personalize/PersonalizeList')),
+    },
+    '/frontend/personalize/edit/:id': {
+      component: dynamicWrapper(app, ['terminal', 'tag', 'tagType', 'shelves'], () => import('../routes/Frontend/Personalize/PersonalizeEdit')),
+    },
     '/trade/shopcart': {
       component: dynamicWrapper(app, [], () => import('../routes/Trade/ShopCart')),
     },

+ 23 - 0
src/components/AXTableSelector/columnsMap.js

@@ -326,6 +326,29 @@ const clMap = {
       dataIndex: 'name',
     }],
   },
+  allTag: {
+    columns: [{
+      title: '标签名称',
+      key: 1,
+      dataIndex: 'name',
+      width: '30%',
+    }, {
+      title: '标签类型',
+      key: 2,
+      dataIndex: 'typeCode',
+      width: '15%',
+    }, {
+      title: '所属标签组',
+      key: 3,
+      dataIndex: 'groupName',
+      width: '40',
+    }, {
+      title: '所属渠道',
+      key: 4,
+      dataIndex: 'merchantName',
+      width: '15%',
+    }],
+  },
   Tag: {
     columns: [{
       title: '标签名称',

+ 125 - 1
src/models/terminal.js

@@ -12,6 +12,14 @@ import {
   updateSpecialTerminalItem,
   deleteSpecialTerminalItem,
   queryTerminalAuthList,
+  queryTerminalTagList,
+  queryTerminalTagItem,
+  createTerminalTagItem,
+  updateTerminalTagItem,
+  deleteTerminalTagItem,
+  queryTerminalRecommendCourse,
+  updateTerminalRecommendCourse,
+  copyMerchantTag,
 } from '../services/terminal';
 
 export default {
@@ -23,6 +31,9 @@ export default {
     pageSize: 15,
     totalSize: 0,
     currentItem: {},
+    userTagList: [],
+    userRecCourse: [],
+    currentUserTagItem: {},
   },
 
   effects: {
@@ -159,6 +170,94 @@ export default {
         });
       }
     },
+    *fetchTerminalTagList({ payload }, { call, put }) {
+      const response = yield call(queryTerminalTagList, payload);
+      if (response.success) {
+        yield put({
+          type: 'querySuccess',
+          payload: {
+            userTagList: response.data || [],
+          },
+        });
+      }
+    },
+    *fetchTerminalTagItem({ payload }, { call, put }) {
+      const response = yield call(queryTerminalTagItem, payload);
+      if (response.success) {
+        yield put({
+          type: 'querySuccess',
+          payload: {
+            currentUserTagItem: response.data || {},
+          },
+        });
+      }
+    },
+    *createTerminalTagItem({ payload }, { call, put }) {
+      const response = yield call(createTerminalTagItem, payload);
+      if (response.success) {
+        message.success('终端用户标签创建成功');
+        const { uid } = payload;
+        yield put({
+          type: 'fetchTerminalTagList',
+          payload: { uid },
+        });
+      }
+    },
+    *updateTerminalTagItem({ payload }, { call, put }) {
+      const response = yield call(updateTerminalTagItem, payload);
+      if (response.success) {
+        message.success('终端用户标签修改成功');
+        const { uid } = payload;
+        yield put({
+          type: 'fetchTerminalTagList',
+          payload: { uid },
+        });
+      }
+    },
+    *deleteTerminalTagItem({ payload }, { call, put }) {
+      const response = yield call(deleteTerminalTagItem, payload);
+      if (response.success) {
+        message.success('终端用户标签删除成功');
+        const { uid } = payload;
+        yield put({
+          type: 'fetchTerminalTagList',
+          payload: { uid },
+        });
+      }
+    },
+    *copyMerchantTagToUser({ payload }, { call, put }) {
+      const { uid, ...rest } = payload;
+      const response = yield call(copyMerchantTag, rest);
+      if (response.success) {
+        message.success('复制渠道标签成功');
+        yield put({
+          type: 'fetchTerminalTagList',
+          payload: { uid },
+        });
+      }
+    },
+    *fetchTerminalRecommendCourse({ payload }, { call, put }) {
+      const response = yield call(queryTerminalRecommendCourse, payload);
+      if (response.success) {
+        yield put({
+          type: 'querySuccess',
+          payload: {
+            userRecCourse: response.data || [],
+          },
+        });
+      }
+    },
+    *updateTerminalRecommendCourse({ payload }, { call, put }) {
+      const response = yield call(updateTerminalRecommendCourse, payload);
+      if (response.success) {
+        message.success('修改用户推荐课程成功');
+        const { uid } = payload;
+        yield put({
+          type: 'fetchTerminalRecommendCourse',
+          payload: { uid },
+        });
+      }
+    },
   },
 
   reducers: {
@@ -178,14 +277,39 @@ export default {
         },
       };
     },
+    fixCurrentUserTagItem(state, action) {
+      const { currentUserTagItem } = state;
+      return {
+        ...state,
+        currentUserTagItem: {
+          ...currentUserTagItem,
+          ...action.payload,
+        },
+      };
+    },
+    fixUserRecCourse(state, action) {
+      return {
+        ...state,
+        userRecCourse: action.payload,
+      };
+    },
     cleanState(state) {
       return {
         ...state,
-        currentItem: {},
         list: [],
         pageNo: 1,
         pageSize: 15,
         totalSize: 0,
+        userTagList: [],
+        userRecCourse: [],
+        currentItem: {},
+        currentUserTagItem: {},
+      };
+    },
+    resetUserTagItem(state) {
+      return {
+        ...state,
+        currentUserTagItem: {},
       };
     },
   },

+ 661 - 0
src/routes/Frontend/Personalize/PersonalizeEdit.js

@@ -0,0 +1,661 @@
+/* eslint-disable no-trailing-spaces */
+import React, { Component } from 'react';
+import pathToRegexp from 'path-to-regexp';
+import { routerRedux } from 'dva/router';
+import { connect } from 'dva';
+import { Card, Table, Modal, Form, Tooltip, Popconfirm, Switch, Button, Input } from 'antd';
+import Selector from '../../../components/AXTableSelector/Selector';
+import AXDragSortTable from '../../../components/AXDragSortTable';
+import FooterToolbar from '../../../components/FooterToolbar';
+import { renderStatus, statusToBool, boolToStatus, renderProductType } from '../../../utils/utils';
+import styles from './PersonalizeEdit.less';
+
+const formItemLayout = {
+  labelCol: {
+    xs: { span: 24 },
+    sm: { span: 2 },
+    md: { span: 2 },
+  },
+  wrapperCol: {
+    xs: { span: 24 },
+    sm: { span: 14 },
+    md: { span: 22 },
+  },
+};
+
+@Form.create()
+@connect(({ loading, terminal, shelves, tagType, tag }) => ({
+  tag,
+  shelves,
+  terminal,
+  tagType,
+  sLoading: loading.models.shelves,
+  tLoading: loading.models.tagType,
+  mtLoading: loading.models.tag,
+}))
+export default class PersonalizeEditPage extends Component {
+  constructor(props) {
+    super(props);
+    const { location } = props;
+    const { state } = location;
+    const match = pathToRegexp('/frontend/personalize/edit/:id').exec(location.pathname);
+    this.state = {
+      uid: match[1],
+      mid: state.merchantId,
+      tagModalDestroy: true,
+      tagModalName: '新建用户标签',
+      tagTypeSelecting: false, // 标签类型处于选择状态
+      recModalDestroy: true,
+      productSelecting: false, // 产品处于选择状态
+      merchantTagModalDestroy: true,
+      targetRow: {},
+    };
+  }
+  componentDidMount() {
+    // 加载用户标签数据
+    this.props.dispatch({
+      type: 'terminal/fetchTerminalTagList',
+      payload: { uid: this.state.uid },
+    });
+    // 加载用户推荐课程
+    this.props.dispatch({
+      type: 'terminal/fetchTerminalRecommendCourse',
+      payload: { uid: this.state.uid },
+    });
+  }
+
+  /**
+   * 用户标签创建/编辑模态框展现及数据加载控制
+   * @param userTagId
+   */
+  handleUserTagModalShow = (userTagId) => {
+    // 展现模态框前清空下currentUserTagItem内容
+    this.props.dispatch({
+      type: 'terminal/resetUserTagItem',
+    });
+    // 展现模态框
+    this.setState({ tagModalDestroy: false });
+    // 如果编辑标签则发获取详情请求
+    if (userTagId) {
+      this.props.dispatch({
+        type: 'terminal/fetchTerminalTagItem',
+        payload: { userTagId },
+      });
+      this.setState({ tagModalName: '编辑用户标签' });
+    }
+  };
+  /**
+   * 用户推荐课程模态框的展现
+   */
+  handleUserRecModalShow = () => {
+    this.setState({ recModalDestroy: false });
+  };
+  handleMerchantTagModalShow = (record) => {
+    this.setState({
+      targetRow: record,
+      merchantTagModalDestroy: false,
+    });
+    this.props.dispatch({
+      type: 'tag/fetchTagList',
+      payload: { merchantId: this.state.mid },
+    });
+  };
+  /**
+   * 模态框的取消操作
+   * @param modalName
+   */
+  handleModalHide = (modalName) => {
+    this.setState({ [modalName]: true });
+  };
+  /**
+   * 用户标签内卡片切换操作
+   * @param name
+   */
+  handleCardSwitch = (name) => {
+    if (name === 'tagType') {
+      this.props.dispatch({
+        type: 'tagType/fetchTagTypeList',
+        payload: {},
+      });
+      this.setState({ tagTypeSelecting: true });
+    }
+    if (name === 'product') {
+      this.props.dispatch({
+        type: 'shelves/fetchItemList',
+        payload: { merchantId: this.state.mid },
+      });
+      this.setState({ productSelecting: true });
+    }
+    if (name === 'course') {
+      this.props.dispatch({
+        type: 'shelves/fetchCourseItemList',
+        payload: { merchantId: this.state.mid },
+      });
+      this.setState({ productSelecting: true });
+    }
+  };
+  /**
+   * 标签类型/关联产品取消选择操作
+   * @param name
+   */
+  handleSelectingCardCancel = (name) => {
+    if (name === 'tagType') {
+      this.setState({ tagTypeSelecting: false });
+    }
+    if (name === 'product' || name === 'course') {
+      this.setState({ productSelecting: false });
+    }
+  };
+  /**
+   * 标签类型/关联产品筛选操作
+   * @param name
+   * @param data
+   */
+  handleSelectingCardChange = (name, data) => {
+    if (name === 'tagType') {
+      this.props.dispatch({
+        type: 'tagType/fetchTagTypeList',
+        payload: data,
+      });
+    }
+    if (name === 'product') {
+      this.props.dispatch({
+        type: 'shelves/fetchItemList',
+        payload: { merchantId: this.state.mid, ...data },
+      });
+    }
+    if (name === 'course') {
+      this.props.dispatch({
+        type: 'shelves/fetchCourseItemList',
+        payload: { merchantId: this.state.mid, ...data },
+      });
+    }
+    if (name === 'tag') {
+      this.props.dispatch({
+        type: 'tag/fetchTagList',
+        payload: { merchantId: this.state.uid, ...data },
+      });
+    }
+  };
+  /**
+   * 标签类型/关联产品完成操作
+   * @param cardName
+   * @param data
+   */
+  handleSelectingCardFinish = (cardName, data) => {
+    if (cardName === 'tagType') {
+      const tagType = data[0] || {};
+      const { code, name } = tagType;
+      this.props.dispatch({
+        type: 'terminal/fixCurrentUserTagItem',
+        payload: {
+          typeCode: code,
+          typeName: name,
+        },
+      });
+      this.setState({ tagTypeSelecting: false });
+    }
+    if (cardName === 'product') {
+      this.props.dispatch({
+        type: 'terminal/fixCurrentUserTagItem',
+        payload: { productList: data },
+      });
+      this.setState({ productSelecting: false });
+    }
+    if (cardName === 'course') {
+      this.props.dispatch({
+        type: 'terminal/fixUserRecCourse',
+        payload: (data || []).slice(0, 5),
+      });
+      this.setState({ productSelecting: false });
+    }
+  };
+  /**
+   * 用户标签内关联产品/推荐课程排序操作
+   * @param rows
+   * @param tabName
+   */
+  handleDragSortTableChange = (rows, tabName) => {
+    if (tabName === 'tag') {
+      this.props.dispatch({
+        type: 'terminal/fixCurrentUserTagItem',
+        payload: { productList: rows },
+      });
+    }
+    if (tabName === 'rec') {
+      this.props.dispatch({
+        type: 'terminal/fixUserRecCourse',
+        payload: rows,
+      });
+    }
+  };
+  handlePageBack = () => {
+    this.props.dispatch(routerRedux.push({
+      pathname: '/frontend/personalize/list',
+      state: this.props.location.state,
+    }));
+  };
+  handleUserTagSubmit = () => {
+    this.props.form.validateFieldsAndScroll((err, values) => {
+      if (!err) {
+        const { name, status } = values;
+        const { uid } = this.state;
+        const { terminal } = this.props;
+        const { currentUserTagItem } = terminal;
+        const { id, typeCode, productList } = currentUserTagItem;
+        const pidList = (productList || []).map(product => product.pid);
+        const newStatus = boolToStatus(status);
+        if (!id) {
+          this.props.dispatch({
+            type: 'terminal/createTerminalTagItem',
+            payload: { uid, name, typeCode, status: newStatus, productList: pidList },
+          });
+        } else {
+          this.props.dispatch({
+            type: 'terminal/updateTerminalTagItem',
+            payload: { id, uid, name, typeCode, status: newStatus, productList: pidList },
+          });
+        }
+        this.setState({ tagModalDestroy: true });
+      }
+    });
+  };
+  handleUserTagCopyOperation = (data) => {
+    const { targetRow } = this.state;
+    const merchantTag = (data || [])[0] || {};
+    this.props.dispatch({
+      type: 'terminal/copyMerchantTagToUser',
+      payload: { uid: this.state.uid, userTagId: targetRow.id, tagId: merchantTag.id },
+    });
+    this.setState({ merchantTagModalDestroy: true });
+  };
+  handleUserTagDelete = (userTagId) => {
+    this.props.dispatch({
+      type: 'terminal/deleteTerminalTagItem',
+      payload: { uid: this.state.uid, userTagId },
+    });
+  };
+  handleUserRecCourseSubmit = () => {
+    const { uid } = this.state;
+    const { terminal } = this.props;
+    const { userRecCourse } = terminal;
+    const idList = (userRecCourse || []).map(product => product.pid);
+    this.props.dispatch({
+      type: 'terminal/updateTerminalRecommendCourse',
+      payload: { uid, idList },
+    });
+    this.setState({ recModalDestroy: true });
+  };
+  render() {
+    const {
+      tagModalDestroy,
+      tagModalName,
+      tagTypeSelecting,
+      productSelecting,
+      recModalDestroy,
+      merchantTagModalDestroy,
+    } = this.state;
+    const { sLoading, tLoading, mtLoading, terminal, shelves, tagType, tag, form } = this.props;
+    const { getFieldDecorator } = form;
+    const { userTagList, userRecCourse, currentUserTagItem } = terminal;
+    const { name, status, productList, typeCode, typeName } = currentUserTagItem;
+
+    // 用户标签列表表头
+    const tagColumns = [{
+      title: '标签名称',
+      dataIndex: 'name',
+      key: 1,
+      width: '30%',
+    }, {
+      title: '标签类型',
+      dataIndex: 'typeCode',
+      key: 2,
+      width: '30%',
+    }, {
+      title: '标签状态',
+      dataIndex: 'status',
+      key: 3,
+      width: '15%',
+      render: text => renderStatus(text),
+      align: 'center',
+    }, {
+      title: '操作',
+      key: 4,
+      width: '25%',
+      align: 'right',
+      render: (_, record) => (
+        <div>
+          <Button
+            size="small"
+            className="editBtn"
+            onClick={() => this.handleUserTagModalShow(record.id)}
+          >编辑
+          </Button>
+          <Tooltip title="复制渠道标签进行关联产品">
+            <Button
+              size="small"
+              className="recBtn"
+              onClick={() => this.handleMerchantTagModalShow(record)}
+            >复制
+            </Button>
+          </Tooltip>
+          <Popconfirm
+            placement="top"
+            title="确定要删除该用户标签?"
+            okText="确定"
+            cancelText="取消"
+            onConfirm={() => this.handleUserTagDelete(record.id)}
+          >
+            <Button
+              size="small"
+              className="delBtn"
+            >删除
+            </Button>
+          </Popconfirm>
+        </div>
+      ),
+    }];
+    // 推荐课程
+    const courseColumns = [{
+      title: '课程编号',
+      dataIndex: 'code',
+      key: 1,
+      width: '40%',
+    }, {
+      title: '课程名称',
+      dataIndex: 'name',
+      key: 2,
+      width: '40%',
+    }, {
+      title: '课程状态',
+      dataIndex: 'status',
+      key: 3,
+      render: text => renderStatus(text),
+      width: '20%',
+    }];
+
+    /* ************************ modal1: 用户标签创建 ************************* */
+    const getTagCreateModal = () => {
+      const tagTypeColumns = [{
+        title: '标签类型编号',
+        dataIndex: 'code',
+        key: 1,
+        width: '50%',
+      }, {
+        title: '标签类型名称',
+        dataIndex: 'name',
+        key: 2,
+        width: '50%',
+      }];
+      const tagTypeData = typeCode ? [{ key: 'row-1', name: typeName || '-', code: typeCode }] : undefined;
+      const tagTypeSelectCard = (
+        <Card title="选择标签类型">
+          <Selector
+            multiple={false}
+            loading={tLoading}
+            selectorName="TagType"
+            list={tagType.list}
+            pageNo={tagType.pageNo}
+            pageSize={tagType.pageSize}
+            totalSize={tagType.totalSize}
+            onCancel={() => this.handleSelectingCardCancel('tagType')}
+            onChange={data => this.handleSelectingCardChange('tagType', data)}
+            onFinish={data => this.handleSelectingCardFinish('tagType', data)}
+          />
+        </Card>
+      );
+      const tagTypeShowCard = (
+        <Card title="标签类型信息">
+          <Table
+            pagination={false}
+            dataSource={tagTypeData}
+            columns={tagTypeColumns}
+            className={styles.tagTable}
+          />
+        </Card>
+      );
+      const productColumns = [{
+        title: '产品编号',
+        dataIndex: 'code',
+        key: 1,
+        width: '20%',
+      }, {
+        title: '产品名称',
+        dataIndex: 'name',
+        key: 2,
+        width: '30%',
+      }, {
+        title: '产品类型',
+        dataIndex: 'type',
+        key: 3,
+        render: text => renderProductType(text),
+      }];
+      const productSelectCard = (
+        <Card title="选择产品">
+          <Selector
+            multiple
+            loading={sLoading}
+            selectorName="Product"
+            list={shelves.list}
+            pageNo={shelves.pageNo}
+            pageSize={shelves.pageSize}
+            totalSize={shelves.totalSize}
+            selectedRows={productList}
+            onCancel={() => this.handleSelectingCardCancel('product')}
+            onChange={data => this.handleSelectingCardChange('product', data)}
+            onFinish={data => this.handleSelectingCardFinish('product', data)}
+          />
+        </Card>
+      );
+      const productShowCard = (
+        <Card title="已关联产品列表">
+          <AXDragSortTable
+            data={productList}
+            columns={productColumns}
+            onChange={rows => this.handleDragSortTableChange(rows, 'tag')}
+          />
+        </Card>
+      );
+      return (
+        <Modal
+          visible
+          width={1100}
+          title={tagModalName}
+          maskClosable={false}
+          cancelText="取消"
+          okText="提交"
+          onCancel={() => this.handleModalHide('tagModalDestroy')}
+          onOk={this.handleUserTagSubmit}
+        >
+          <Form>
+            <Form.Item hasFeedback label="标签名称" {...formItemLayout}>
+              {getFieldDecorator('name', {
+                rules: [{ required: true, message: '请填写标签名称' }],
+                initialValue: name,
+              })(
+                <Input placeholder="请输入" />
+              )}
+            </Form.Item>
+            <Form.Item label="标签类型" {...formItemLayout}>
+              <Button
+                disabled={tagTypeSelecting}
+                type="primary"
+                size="small"
+                icon="search"
+                onClick={() => this.handleCardSwitch('tagType')}
+              >选择
+              </Button>
+            </Form.Item>
+            <Form.Item wrapperCol={{ offset: 2, span: 22 }}>
+              {tagTypeSelecting ? tagTypeSelectCard : tagTypeShowCard}
+            </Form.Item>
+            <Form.Item label="关联产品" {...formItemLayout}>
+              <Button
+                disabled={productSelecting}
+                type="primary"
+                size="small"
+                icon="form"
+                onClick={() => this.handleCardSwitch('product')}
+              >编辑
+              </Button>
+            </Form.Item>
+            <Form.Item wrapperCol={{ offset: 2, span: 22 }}>
+              {productSelecting ? productSelectCard : productShowCard}
+            </Form.Item>
+            <Form.Item label="标签状态" {...formItemLayout}>
+              {getFieldDecorator('status', {
+                valuePropName: 'checked',
+                initialValue: statusToBool(status),
+              })(
+                <Switch checkedChildren="正常" unCheckedChildren="删除" />
+              )}
+            </Form.Item>
+          </Form>
+        </Modal>
+      );
+    };
+
+    /* ************************ modal2: 用户推荐课程修改 ************************* */
+    const getRecCourseModal = () => {
+      const cColumns = [{
+        title: '课程编号',
+        dataIndex: 'code',
+        key: 1,
+        width: '30%',
+      }, {
+        title: '课程名称',
+        dataIndex: 'name',
+        key: 2,
+        width: '30%',
+      }];
+      const recCourseSelectCard = (
+        <Card title="选择课程">
+          <Selector
+            multiple
+            loading={sLoading}
+            selectorName="Course"
+            list={shelves.list}
+            pageNo={shelves.pageNo}
+            pageSize={shelves.pageSize}
+            totalSize={shelves.totalSize}
+            selectedRows={userRecCourse}
+            onCancel={() => this.handleSelectingCardCancel('course')}
+            onChange={data => this.handleSelectingCardChange('course', data)}
+            onFinish={data => this.handleSelectingCardFinish('course', data)}
+          />
+        </Card>
+      );
+      const recCourseShowCard = (
+        <Card title="推荐课程列表">
+          <AXDragSortTable
+            data={userRecCourse}
+            columns={cColumns}
+            onChange={rows => this.handleDragSortTableChange(rows, 'rec')}
+          />
+        </Card>
+      );
+      return (
+        <Modal
+          visible
+          width={1100}
+          title="用户推荐课程"
+          maskClosable={false}
+          cancelText="取消"
+          okText="提交"
+          onCancel={() => this.handleModalHide('recModalDestroy')}
+          onOk={this.handleUserRecCourseSubmit}
+        >
+          <Form>
+            <Form.Item label="推荐课程" {...formItemLayout}>
+              <Button
+                disabled={productSelecting}
+                type="primary"
+                size="small"
+                icon="search"
+                onClick={() => this.handleCardSwitch('course')}
+              >选择
+              </Button>
+            </Form.Item>
+            <Form.Item wrapperCol={{ offset: 2, span: 22 }}>
+              {productSelecting ? recCourseSelectCard : recCourseShowCard}
+            </Form.Item>
+          </Form>
+        </Modal>
+      );
+    };
+
+    /* ************************ modal3: 渠道标签选择 ************************* */
+    const getMerchantTagModal = () => {
+      return (
+        <Modal
+          width={1100}
+          footer={null}
+          visible
+          title="渠道标签列表"
+          maskClosable={false}
+          onCancel={() => this.handleModalHide('merchantTagModalDestroy')}
+        >
+          <Selector
+            multiple={false}
+            loading={mtLoading}
+            selectorName="Tag"
+            list={tag.list}
+            pageNo={tag.pageNo}
+            pageSize={tag.pageSize}
+            totalSize={tag.totalSize}
+            onCancel={() => this.handleModalHide('merchantTagModalDestroy')}
+            onChange={data => this.handleSelectingCardChange('tag', data)}
+            onFinish={this.handleUserTagCopyOperation}
+          />
+        </Modal>
+      );
+    };
+
+    return (
+      <div>
+        <Card
+          title={
+            <div>
+              用户标签
+              <Button onClick={() => this.handleUserTagModalShow()} style={{ float: 'right' }} type="primary">新建标签</Button>
+            </div>
+          }
+          style={{ marginBottom: 16 }}
+          className={styles.tagCard}
+        >
+          <Table
+            pagination={false}
+            dataSource={userTagList}
+            columns={tagColumns}
+            rowKey={record => record.id}
+            className={styles.tagTable}
+          />
+          {!tagModalDestroy && getTagCreateModal()}
+          {!merchantTagModalDestroy && getMerchantTagModal()}
+        </Card>
+        <Card
+          title={
+            <div>
+              推荐课程
+              <Button onClick={this.handleUserRecModalShow} style={{ float: 'right' }} type="primary">更换课程</Button>
+            </div>
+          }
+          style={{ marginBottom: 70 }}
+          className={styles.tagCard}
+        >
+          <Table
+            pagination={false}
+            dataSource={userRecCourse}
+            columns={courseColumns}
+            rowKey={record => record.id}
+            className={styles.tagTable}
+          />
+          {!recModalDestroy && getRecCourseModal()}
+        </Card>
+        <FooterToolbar style={{ width: '100%' }}>
+          <Button type="primary" onClick={this.handlePageBack}>返回上一页</Button>
+        </FooterToolbar>
+      </div>
+    );
+  }
+}

+ 26 - 0
src/routes/Frontend/Personalize/PersonalizeEdit.less

@@ -0,0 +1,26 @@
+@import "../../../../node_modules/antd/lib/style/themes/default.less";
+
+.tagTable {
+  :global {
+    .ant-table-title {
+      padding: 0 0 16px 0;
+    }
+    .ant-table-footer {
+      padding: 10px;
+    }
+    .ant-table-tbody > tr > td {
+      padding: 5px;
+    }
+    .ant-table-thead > tr > th {
+      padding: 10px 5px;
+    }
+  }
+}
+.tagCard {
+  :global {
+    .ant-card-head {
+      padding: 0 16px !important;
+    }
+  }
+}
+

+ 307 - 0
src/routes/Frontend/Personalize/PersonalizeList.js

@@ -0,0 +1,307 @@
+/* eslint-disable prefer-destructuring */
+import React, { Component } from 'react';
+import moment from 'moment';
+import { connect } from 'dva';
+import { routerRedux } from 'dva/router';
+import { Card, Modal, Form, Button, message } from 'antd';
+import { StandardTableList } from '../../../components/AXList';
+import AXRemoteSelect from '../../../components/AXRemoteSelect';
+import Ellipsis from '../../../components/Ellipsis';
+import { addRowKey, renderStatus, renderBindStatus } from '../../../utils/utils';
+
+const Message = message;
+
+const formItemLayout = {
+  labelCol: {
+    xs: { span: 24 },
+    sm: { span: 7 },
+  },
+  wrapperCol: {
+    xs: { span: 24 },
+    sm: { span: 15 },
+    md: { span: 13 },
+  },
+};
+function arrayDataFormatter(data) {
+  return data.map((item) => {
+    return {
+      text: item.name,
+      value: `${item.name}||${item.id}`,
+    };
+  });
+}
+
+@Form.create()
+@connect(({ loading, campus, merchant, terminal }) => ({
+  campus,
+  merchant,
+  terminal,
+  fetching1: loading.models.merchant,
+  fetching2: loading.models.campus,
+  loading: loading.models.terminal,
+}))
+export default class PersonalizeListPage extends Component {
+  constructor(props) {
+    super(props);
+    const { state } = props.location;
+    this.state = {
+      UIParams: (state || {}).UIParams, // 组件的状态参数
+      Queryers: (state || {}).Queryers, // 查询的条件参数
+      merchants: (state || {}).merchants || [], // 记录筛选的渠道
+      campuses: (state || {}).campuses || [], // 记录筛选的校区
+      filterModalDestroy: true,
+    };
+  }
+  componentWillMount() {
+    this.props.dispatch({
+      type: 'terminal/cleanState',
+    });
+  }
+  componentDidMount() {
+    const { merchants, campuses } = this.state;
+    let merchantId;
+    if (merchants && merchants.length) {
+      merchantId = merchants[0].split('||')[1];
+    }
+    let campusId;
+    if (campuses && campuses.length) {
+      campusId = campuses[0].split('||')[1];
+    }
+    this.props.dispatch({
+      type: 'terminal/fetchTerminalList',
+      payload: {
+        campusId,
+        merchantId,
+        ...this.state.Queryers,
+      },
+    });
+  }
+  handleEditOperation = (item) => {
+    this.props.dispatch(routerRedux.push({
+      pathname: `/frontend/personalize/edit/${item.id}`,
+      state: {
+        merchantId: item.merchantId,
+        ...this.state,
+      },
+    }));
+  };
+  handleFilterOperation = (params, states) => {
+    this.setState({
+      UIParams: states,
+      Queryers: params,
+    });
+    const { merchants, campuses } = this.state;
+    let merchantId;
+    if (merchants && merchants.length) {
+      merchantId = merchants[0].split('||')[1];
+    }
+    let campusId;
+    if (campuses && campuses.length) {
+      campusId = campuses[0].split('||')[1];
+    }
+    this.props.dispatch({
+      type: 'terminal/fetchTerminalList',
+      payload: {
+        campusId,
+        merchantId,
+        ...params,
+      },
+    });
+  };
+  handleModalFilterOperation = () => {
+    const { getFieldsValue } = this.props.form;
+    const { merchants, campuses } = getFieldsValue();
+    let merchantId;
+    if (merchants && merchants.length) {
+      merchantId = merchants[0].split('||')[1];
+    }
+    let campusId;
+    if (campuses && campuses.length) {
+      campusId = campuses[0].split('||')[1];
+    }
+    this.props.dispatch({
+      type: 'terminal/fetchTerminalList',
+      payload: {
+        ...this.state.Queryers,
+        merchantId,
+        campusId,
+      },
+    });
+    this.setState({ merchants, campuses });
+    this.handleFilterModalDestroy();
+  };
+  handleBatchOperation = () => {
+    Message.info('暂不支持批量操作!');
+  };
+  handleFilterModalShow = () => {
+    this.setState({ filterModalDestroy: false });
+  };
+  handleFilterModalDestroy = () => {
+    this.setState({ filterModalDestroy: true });
+  };
+  handleMerchantRemoteSelectSearch = (value) => {
+    this.props.dispatch({
+      type: 'merchant/fetchMerchantList',
+      payload: {
+        pageSize: 50,
+        name: value,
+      },
+    });
+  };
+  handleCampusRemoteSelectSearch = (value) => {
+    this.props.dispatch({
+      type: 'campus/fetchCampusList',
+      payload: {
+        pageSize: 50,
+        name: value,
+      },
+    });
+  };
+
+  render() {
+    const { merchants, campuses } = this.state;
+    const { loading, fetching1, fetching2, form, campus, merchant, terminal } = this.props;
+    const { list, totalSize, pageSize, pageNo } = terminal;
+    const { getFieldDecorator } = form;
+    const renderCampusName = (name) => {
+      return (
+        <Ellipsis tooltip lines={1}>{name}</Ellipsis>
+      );
+    };
+    const renderOperation = (item) => {
+      return (
+        <div>
+          <Button
+            size="small"
+            className="editBtn"
+            onClick={() => this.handleEditOperation(item)}
+          >用户配置
+          </Button>
+        </div>
+      );
+    };
+    const batchActions = [{
+      key: 'config',
+      name: '批量配置',
+    }];
+    const basicSearch = {
+      keys: [{
+        name: '终端编号',
+        field: 'code',
+      }, {
+        name: '终端名称',
+        field: 'name',
+      }],
+    };
+    const pagination = {
+      pageNo,
+      pageSize,
+      totalSize,
+    };
+    const columns = [{
+      title: '终端编号',
+      key: 1,
+      dataIndex: 'code',
+      width: '15%',
+    }, {
+      title: '终端名称',
+      key: 2,
+      dataIndex: 'name',
+      width: '12%',
+    }, {
+      title: '所属校区',
+      key: 3,
+      dataIndex: 'campusName',
+      render: text => renderCampusName(text),
+      width: '18%',
+    }, {
+      title: '所属渠道',
+      key: 4,
+      dataIndex: 'merchantName',
+      width: '10%',
+    }, {
+      title: '账号状态',
+      key: 5,
+      dataIndex: 'status',
+      render: text => renderStatus(text, '已禁用'),
+      width: '8%',
+    }, {
+      title: '绑定状态',
+      key: 6,
+      dataIndex: 'deviceStatus',
+      render: text => renderBindStatus(text),
+      width: '9%',
+    }, {
+      title: '更新时间',
+      key: 7,
+      dataIndex: 'gmtModified',
+      render: text => moment(text).format('YYYY-MM-DD HH:mm:ss'),
+      width: '15%',
+    }, {
+      title: '操作',
+      key: 8,
+      dataIndex: 'operation',
+      render: (_, record) => renderOperation(record),
+      width: '13%',
+      align: 'right',
+    }];
+    return (
+      <Card>
+        <StandardTableList
+          columns={columns}
+          loading={loading}
+          dataSource={addRowKey(list)}
+          header={{
+            basicSearch,
+            onAdvanceFilterClick: this.handleFilterModalShow,
+            onFilterClick: this.handleFilterOperation,
+            onCreateClick: this.handleCreateOperation,
+          }}
+          footer={{
+            pagination,
+            batchActions,
+            onBatchClick: this.handleBatchOperation,
+          }}
+          keepUIState={{ ...this.state.UIParams }}
+        />
+        {!this.state.filterModalDestroy && (
+          <Modal
+            width={600}
+            visible
+            title="高级筛选"
+            okText="筛选"
+            cancelText="取消"
+            maskClosable={false}
+            onCancel={this.handleFilterModalDestroy}
+            onOk={this.handleModalFilterOperation}
+          >
+            <Form>
+              <Form.Item label="所属商户" {...formItemLayout}>
+                {getFieldDecorator('merchants', {
+                  initialValue: merchants,
+                })(
+                  <AXRemoteSelect
+                    fetching={fetching1}
+                    dataSource={arrayDataFormatter(merchant.list)}
+                    onSearch={this.handleMerchantRemoteSelectSearch}
+                  />
+                )}
+              </Form.Item>
+              <Form.Item label="所属校区" {...formItemLayout}>
+                {getFieldDecorator('campuses', {
+                  initialValue: campuses,
+                })(
+                  <AXRemoteSelect
+                    fetching={fetching2}
+                    dataSource={arrayDataFormatter(campus.list)}
+                    onSearch={this.handleCampusRemoteSelectSearch}
+                  />
+                )}
+              </Form.Item>
+            </Form>
+          </Modal>
+        )}
+      </Card>
+    );
+  }
+}

+ 32 - 0
src/routes/Frontend/Personalize/index.js

@@ -0,0 +1,32 @@
+import React, { Component } from 'react';
+import { Redirect, Route, Switch } from 'dva/router';
+import { connect } from 'dva';
+import PageHeaderLayout from '../../../layouts/PageHeaderLayout';
+import { getRoutes } from '../../../utils/utils';
+
+@connect()
+export default class Personalize extends Component {
+  render() {
+    const { match, routerData } = this.props;
+    const routes = getRoutes(match.path, routerData);
+    return (
+      <PageHeaderLayout>
+        <Switch>
+          {
+            routes.map(item =>
+              (
+                <Route
+                  key={item.key}
+                  path={item.path}
+                  component={item.component}
+                  exact={item.exact}
+                />
+              )
+            )
+          }
+          <Redirect exact from="/frontend/personalize" to="/frontend/personalize/list" />
+        </Switch>
+      </PageHeaderLayout>
+    );
+  }
+}

+ 1 - 0
src/routes/Frontend/Recommend/RecommendPoster.js

@@ -19,6 +19,7 @@ import styles from './RecommendPoster.less';
   sLoading: loading.models.shelves,
   mLoading: loading.models.merchant,
 }))
+
 export default class RecommendPosterEditPage extends Component {
   state = {
     productSelectorDestroy: true,

+ 2 - 2
src/routes/Frontend/Tag/TagCreate.js

@@ -4,9 +4,9 @@ import { connect } from 'dva';
 import { routerRedux } from 'dva/router';
 import { message, Form, Table, Modal, Card, Button, Input, Switch, Radio } from 'antd';
 import { statusToBool, boolToStatus } from '../../../utils/utils';
-import AXDragSortTable from '../../../components/AXDragSortTable/index';
+import AXDragSortTable from '../../../components/AXDragSortTable';
 import Selector from '../../../components/AXTableSelector/Selector';
-import FooterToolbar from '../../../components/FooterToolbar/index';
+import FooterToolbar from '../../../components/FooterToolbar';
 import styles from './TagCreate.less';
 
 const Message = message;

+ 1 - 0
src/routes/Frontend/Tag/TagList.less

@@ -14,3 +14,4 @@
   color: #fff;
   font-weight: 500;
 }
+

+ 0 - 1
src/routes/Frontend/TagType/index.js

@@ -9,7 +9,6 @@ export default class TagType extends Component {
   render() {
     const { match, routerData } = this.props;
     const routes = getRoutes(match.path, routerData);
-
     return (
       <PageHeaderLayout>
         <Switch>

+ 1 - 0
src/routes/Resource/Picture/PictureTableList.less

@@ -28,6 +28,7 @@
   font-weight: 500;
 }
 .delBtn {
+  margin-right: 10px;
   background: #f5222d;
   color: #fff;
   font-weight: 500;

+ 0 - 1
src/routes/Terminal/User/TerminalList.js

@@ -217,7 +217,6 @@ export default class TerminalListPage extends Component {
     const { loading, fetching1, fetching2, form, campus, merchant, terminal } = this.props;
     const { list, totalSize, pageSize, pageNo } = terminal;
     const { getFieldDecorator } = form;
-
     const renderCampusName = (name) => {
       return (
         <Ellipsis tooltip lines={1}>{name}</Ellipsis>

+ 143 - 0
src/services/terminal.js

@@ -2,6 +2,11 @@ import { stringify } from 'qs';
 import request from '../utils/request';
 import { api, Hotax } from '../utils/config';
 
+/**
+ * 查询终端用户列表
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function queryTerminalList(params) {
   const newParams = {
     pageSize: Hotax.PAGE_SIZE,
@@ -10,6 +15,11 @@ export async function queryTerminalList(params) {
   return request(`${api.terminal}?${stringify(newParams)}`);
 }
 
+/**
+ * 创建终端用户
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function createTerminalItem(params) {
   const options = {
     method: 'POST',
@@ -18,6 +28,11 @@ export async function createTerminalItem(params) {
   return request(`${api.terminalItem}`, options);
 }
 
+/**
+ * 修改终端用户
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function updateTerminalItem(params) {
   const options = {
     method: 'PUT',
@@ -26,6 +41,11 @@ export async function updateTerminalItem(params) {
   return request(`${api.terminalItem}`, options);
 }
 
+/**
+ * 删除终端用户
+ * @param id
+ * @returns {Promise<Object>}
+ */
 export async function deleteTerminalItem({ id }) {
   const options = {
     method: 'DELETE',
@@ -33,6 +53,11 @@ export async function deleteTerminalItem({ id }) {
   return request(`${api.terminalItem}/${id}`, options);
 }
 
+/**
+ * 查询白名单用户
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function querySpecialTerminalList(params) {
   const newParams = {
     pageSize: Hotax.PAGE_SIZE,
@@ -41,10 +66,20 @@ export async function querySpecialTerminalList(params) {
   return request(`${api.specialTerminal}?${stringify(newParams)}`);
 }
 
+/**
+ * 查询白名单用户详情
+ * @param userId
+ * @returns {Promise<Object>}
+ */
 export async function querySpecialTerminalItem({ userId }) {
   return request(`${api.specialTerminalItem}/${userId}`);
 }
 
+/**
+ * 创建白名单用户
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function createSpecialTerminalItem(params) {
   const options = {
     method: 'POST',
@@ -53,6 +88,11 @@ export async function createSpecialTerminalItem(params) {
   return request(`${api.specialTerminalItem}`, options);
 }
 
+/**
+ * 修改白名单用户
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function updateSpecialTerminalItem(params) {
   const options = {
     method: 'PUT',
@@ -61,6 +101,11 @@ export async function updateSpecialTerminalItem(params) {
   return request(`${api.specialTerminalItem}`, options);
 }
 
+/**
+ * 删除白名单用户
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function deleteSpecialTerminalItem(params) {
   const options = {
     method: 'DELETE',
@@ -69,6 +114,11 @@ export async function deleteSpecialTerminalItem(params) {
   return request(`${api.specialTerminalItem}`, options);
 }
 
+/**
+ * 解除终端绑定
+ * @param id
+ * @returns {Promise<Object>}
+ */
 export async function deviceUnbound({ id }) {
   const options = {
     method: 'DELETE',
@@ -76,6 +126,11 @@ export async function deviceUnbound({ id }) {
   return request(`${api.terminalUnbound}/${id}`, options);
 }
 
+/**
+ * 查询终端权限列表
+ * @param params
+ * @returns {Promise<Object>}
+ */
 export async function queryTerminalAuthList(params) {
   const newParams = {
     pageSize: Hotax.PAGE_SIZE,
@@ -83,3 +138,91 @@ export async function queryTerminalAuthList(params) {
   };
   return request(`${api.terminalAuth}?${stringify(newParams)}`);
 }
+
+/**
+ * 查询终端标签列表
+ * @param uid
+ * @returns {Promise<Object>}
+ */
+export async function queryTerminalTagList({ uid }) {
+  return request(`${api.userTags}/${uid}`);
+}
+
+/**
+ * 获取终端标签详情
+ * @param userTagId
+ * @returns {Promise<Object>}
+ */
+export async function queryTerminalTagItem({ userTagId }) {
+  return request(`${api.userTag}/${userTagId}`);
+}
+
+/**
+ * 创建终端标签
+ * @param params
+ * @returns {Promise<Object>}
+ */
+export async function createTerminalTagItem(params) {
+  const options = {
+    method: 'POST',
+    body: params,
+  };
+  return request(`${api.userTag}`, options);
+}
+
+/**
+ * 修改终端标签
+ * @param params
+ * @returns {Promise<Object>}
+ */
+export async function updateTerminalTagItem(params) {
+  const options = {
+    method: 'PUT',
+    body: params,
+  };
+  return request(`${api.userTag}`, options);
+}
+
+/**
+ * 删除终端标签
+ * @param userTagId
+ * @returns {Promise<Object>}
+ */
+export async function deleteTerminalTagItem({ userTagId }) {
+  const options = {
+    method: 'DELETE',
+  };
+  return request(`${api.userTag}/${userTagId}`, options);
+}
+
+/**
+ * 复制渠道标签
+ * @param params
+ * @returns {Promise<Object>}
+ */
+export async function copyMerchantTag(params) {
+  return request(`${api.userTagCopy}?${stringify(params)}`);
+}
+
+/**
+ * 查询终端的推荐课程
+ * @param params
+ * @returns {Promise<Object>}
+ */
+export async function queryTerminalRecommendCourse({ uid }) {
+  return request(`${api.userRecommend}/${uid}`);
+}
+
+/**
+ * 修改终端的推荐课程
+ * @param uid <用户id>
+ * @param idList <课程的 idList>
+ * @returns {Promise<Object>}
+ */
+export async function updateTerminalRecommendCourse({ uid, idList }) {
+  const options = {
+    method: 'PUT',
+    body: idList,
+  };
+  return request(`${api.userRecommend}/${uid}`, options);
+}

+ 4 - 0
src/utils/config.js

@@ -129,6 +129,10 @@ const apiObj = {
   orderSend: '/order/send',
   orderReceive: '/order/receive',
   snapshot: '/order/snapshot',
+  userTags: '/user/userTag/uid',
+  userTag: '/userTag',
+  userTagCopy: '/userTag/copy',
+  userRecommend: '/user/userRecommend/uid',
 };
 
 /**

+ 2 - 1
src/utils/utils.js

@@ -63,7 +63,6 @@ export function getTimeDistance(type) {
     now.setHours(0);
     now.setMinutes(0);
     now.setSeconds(0);
-
     if (day === 0) {
       day = 6;
     } else {
@@ -322,6 +321,8 @@ export function renderProductType(type) {
     return '课程';
   } else if (type === Hotax.PRODUCT_SUPPORT) {
     return '配套';
+  } else if (type === Hotax.PRODUCT_TRAINING) {
+    return '师训';
   } else if (type === Hotax.PRODUCT_PACKAGE) {
     return '套餐包';
   } else {