Skip to content
On this page

业务篇-简历模版列表实现与侧边栏交互效果


前言

前面章节跟着阿宽已将简历平台搭建完毕,能实现主流程:信息录入->信息展示->信息导出,接下来我们为简历平台添加一些丰富有趣的功能。

本章节将带大家开发简历列表模块~ 本章节涉及的组件样式均不做讲解,所以小伙伴们可以结合chapter-15代码进行配套阅读实践。

如果你对本章节内容兴趣不大,可以快速阅读或跳过。

组件划分

我们先来简单实现一下简历模版列表的效果。以下面原型稿为主进行开发

image.png

当小伙伴们看到原型图之后,结合前面所说的组件化思想,想必对组件已经有了一个明确的划分。下面是阿宽的一个组件划分图:

image.png

接下来就让我们动手实现~

添加模版列表入口模块

进入到 renderer/container 文件夹下,我们新增一个文件夹,取名为:templateList,表明这是模版列表模块,并创建入口文件 index.tsx 和 index.less

ts
// renderer/container/templateList/index.tsx
import React from 'react';
import './index.less';

function TemplateList() {
  return <div styleName="container">我是模版列表模块</div>;
}
export default TemplateList;

我们新增了列表模块,那么我们需要在路由常量处添加此入口,并且支持点击跳转至模块列表页面。

我们修改 /renderer/common/constants 文件夹下的 router.ts,下面是伪代码

ts
// renderer/common/constants/router.ts

const ROUTER = {
  // 👇 新增模版列表入口
  templateList: '/templateList',
};

export const ROUTER_KEY = {
  // 👇 新增模版列表入口
  templateList: 'templateList',
};

// 入口模块
export const ROUTER_ENTRY = [
  // 👇 新增模版列表入口
  {
    url: ROUTER.templateList,
    key: ROUTER_KEY.templateList,
    text: '模版',
  },
];

添加了路由入口之后,我们还需要在路由组件,编写此路由对应的页面。路由组件 router.tsx,我们稍作修改,下面是伪代码

ts
// renderer/router.tsx

import TemplateList from '@src/container/templateList';
import ROUTER from '@common/constants/router';

function Router() {
  return (
    <HashRouter>
      <Switch>
        {/* 👇 新增路由,好让我们能跳转到对应的页面 */}
        <Route path={ROUTER.templateList} exact>
          <TemplateList />
        </Route>
      </Switch>
    </HashRouter>
  );
}
export default Router;

此时我们运行一下程序,执行 npm run start:mainnpm run start:render,可以看到,我们的首页多了一个模版入口,当我们点击之后,进入到我们的模版列表页面。

image.png

image.png

Header 组件实现

在 templateList 文件夹下,新增 Header 文件夹,并新增 index.tsx 与 index.less,样式相关代码省略

ts
// renderer/container/templateList/Header/index.tsx

import React from 'react';
import './index.less';
import { useHistory } from 'react-router';

function Header() {
  const history = useHistory();
  const goBack = () => history.push('/');
  return (
    <div styleName="header">
      <div styleName="back" onClick={goBack}>
        返回
      </div>
      <p styleName="title">简历模版仓库</p>
    </div>
  );
}
export default Header;

此刻,我们将 Header 组件引入,看看效果如何

ts
// renderer/container/templateList/index.tsx

import React from 'react';
import './index.less';
// 👇 引入Header组件
import Header from './Header';

function TemplateList() {
  return (
    <div styleName="container">
      <Header />
    </div>
  );
}
export default TemplateList;

image.png

列表侧边栏组件实现

我们期望的效果是:列表容器中陈列出所有的模版,当鼠标悬浮在图片上,如果当前悬浮的是当前模版,则显示“已使用”,否则显示“预览模版”。

请注意:实现此组件之前,请小伙伴先去 assets 文件夹下新增 template 文件夹,添加两个模版的封面,这两个模版封面地址在此:下载封面地址

image.png

在添加了模版封面之后,接下来我们在 templateList 文件夹下,新增 Navigation 文件夹,并新增 index.tsx 与 index.less,由于样式代码相对较多,这里不做展示。

ts
// renderer/container/templateList/Navigation/index.tsx

import React from 'react';
import './index.less';
import UseIcon from '@assets/icon/use.png';
// 👇 模版封面图
import TemplateCoverOne from '@assets/template/template1.jpg';
import TemplateCoverTwo from '@assets/template/template2.jpg';
import MyScrollBox from '@common/components/MyScrollBox';
import MyButton from '@common/components/MyButton';

