<repo_name>luna825/augblog-umi<file_sep>/src/utils/Authorized.js
import RenderAuthorized from '@/components/Authorized';

// 默认为用户guset
let Authorized = RenderAuthorized('guest'); // eslint-disable-line

// Reload the rights component
const reloadAuthorized = role => {
  Authorized = RenderAuthorized(role);
};

export { reloadAuthorized };
export default Authorized;
<file_sep>/src/pages/Article/Edit.js
import React, { PureComponent } from 'react';
import { connect } from 'dva';
import RichEditor from '@/components/RichEditor/RichEditor';
import Pageloading from '@/components/PageLoading';
import { message } from 'antd';
import router from 'umi/router';
import { update } from '@/services/api';

@connect(({ article, loading }) => ({
  article,
  loading: loading.models.article,
}))
class Edit extends PureComponent {
  onSubmit = async article => {
    const { article: oldArticle, dispatch } = this.props;
    const hide = message.loading('正在提交数据...');
    try {
      const response = await update(oldArticle.links.self, article);
      if (response.status === 200) {
        hide();
        message.success('文章编辑成功');
        dispatch({
          type: 'article/save',
          payload: response.data,
        });
        router.push(`/posts/${response.data.id}`);
      }
    } catch (e) {
      hide();
      message.error('文章编辑失败');
    }
  };

  render() {
    const { article, loading } = this.props;
    return (
      <>{loading ? <Pageloading /> : <RichEditor article={article} onSubmit={this.onSubmit} />}</>
    );
  }
}

export default Edit;
<file_sep>/src/utils/auth.js
import request from '@/utils/clientRequest';

export async function login(params) {
  return request('/token', {
    method: 'POST',
    data: params,
  });
}

export async function logout() {
  return false;
}

export async function fetchCurrentUser() {
  return request('/user');
}
<file_sep>/src/components/PageLoading/loading.js
import React from 'react';
import './index.css';

// loading components from code split
// https://umijs.org/plugin/umi-plugin-react.html#dynamicimport
export default () => (
  <div className="lds-ring">
    <div />
    <div />
    <div />
    <div />
  </div>
);
<file_sep>/src/models/auth.js
import { routerRedux } from 'dva/router';
import { getPageQuery } from '@/utils/utils';
import { login } from '@/services/auth';
import { reloadAuthorized } from '@/utils/Authorized';

