Appearance
业务篇-简历模版列表实现与侧边栏交互效果
前言
前面章节跟着阿宽已将简历平台搭建完毕,能实现主流程:信息录入->信息展示->信息导出,接下来我们为简历平台添加一些丰富有趣的功能。
本章节将带大家开发简历列表模块~ 本章节涉及的组件样式均不做讲解,所以小伙伴们可以结合chapter-15代码进行配套阅读实践。
如果你对本章节内容兴趣不大,可以快速阅读或跳过。
组件划分
我们先来简单实现一下简历模版列表的效果。以下面原型稿为主进行开发
当小伙伴们看到原型图之后,结合前面所说的组件化思想,想必对组件已经有了一个明确的划分。下面是阿宽的一个组件划分图:
接下来就让我们动手实现~
添加模版列表入口模块
进入到 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:main
与 npm run start:render
,可以看到,我们的首页多了一个模版
入口,当我们点击之后,进入到我们的模版列表页面。
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;
列表侧边栏组件实现
我们期望的效果是:列表容器中陈列出所有的模版,当鼠标悬浮在图片上,如果当前悬浮的是当前模版,则显示“已使用”,否则显示“预览模版”。
请注意:实现此组件之前,请小伙伴先去 assets 文件夹下新增 template 文件夹,添加两个模版的封面,这两个模版封面地址在此:下载封面地址
在添加了模版封面之后,接下来我们在 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;
我们来刷新一下页面,可以看到,我们的模版列表静态效果已经完成。
Footer 组件实现
我们在 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;
刷新一下页面,看看效果是否符合我们期望。果不其然,并且滚动至底部,可以看到跳转按钮。
至此,我们的模版列表已开发完毕,基本上能实现我们想要的样式效果。
🤔 模版数据显示
上面我们是以简单粗暴形式,将页面撸了出来,但细心的你会发现,几乎都是写死的数据,比如模版封面图片,我们都是通过 import 引入,假设将来有 n 张模版封面,是不是需要引入 n 次?
我在想:能否通过读取模版封面的文件夹,以读文件夹的形式把所有封面读出来,然后进行展示呢?得益于 Electron 内置了 NodeJS,我们通过 fs 文件系统模块来试试,看看是否能读到模版封面的所有图片。
useReadDirAssetsTemplateHooks
我们在 renderer 文件夹下,新增 hooks 文件夹,下面是我们的文件目录图
在 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 张图片
如上所示,我们现在只拿到文件名,那么如何读到此文件内容呢?下面我们通过 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 中已经将我们的模版数据存入。
动态显示数据模版列表
前往 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 多张模版封面。
侧边栏展开收起
虽然说我们是将基本功能实现完成了,但在交互体验上,总感觉有些“呆板僵硬”,我们能否通过侧边栏展开收起的交互效果,让应用看起来“年轻”一些呢?
先来看看效果图
- 侧边栏展开时
- 侧边栏收起时
我们来分析一下,看起来侧边栏好像通过定位进行的“位置切换”,同时我们可以看到,静态模版是居中显示在页面中的,侧边栏此刻还多了一个切换状态的“按钮”。
对于已完成的模块,我们是不期望再去动它。并且我们思考一下,这个交互效果会不会是通用的?比如将来在其他模块,是否也存在侧边栏,同时侧边栏交互与此一致?仔细品一品,是不是做成通用组件会更加合适?
MyRectSize
我们在 common/components
下新增一个文件夹,取名为 MyRectSize,新增 5 个文件:index.ts
、parent.ts
、left.ts
、right.ts
、index.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 报错,那么小伙伴们可以根据报错信息,去线上看看相应的代码。
本章节的代码量相对较大,如果对本章节存在疑问,欢迎在评论区留言。