function Navigation() {
  const height = document.body.clientHeight;

  return (
    <div styleName="navigation">
      <MyScrollBox maxHeight={height - 60 - 32}>
        {/* 悬浮效果一:属于当前模版 */}
        <div styleName="template">
          <img styleName="cover" src={TemplateCoverOne} />
          <div styleName="mask">
            <img styleName="use" src={UseIcon} />
          </div>
        </div>
        {/* 悬浮效果二:可选择预览模版 */}
        <div styleName="template">
          <img styleName="cover" src={TemplateCoverTwo} />
          <div styleName="mask">
            <MyButton size="middle" className="view-btn" onClick={() => {}}>
              预览模版
            </MyButton>
          </div>
        </div>
      </MyScrollBox>
    </div>
  );
}

export default Navigation;

我们来刷新一下页面,可以看到,我们的模版列表静态效果已经完成。

image.png

我们在 templateList 文件夹下,新增 Footer 文件夹,在此文件夹中新增 index.tsx 与 index.less。

该组件的主要职责是:以当前预览的静态模版进行简历制作。

明白此组件的作用之后,我们很快就能编写出相关代码。

ts
// renderer/container/templateList/Footer/index.tsx

import React from 'react';
import './index.less';
import MyButton from '@common/components/MyButton';

function Footer() {
  const onMadeResume = () => {
    console.log('跳转前往制作页面');
  };
  return (
    <div styleName="footer">
      <MyButton size="middle" className="use-btn" onClick={onMadeResume}>
        以此模版前往制作简历
      </MyButton>
    </div>
  );
}

export default Footer;

静态模版组件展示

接下来我们在 templateList 文件夹下,新增 StaticResume 文件夹,表示此这是静态模版预览,我们新增 index.tsx 与 index.less。

第八章节处,阿宽为大家提供了一份简历模版,所以不出意外的情况下,小伙伴们的 renderer/container 文件夹下,会存在一个名为 templates 的文件夹,我们直接引入里面的静态模版进行展示即可。

ts
// renderer/container/templateList/StaticResume/index.tsx

import React from 'react';
import './index.less';
// 👇 引入所有的静态模版
import * as TemplateList from '@src/container/templates';
// 👇 引入上边写好的 Footer 组件
import Footer from '../Footer';
import MyScrollBox from '@common/components/MyScrollBox';

function StaticResume() {
  const HEADER_HEIGHT = 76; // 距离头部距离
  const height = document.body.clientHeight;

  return (
    <div styleName="container">
      <MyScrollBox maxHeight={height - HEADER_HEIGHT}>
        {/* 这里暂时先写死第一个静态模版 */}
        <TemplateList.TemplateOne />
        <Footer />
      </MyScrollBox>
    </div>
  );
}

export default StaticResume;

刷新一下页面,看看效果是否符合我们期望。果不其然,并且滚动至底部,可以看到跳转按钮。

image.png

至此,我们的模版列表已开发完毕,基本上能实现我们想要的样式效果。

🤔 模版数据显示

上面我们是以简单粗暴形式,将页面撸了出来,但细心的你会发现,几乎都是写死的数据,比如模版封面图片,我们都是通过 import 引入,假设将来有 n 张模版封面,是不是需要引入 n 次?

我在想:能否通过读取模版封面的文件夹,以读文件夹的形式把所有封面读出来,然后进行展示呢?得益于 Electron 内置了 NodeJS,我们通过 fs 文件系统模块来试试,看看是否能读到模版封面的所有图片。

useReadDirAssetsTemplateHooks

我们在 renderer 文件夹下,新增 hooks 文件夹,下面是我们的文件目录图

image.png

在 hooks 文件夹下,新增自定义 hooks,暂且叫做:useReadDirAssetsTemplateHooks.ts,从名称上可知,该 hooks 主要是读取模版静态文件目录。让我们来编写它

ts
// renderer/hooks/useReadDirAssetsTemplateHooks.ts

import fileAction from '@common/utils/file';
import { getAppPath } from '@common/utils/appPath';

export default function () {
  return () => {
    // 1. 先获取应用地址
    getAppPath().then((appPath: string) => {
      console.log(appPath);
      // 2. 从assets读取模版图片信息,构造模版列表
      fileAction
        .readDir(`${appPath}assets/template`)
        .then((files: string[]) => {
          // 👇 打印一下该目录下的文件
          console.log('该目录下的文件有:\n');
          console.log(files);
        })
        .catch((err: NodeJS.ErrnoException) => {
          throw new Error(err.message);
        });
    });
  };
}