export default {
  namespace: 'auth',

  state: {
    isLogin: false,
  },

  effects: {
    *login({ payload }, { call, put }) {
      try {
        const response = yield call(login, payload);
        yield localStorage.setItem('aug-blog-user-token', response.data.token);
        yield put({
          type: 'user/fetchCurrent',
        });
        yield put({
          type: 'changeLoginStatus',
          isLogin: true,
        });
        if (response.status === 201) {
          const urlParams = new URL(window.location.href);
          const params = getPageQuery();
          let { redirect } = params;
          if (redirect) {
            const redirectUrlParams = new URL(redirect);
            if (redirectUrlParams.origin === urlParams.origin) {
              redirect = redirect.substr(urlParams.origin.length);
              if (redirect.match(/^\/.*#/)) {
                redirect = redirect.substr(redirect.indexOf('#') + 1);
              }
            } else {
              window.location.href = redirect;
              return;
            }
          }
          yield put(routerRedux.replace(redirect || '/'));
        }
      } catch (e) {
        yield put({
          type: 'changeLoginStatus',
          isLogin: false,
          error: e.data,
        });
      }
    },

    *logout(_, { put }) {
      yield put({
        type: 'changeLoginStatus',
        isLogin: false,
      });
      yield put({
        type: 'user/saveCurrentUser',
      });
      reloadAuthorized('guest');
      localStorage.removeItem('aug-blog-user-token');
      yield put(
        routerRedux.push({
          pathname: '/home',
        })
      );
    },
  },

  reducers: {
    changeLoginStatus(state, action) {
      return {
        ...state,
        ...action,
      };
    },
  },
};
<file_sep>/src/components/RichEditor/RichEditor.js
import 'braft-editor/dist/index.css';
import React from 'react';
import BraftEditor from 'braft-editor';
import HeaderId from 'braft-extensions/dist/header-id';
import { Form, Icon, message } from 'antd';
import styles from './RichText.less';

BraftEditor.use(HeaderId());
const FormItem = Form.Item;

const controls = [
  'undo',
  'redo',
  'remove-styles',
  'separator',
  'headings',
  'font-size',
  'separator',
  'bold',
  'italic',
  'underline',
  'strike-through',
  'separator',
  'superscript',
  'subscript',
  'separator',
  'text-indent',
  'text-align',
  'separator',
  'list-ul',
  'list-ol',
  'blockquote',
  'code',
  'separator',
  'link',
  'separator',
  'hr',
  'separator',
  'separator',
];

class Editor extends React.Component {
  static defaultProps = {
    article: {},
    onSubmit: () => {},
  };

  handleSubmit = async event => {
    event.preventDefault();
    const { form, onSubmit } = this.props;
    form.validateFields((error, values) => {
      if (error) {
        message.error(`博客内容或标题不能为空!`);
        return;
      }
      const submitData = {
        title: values.title,
        body: values.content.toRAW(), // or values.content.toHTML()
        bodyHtml: values.content.toHTML(),
      };
      onSubmit(submitData);
    });
  };

  renderTitle = (getFieldDecorator, title = '') => (
    <FormItem className={styles.editorTitle}>
      {getFieldDecorator('title', {
        validateTrigger: 'onBlur',
        initialValue: title,
        rules: [
          {
            required: true,
            validator: (_, value, callback) => {
              if (value === '') {
                callback(false);
              } else {
                callback();
              }
            },
          },
        ],
      })(<input placeholder="无标题" />)}
    </FormItem>
  );

  render() {
    const { form, article } = this.props;
    const { getFieldDecorator } = form;

    const extendControls = [
      'separator',
      {
        key: 'update-and-post', // 控件唯一标识，必传
        type: 'button',
        title: '更新发布', // 指定鼠标悬停提示文案
        className: 'post-button', // 指定按钮的样式名
        html: null, // 指定在按钮中渲染的html字符串
        text: (
          <span>
            <Icon type="sync" />
            {' 更新发布'}
          </span>
        ), // 指定按钮文字，此处可传入jsx，若已指定html，则text不会显示
        onClick: event => this.handleSubmit(event),
      },
    ];

    return (
      <div className={styles.main}>
        <Form>
          <FormItem>
            {getFieldDecorator('content', {
              validateTrigger: 'onBlur',
              initialValue: BraftEditor.createEditorState(article.bodyHtml),
              rules: [
                {
                  required: true,
                  validator: (_, value, callback) => {
                    if (value.isEmpty()) {
                      callback(false);
                    } else {
                      callback();
                    }
                  },
                },
              ],
            })(
              <BraftEditor
                controlBarClassName={styles.editorControl}
                className={styles.editor}
                contentClassName={styles.editorContent}
                controls={controls}
                placeholder="请输入正文内容"
                componentBelowControlBar={this.renderTitle(getFieldDecorator, article.title)}
                extendControls={extendControls}
              />
            )}
          </FormItem>
        </Form>
      </div>
    );
  }
}

export default Form.create()(Editor);
<file_sep>/src/models/user.js
import { fetchCurrentUser } from '@/services/auth';
import { reloadAuthorized } from '@/utils/Authorized';

export default {
  namespace: 'user',

  state: {
    currentUser: {},
  },

  effects: {
    *fetchCurrent(_, { call, put }) {
      if (!localStorage.getItem('aug-blog-user-token')) {
        yield put({
          type: 'savaCurrentUser',
        });
        return;
      }
      try {
        const { data } = yield call(fetchCurrentUser);
        reloadAuthorized(data.role);
        yield put({
          type: 'saveCurrentUser',
          payload: data,
        });
      } catch (e) {
        localStorage.removeItem('aug-blog-user-token');
        yield put({
          type: 'savaCurrentUser',
        });
      }
    },
  },

  reducers: {
    saveCurrentUser(state, action) {
      return {
        ...state,
        currentUser: action.payload || {},
      };
    },
  },
};
<file_sep>/src/pages/Home/PostList.js
import React, { memo } from 'react';
import Link from 'umi/link';
import { Card, List, Spin } from 'antd';
import InfiniteScroll from 'react-infinite-scroller';
import ArticleListContent from '@/components/ArticleListContent';
import styles from './postList.less';

const { Item } = List;

export default memo(({ articles, loading, handleInfiniteOnLoad }) => (
  <Card bordered={false} bodyStyle={{ padding: '8px 32px 32px 32px' }}>
    <InfiniteScroll
      initialLoad={false}
      pageStart={0}
      loadMore={() => handleInfiniteOnLoad(articles.links.next)}
      hasMore={!loading && !!articles.links.next}
    >
      <List
        size="large"
        rowKey="id"
        itemLayout="vertical"
        loading={loading}
        dataSource={articles.items}
        renderItem={item => (
          <Item key={item.id} extra={item.images ? <div className={styles.listItemExtra} /> : null}>
            <Item.Meta title={<Link to={`/posts/${item.id}`}>{item.title}</Link>} />
            <ArticleListContent data={item} />
          </Item>
        )}
      >
        {loading && articles.links.next && (
          <div className={styles.loadingContainer}>
            <Spin />
          </div>
        )}
      </List>
    </InfiniteScroll>
  </Card>
));
<file_sep>/src/pages/Account/model.js
import { message } from 'antd';
import { fetch, remove } from '@/services/api';
import removeArr from 'lodash/remove';

export default {
  namespace: 'account',

  state: {
    articles: {
      next: null,
      items: [],
    },
  },

  effects: {
    *fetchArticles({ url, add }, { put, call }) {
      try {
        const { data } = yield call(fetch, url);
        yield put({
          type: 'saveArticles',
          payload: data,
          add,
        });
      } catch (e) {
        // 出现错误，传递空数据
        yield put({
          type: 'save',
          payload: { items: [] },
          add: true,
        });
      }
    },
    *removeArticle({ url, id }, { put, call }) {
      const hide = message.loading('正在删除...');
      try {
        yield call(remove, url);
        yield put({
          type: 'deleteArticle',
          payload: id,
        });
        hide();
      } catch (e) {
        hide();
        message.error('删除失败, 请稍后再试...');
      }
    },
  },

  reducers: {
    saveArticles(state, { payload, add }) {
      const { articles } = state;

      return {
        ...state,
        articles: {
          ...articles,
          ...payload,
          items: add ? state.items.concat(payload.items) : payload.items,
        },
      };
    },
    deleteArticle(state, { payload }) {
      const { articles } = state;
      removeArr(articles.items, article => article.id === payload);
      return {
        ...state,
        articles: {
          ...articles,
        },
      };
    },
  },
};
<file_sep>/src/utils/clientRequest.js
import axios from 'axios';
import router from 'umi/router';
import { Message } from 'antd';
// const codeMessage = {
//   200: '服务器成功返回请求的数据。',
//   201: '新建或修改数据成功。',
//   202: '一个请求已经进入后台排队（异步任务）。',
//   204: '删除数据成功。',
//   400: '发出的请求有错误，服务器没有进行新建或修改数据的操作。',
//   401: '用户没有权限（令牌、用户名、密码错误）。',
//   403: '用户得到授权，但是访问是被禁止的。',
//   404: '发出的请求针对的是不存在的记录，服务器没有进行操作。',
//   406: '请求的格式不可得。',
//   410: '请求的资源被永久删除，且不会再得到的。',
//   422: '当创建一个对象时，发生一个验证错误。',
//   500: '服务器发生错误，请检查服务器。',
//   502: '网关错误。',
//   503: '服务不可用，服务器暂时过载或维护。',
//   504: '网关超时。',
// };

axios.defaults.timeout = 3000;
axios.defaults.withCredentials = true;

axios.interceptors.request.use(
  config => {
    const newConfig = { ...config };
    // 从本地存储中取得 jwt 的验证 token
    const token = localStorage.getItem('aug-blog-user-token');
    if (token) {
      newConfig.headers.Authorization = `Bearer ${token}`;
    }
    return newConfig;
  },
  err => Promise.reject(err)
);

axios.interceptors.response.use(
  response => {
    const { data, status } = response;
    if (status >= 200 && status < 300) {
      if (data.error_code === 1) {
        Message.success('数据删除成功');
      }
      return response;
    }
    return Promise.reject(response);
  },
  error => Promise.reject(error)
);

export default function request(url, opt) {
  return axios(url, opt).catch(error => {
    // 请求配置发生的错误
    if (!error.response) {
      // eslint-disable-next-line no-console
      return console.log('Error', error.message);
    }

    // 响应时的状态码处理
    const { status } = error.response;
    if (status === 401) {
      // @HACK
      /* eslint-disable no-underscore-dangle */
      window.g_app._store.dispatch({
        type: 'login/logout',
      });
    }
    // environment should not be used
    if (status === 403) {
      router.push('/exception/403');
    }
    if (status <= 504 && status >= 500) {
      router.push('/exception/500');
    }
    if (status >= 404 && status < 422) {
      router.push('/exception/404');
    }
    return Promise.reject(error.response);
  });
}
<file_sep>/src/pages/Authorized.js
import React from 'react';
import RenderAuthorized from '@/components/Authorized';
import Redirect from 'umi/redirect';

const Authorized = RenderAuthorized();

export default ({ children }) => (
  <Authorized authority={children.props.route.authority} noMatch={<Redirect to="/login" />}>
    {children}
  </Authorized>
);
<file_sep>/src/pages/Article/ArticleWrapper.js
/* eslint-disable react/no-danger */
import 'github-markdown-css';
import React, { PureComponent } from 'react';
import { connect } from 'dva';

@connect()
class ArticleWrapper extends PureComponent {
  componentDidMount() {
    const { dispatch, match } = this.props;
    dispatch({
      type: 'article/fetch',
      payload: `/api/v1/posts/${match.params.id}`,
    });
  }

  render() {
    const { children } = this.props;
    return <>{children}</>;
  }
}

export default ArticleWrapper;
<file_sep>/src/pages/Login/Login.js
import React, { Component } from 'react';
import { Checkbox, Alert } from 'antd';
import { formatMessage, FormattedMessage } from 'umi/locale';
import { connect } from 'dva';
import Login from '@/components/Login';
import styles from './login.less';

const { UserName, Password, Submit } = Login;

@connect(({ auth, loading }) => ({
  auth,
  submitting: loading.effects['auth/login'],
}))
class LoginPage extends Component {
  state = {
    autoLogin: true,
    type: 100,
  };

  changeAutoLogin = e => {
    this.setState({
      autoLogin: e.target.checked,
    });
  };

  handleSubmit = (err, values) => {
    const { type } = this.state;
    if (!err) {
      const { dispatch } = this.props;
      dispatch({
        type: 'auth/login',
        payload: {
          ...values,
          type,
        },
      });
    }
  };

  renderMessage = content => (
    <Alert style={{ marginBottom: 24 }} message={content} type="error" showIcon />
  );

  render() {
    const { autoLogin } = this.state;
    const { submitting, auth = {} } = this.props;
    return (
      <div className={styles.main}>
        {auth.error &&
          !auth.isLogin &&
          !submitting &&
          this.renderMessage(formatMessage({ id: 'app.login.message-invalid-credentials' }))}
        <Login
          onSubmit={this.handleSubmit}
          ref={form => {
            this.loginForm = form;
          }}
        >
          <UserName name="account" />
          <Password name="<PASSWORD>" />
          <div>
            <Checkbox checked={autoLogin} onChange={this.changeAutoLogin}>
              <FormattedMessage id="app.login.remember-me" />
            </Checkbox>
            <a style={{ float: 'right' }} href="">
              <FormattedMessage id="app.login.forgot-password" />
            </a>
          </div>
          <Submit loading={submitting}>
            <FormattedMessage id="app.login.login" />
          </Submit>
        </Login>
      </div>
    );
  }
}

export default LoginPage;
<file_sep>/src/components/ArticleListContent/index.js
import React from 'react';
import moment from 'moment';
import { Avatar } from 'antd';
import styles from './index.less';

const formatBody = body => {
  try {
    const bodyObj = JSON.parse(body);
    const text = bodyObj.blocks.map(block => block.text).join(' ');
    return text;
  } catch (e) {
    return body;
  }
};

const ArticleListContent = ({ data: { body, timestamp, author } }) => (
  <div className={styles.listContent}>
    <p className={styles.description}>{`${formatBody(body).slice(0, 150)}...`}</p>
    <div className={styles.extra}>
      <Avatar src={author.avatarUrl} />
      <a>{author.nickname}</a>
      <em>{moment(timestamp).format('YYYY-MM-DD HH:mm')}</em>
    </div>
  </div>
);

export default ArticleListContent;
<file_sep>/src/pages/Home/Home.js
import React, { PureComponent } from 'react';
import { Layout, Divider, Icon, Card } from 'antd';
import { connect } from 'dva';
import GridContent from '@/components/PageHeaderWrapper/GridContent';
import PostList from './PostList';
import styles from './index.less';

const { Content, Sider } = Layout;

class Home extends PureComponent {
  componentDidMount() {
    const { dispatch } = this.props;
    dispatch({
      type: 'articles/fetch',
      url: '/api/v1/posts',
    });
  }

  onLoadMore = next => {
    const { dispatch } = this.props;
    dispatch({
      type: 'articles/fetch',
      url: next,
      add: true,
    });
  };

  render() {
    const { articles, loading } = this.props;
    return (
      <GridContent>
        <Content>
          <PostList articles={articles} loading={loading} handleInfiniteOnLoad={this.onLoadMore} />
        </Content>
        <Sider
          width={300}
          className={styles.sider}
          breakpoint="lg"
          collapsedWidth={0}
          trigger={null}
        >
          <Card bordered={false}>
            <div className={styles.avatarHolder}>
              <img alt="" src="https://avatars0.githubusercontent.com/u/10859713?s=460&v=4" />
              <div className={styles.name}>August</div>
            </div>
            <div className={styles.detail}>
              <p>欢迎来到我的博客!</p>
              <p>我是一名计算机爱好者，目前居住于重庆</p>
              <p>谢谢你的访问。</p>
            </div>
            <Divider dashed />
            <div className={styles.link}>
              <div>
                {' '}
                <Icon type="mail" /> <EMAIL>{' '}
              </div>
              <div>
                {' '}
                <Icon type="github" />{' '}
                <a rel="noopener noreferrer" target="_blank" href="https://github.com/luna825">
                  Github{' '}
                </a>
              </div>
            </div>
          </Card>
        </Sider>
      </GridContent>
    );
  }
}

export default connect(({ articles, loading }) => ({
  articles,
  loading: loading.models.articles,
}))(Home);
<file_sep>/Dockerfile
FROM circleci/node:latest-browsers

WORKDIR /usr/src/app/
USER root
COPY package.json ./
RUN yarn

COPY ./ ./
RUN npm run build
CMD ["ls"]<file_sep>/src/pages/Home/models/articles.js
import { fetch } from '@/services/api';

export default {
  namespace: 'articles',

  state: {
    links: {
      next: null,
    },
    items: [],
  },

  effects: {
    *fetch({ url, add }, { put, call }) {
      try {
        const { data } = yield call(fetch, url);
        yield put({
          type: 'save',
          payload: data,
          add,
        });
      } catch (e) {
        // 出现错误，传递空数据
        yield put({
          type: 'save',
          payload: { items: [] },
          add: true,
        });
      }
    },
  },

  reducers: {
    save(state, { payload, add }) {
      return {
        ...state,
        ...payload,
        items: add ? state.items.concat(payload.items) : payload.items,
      };
    },
  },
};
<file_sep>/src/pages/Article/Article.js
import React, { PureComponent } from 'react';
import { connect } from 'dva';
import { Card, Avatar, Row, Col, Skeleton } from 'antd';
import moment from 'moment';
import styles from './index.less';

@connect(({ article, loading }) => ({
  article,
  loading: loading.models.article,
}))
class Article extends PureComponent {
  render() {
    const { article, loading } = this.props;
    return (
      <Card style={{ maxWidth: '100rem', margin: 'auto' }}>
        <Row className={styles.blog}>
          <Col xs={24} md={18}>
            <Skeleton loading={loading} active avatar paragraph={{ rows: 22 }}>
              {Object.keys(article).length && article.bodyHtml && (
                <>
                  <div className={styles.header}>
                    <Avatar size="large" src={article.author.avatarUrl} />
                    <div className={styles.avatarInfoBox}>
                      <a className={styles.username}>{article.author.nickname}</a>
                      <div className={styles.time}>
                        {moment(article.timestamp).format('YYYY年MM月DD日 HH:mm')}
                      </div>
                    </div>
                  </div>
                  <h1 className={styles.title}>{article.title}</h1>
                  <div
                    className="markdown-body"
                    dangerouslySetInnerHTML={{ __html: article.bodyHtml }}
                  />
                </>
              )}
            </Skeleton>
          </Col>
        </Row>
      </Card>
    );
  }
}

export default Article;