不出意外的话,此时的你会报错,原因是找不到 fileAction.readDir() 方法,原来在第七章节我们封装的 file.ts 文件中未支持 readDir,我们前往 @common/utils/file.ts,添加一下代码。

ts
const fileAction = {
  /**
   * @description 读取目录内容
   * @param path 路径
   * @returns  {Promise}
   */
  readDir: (path: string): Promise<string[]> => {
    return fsPromiseAPIs.readdir(path);
  },
};

添加之后,我们需要思考什么时机执行该逻辑:阿宽希望在应用启动后,能将模版模块相关的初始化工作完成。所以阿宽选择在路由组件的 didMount 生命周期中执行初始化的相关工作。

前往路由组件 router.tsx 进行初始化

ts
// renderer/router.tsx

import useReadDirAssetsTemplateHooks from './hooks/useReadDirAssetsTemplateHooks';

function Router() {
  const readDirAssetsTemplateHooks = useReadDirAssetsTemplateHooks();
  // 👇 进行初始化工作
  useEffect(() => {
    readDirAssetsTemplateHooks();
  }, []);

  // 后面代码忽略
}
export default Router;

此时我们将应用跑起来,打开控制台,可以看到此文件夹下存在 2 张图片

image.png

如上所示,我们现在只拿到文件名,那么如何读到此文件内容呢?下面我们通过 fileAction.read 方法读取图片内容,构造一个模版封面列表,将其存入到 redux 中,并默认选中第一条模版

ts
// renderer/hooks/useReadDirAssetsTemplateHooks.ts
import fileAction from '@common/utils/file';
import { useDispatch } from 'react-redux';
import { getAppPath } from '@common/utils/appPath';
import { createUID } from '@common/utils';

export default function () {
  const dispatch = useDispatch();
  return () => {
    // 1. 先获取应用地址
    getAppPath().then((appPath: string) => {
      // 2. 从assets读取模版图片信息,构造模版列表
      fileAction
        .readDir(`${appPath}assets/template`)
        .then(async (files: string[]) => {
          // 3. 构造模版列表
          if (files.length > 0) {
            let templateList: TSTemplate.Item[] = [];
            for (const fileName of files) {
              const base64URL = await fileAction.read(`${appPath}assets/template/${fileName}`, 'base64');
              templateList.push({
                templateName: fileName,
                templateId: createUID(),
                templateCover: `data:image/png;base64,${base64URL}`,
              });
            }
            // 4. 存入到 redux 中,并默认选中第一条
            dispatch({
              type: 'templateModel/setStoreList',
              payload: [
                {
                  key: 'templateList',
                  values: templateList,
                },
                {
                  key: 'selectTemplate',
                  values: templateList[0],
                },
              ],
            });
          }
        })
        .catch((err: NodeJS.ErrnoException) => {
          throw new Error(err.message);
        });
    });
  };
}

此时会看到 TSTemplate 报错,我们去 @common/types/template.d.ts 中添加类型说明

ts
// renderer/common/types/template.d.ts

declare namespace TSTemplate {
  export interface Item {
    /**
     * @description 模版id
     */
    templateId: string;
    /**
     * @description 模版名称
     */
    templateName: string;
    /**
     * @description 模版封面
     */
    templateCover: string;
  }
}

同时需要去 store/templateModel 文件下,添加 state 值

ts
//  renderer/store/templateModel.ts

export interface TStore {
  /**
   * @description 选中工具条模块的keys
   */
  resumeToolbarKeys: string[];
  /**
   * @description 模块列表
   */
  templateList: TSTemplate.Item[];
  /**
   * @description 当前选中的模版
   */
  selectTemplate: TSTemplate.Item;
}

const templateModel: TSRcReduxModel.Props<TStore> = {
  namespace: 'templateModel',
  openSeamlessImmutable: true,
  state: {
    resumeToolbarKeys: [],
    templateList: [],
    selectTemplate: {
      templateId: '',
      templateName: '',
      templateCover: '',
    },
  },
};

export default templateModel;

最后我们刷新一下页面,可以看到 redux 中已经将我们的模版数据存入。

image.png

动态显示数据模版列表

前往 Navigation 组件中,讲静态数据改为从 redux 中读取数据。

ts
import React from 'react';
import './index.less';
import UseIcon from '@assets/icon/use.png';
import MyScrollBox from '@common/components/MyScrollBox';
import MyButton from '@common/components/MyButton';
import { useDispatch, useSelector } from 'react-redux';

function Navigation() {
  const dispatch = useDispatch();
  const HEADER_HEIGHT = 92;
  const height = document.body.clientHeight;
  // 👇 从 redux 中读取数据
  const templateList: TSTemplate.Item[] = useSelector((state: any) => state.templateModel.templateList);
  const selectTemplate: TSTemplate.Item = useSelector((state: any) => state.templateModel.selectTemplate);

  const onChangeTemplate = (template: TSTemplate.Item) => {
    dispatch({
      type: 'templateModel/setStore',
      payload: {
        key: 'selectTemplate',
        values: template,
      },
    });
  };

  return (
    <div styleName="navigation">
      <MyScrollBox maxHeight={height - HEADER_HEIGHT}>
        {templateList &&
          templateList.length > 0 &&
          templateList.map((template: TSTemplate.Item) => {
            return (
              <div styleName="template" key={template?.templateId}>
                <img styleName="cover" src={template?.templateCover} />
                <div styleName="mask">
                  {selectTemplate?.templateId === template?.templateId && (
                    <img styleName="use" src={UseIcon} />
                  )}
                  {selectTemplate?.templateId !== template?.templateId && (
                    <MyButton size="middle" className="view-btn" onClick={() => { onChangeTemplate(template) }}>
                      预览模版
                    </MyButton>
                  )}
                </div>
              </div>
            );
          })}
      </MyScrollBox>
    </div>
  );
}

export default Navigation;

至此我们实现了自定义 useReadDirAssetsTemplateHooks通过 fs 文件系统读取文件夹内容,构造模版列表,进行动态的展示,从而解决繁琐的多次 import 多张模版封面

侧边栏展开收起

虽然说我们是将基本功能实现完成了,但在交互体验上,总感觉有些“呆板僵硬”,我们能否通过侧边栏展开收起的交互效果,让应用看起来“年轻”一些呢?

先来看看效果图

  • 侧边栏展开时

image.png

  • 侧边栏收起时

image.png

我们来分析一下,看起来侧边栏好像通过定位进行的“位置切换”,同时我们可以看到,静态模版是居中显示在页面中的,侧边栏此刻还多了一个切换状态的“按钮”。

对于已完成的模块,我们是不期望再去动它。并且我们思考一下,这个交互效果会不会是通用的?比如将来在其他模块,是否也存在侧边栏,同时侧边栏交互与此一致?仔细品一品,是不是做成通用组件会更加合适?

MyRectSize

我们在 common/components 下新增一个文件夹,取名为 MyRectSize,新增 5 个文件:index.tsparent.tsleft.tsright.tsindex.less

出于代码量比较精简,就全量贴上来了,关于样式部分,小伙伴们可点击链接样式代码阅读

下面直接贴代码并在注释中讲解,一定要看注释!!!

ts
// 👇 定义组件的入口文件
import './index.less';
import ParentComponent from './parent';
export default ParentComponent;

在父组件中,主要是获取获取样式,进行传递

ts
import React from 'react';
// 👇 1. 引入左侧与右侧组件
import LeftComponent from './left';
import RightComponent from './right';

interface IProps {
  /**
   * @description 自定义样式
   */
  style?: React.CSSProperties;
  children?: any;
}

class ParentComponent extends React.Component<IProps> {
  // 👇 2. 定义类组件的静态属性
  static Left = LeftComponent;
  static Right = RightComponent;

  defaultLeftBoxRef = React.createRef();

  getParentStyle() {
    return {
      display: 'flex',
      justifyContent: 'center',
    };
  }

  getLeftStyle() {
    return {
      position: 'absolute',
      left: 16,
    };
  }

  getRightStyle() {
    return {};
  }

  // 👇 3. 获取左侧容器
  get leftBoxRef() {
    const { children } = this.props;
    const leftElement = children[0];
    return leftElement.props.boxRef || this.defaultLeftBoxRef;
  }

  getChild() {
    const { children } = this.props;
    const leftElement = children[0];
    const rightElement = children[1];

    return [
      React.cloneElement(leftElement, {
        style: { ...this.getLeftStyle(), ...(leftElement.props.style || {}) },
        // 👇 4. 一定要给左侧组件传递
        boxRef: this.leftBoxRef,
        key: 'componentLeft',
      }),
      React.cloneElement(rightElement, {
        style: { ...this.getRightStyle(), ...(rightElement.props.style || {}) },
        key: 'componentRight',
      }),
    ];
  }

  render() {
    const { style } = this.props;
    let finialStyle = this.getParentStyle();
    return (
      <div className="parent-box" style={{ ...finialStyle, ...style }}>
        {this.getChild()}
      </div>
    );
  }
}

export default ParentComponent;

接下来看看 left 组件中,做了什么事情,主要是获取侧边栏左侧自组件的真实宽度,并内置了切换的 Icon 按钮,通过展示/收起交互,进行位置偏移。

ts
import React from 'react';
import classnames from 'classnames';
import { reducePX, transformStringToNumber } from '@common/utils';

interface IProps {
  /**
   * @description 自定义样式
   */
  style?: React.CSSProperties;
  boxRef?: any;
  key?: string;
}

interface IState {
  /**
   * @description 是否显示Menu控件
   */
  showMenu: boolean;
  /**
   * @description 左侧组件DOM宽度
   */
  width: number;
}

class LeftComponent extends React.Component<IProps, IState> {
  isTransition: boolean;
  defaultRef = React.createRef();

  constructor(props: IProps) {
    super(props);
    this.state = {
      showMenu: true,
      // 👇 1. 一开始时的宽度是0
      width: 0,
    };
    this.isTransition = false; // 只有点击的时候才加上动画
  }

  componentDidMount() {
    // 👇 2. 这里就是获取父组件给左侧自组件传递的 boxRef
    if (
      this.boxRef.current &&
      this.boxRef.current.children &&
      this.boxRef.current.children.length > 0 &&
      this.boxRef.current.children[0].clientWidth
    ) {
      // 👇 3. 获取左侧侧边栏的真实宽度,然后赋值,下面在渲染时会用到
      this.setState({ width: this.boxRef.current.children[0].clientWidth });
    }
  }

  get boxRef() {
    return this.props.boxRef || this.defaultRef;
  }

  onChangeMenu = () => {
    this.setState((prev) => {
      return {
        showMenu: !prev.showMenu,
      };
    });
  };

  render() {
    const { showMenu, width } = this.state;
    const { key = 'componentLeft', style = {}, children } = this.props;
    return (
      <div key={key}>
        <div
          ref={this.boxRef}
          className="left-box"
          style={{ width, ...style, left: showMenu ? style?.left : -width }}
        >
          {children}
        </div>
        <div
          className="rect-menu"
          style={{
            left: showMenu
              ? width + (transformStringToNumber(reducePX(style?.left)) || 0)
              : 0,
            transition: this.isTransition ? 'all 0.5s' : 'none',
          }}
          onClick={() => {
            this.onChangeMenu();
            this.isTransition = true;
          }}
        >
          <div
            className={classnames('rect-icon', {
              'rect-icon-hidden': !showMenu,
            })}
          />
        </div>
      </div>
    );
  }
}

export default LeftComponent;
ts
import React from 'react';

interface IProps {
  /**
   * @description 自定义样式
   */
  style?: React.CSSProperties;
  children?: React.ReactNode;
  key?: string;
}

class RightComponent extends React.PureComponent<IProps> {
  render() {
    const { key = 'componentRight', style = {}, children } = this.props;
    return (
      <div key={key} className="right-box" style={style}>
        {children}
      </div>
    );
  }
}

export default RightComponent;

我们的组件编写完毕,用起来试试,前往 renderer/container/templateList 修改 index.tsx,我们将代码改写为:

ts
// renderer/container/templateList/index.tsx

import React from 'react';
import './index.less';
import Header from './Header';
import Navigation from './Navigation';
import StaticResume from './StaticResume';
// 👇 引入
import MyRectSize from '@common/components/MyRectSize';

function TemplateList() {
  return (
    <div styleName="container">
      <Header />
      <div styleName="content">
        <MyRectSize>
          <MyRectSize.Left>
            <Navigation />
          </MyRectSize.Left>
          <MyRectSize.Right>
            <StaticResume />
          </MyRectSize.Right>
        </MyRectSize>
      </div>
    </div>
  );
}
export default TemplateList;

如果你发现接入此组件之后有一些样式上的问题,你需要做一些修改。具体详情可看这里:commit

至此,我们完成了简历模版列表的实现以及侧边栏的交互效果。

总结

本章节功能实现出发,通过初步完成静态效果,让小伙伴们快速实现此需求功能。由于每次都需要手动 import 引入模版封面,于是思考封装 hooks 实现读取模版静态文件夹的方式解决。通过对交互的打磨思考,最终实现侧边栏的动态交互效果。

如果您在边阅读边实践时,发现代码报错或者 TS 报错,那么小伙伴们可以根据报错信息,去线上看看相应的代码。

本章节的代码量相对较大,如果对本章节存在疑问,欢迎在评论区留言。