手摸手,带你用Electron+React开发一款桌面应用(3)

2,643 阅读9分钟

我正在参加跨端技术专题征文活动,详情查看:juejin.cn/post/710123…

大家好,我是爱吃鱼的桶哥,继上一篇文章给大家分享了数据如何获取后,这段时间因为工作比较忙,所以一直没有更新文章,今天我们就把剩下的内容继续完善一下。

设置

之前是文章中已经跟大家介绍过要开发的这块软件是包含了图书是搜索、下载以及发送到Kindle中的,目前我们已经完成了前两步,就只剩下发送这个功能了,但是我们要把图书发送到Kindle中还需要设置一个发送是邮箱,所以我们需要先完善设置界面,让用户能够设置自己的发送邮箱和收件邮箱,这样才能一键将图书发送到Kindle中。以下即开发完成的设置界面:

image.png

发送界面如下:

image.png

这一次我们要完成的就是这两个页面,我们需要在设置界面输入自己想要发送的邮箱地址、秘钥,以及接收的邮箱地址;然后我们要到发送界面点击某本书的send按钮,然后这本书就能一键自动发送到我们的邮箱中了,那么接下来我们就开始实战吧!

首先我们打开之前的renderer/src/pages目录,找到Setting目录,这个目录下的内容就是设置界面相关的代码,因为我们没有专业的设计师,因此使用antd默认的表格组件来完成,具体代码如下:

// Setting/index.tsx
import { Tabs } from 'antd';
import SendEmail from './components/SendEmail';
import LocalStorage from './components/LocalStorage';

const { TabPane } = Tabs;

/**
 * 设置页面
 * @returns DOM 
 */
const Setting = () => {
  return (
    <Tabs tabPosition={'left'}>
      <TabPane tab="邮箱设置" key="1">
        <SendEmail />
      </TabPane>
      <TabPane tab="本地存储" key="2">
        <LocalStorage />
      </TabPane>
    </Tabs>
  )
};

export default Setting;

设置界面我们将内容分为了左右两个部分,左边是tab切换,右侧是对应的切换内容,这里只是为了演示相关的功能,所以做了一个tab切换,实际上大家还可以根据自己的需求来增加更多的内容。

接下来我们一起来完成一下邮箱设置的相关功能,主要是需要将用户输入的信息保存在应用的本地数据中,这样当用户需要发送文件时,可以直接获取到对应的邮箱信息,具体的设置代码如下:

import { FC, useEffect } from 'react';
import { Button, Form, Input, Select, message } from 'antd';
import { store } from '@/utils/store';

const layout = {
  labelCol: { span: 5 },
  wrapperCol: { span: 19 },
};

/**
 * 发送到kindle的邮箱地址
 * @returns DOM
 */
const SendEmail: FC = () => {
  const [form] = Form.useForm();

  // 初始化邮箱信息
  const getInitialEmail = async () => {
    const email = await store.get('emailSetting');
    if (email) {
      form.setFieldsValue(email);
    }
  };

  // 保存数据
  const handleSubmit = () => {
    form.validateFields()
      .then(async (res) => {
        await store.set('emailSetting', res);
        message.success('保存成功');
      });
  };

  useEffect(() => {
    getInitialEmail();
  }, []);

  return (
    <Form form={form} {...layout} name="control-hooks" onFinish={handleSubmit}>
      <Form.Item label="发送邮箱类型" name="type" rules={[{ required: true, message: '请选择发送邮箱类型' }]}>
        <Select placeholder="请选择发送邮箱类型">
          <Select.Option value={'163'}>163邮箱</Select.Option>
          <Select.Option value={'126'}>126邮箱</Select.Option>
          <Select.Option value={'qq'}>qq邮箱</Select.Option>
        </Select>
      </Form.Item>
      <Form.Item label="发送邮箱地址" name="sendEmail" rules={[{ required: true, message: '请输入发送邮箱' }]}>
        <Input placeholder="请输入发送邮箱" />
      </Form.Item>
      <Form.Item label="发送邮箱秘钥" name="passKey" rules={[{ required: true, message: "请输入发送邮箱秘钥" }]}>
        <Input.Password placeholder="请输入发送邮箱秘钥" />
      </Form.Item>
      <Form.Item label="接收邮箱地址" name="receiveEmail" rules={[{ required: true, message: "请输入接收邮箱" }]}>
        <Input placeholder="请输入接收邮箱" />
      </Form.Item>
      <Form.Item labelCol={{ span: 0 }} wrapperCol={{ span: 24 }} style={{ textAlign: 'right' }}>
        <Button htmlType="submit" type="primary">保存</Button>
      </Form.Item>
    </Form>
  )
}

export default SendEmail;

在如上的代码中,我们引入了一个自定义的store文件,而这个store能够帮助我们将相关的信息保存在电脑的硬盘中,我们可以一起看一下store的相关代码:

// utils/store.ts

/**
 * 数据存储
 */
export const store = {
  async get(key: string) {
    const { invoke } = window.ipcRenderer
    let value = await invoke('electron-store', 'get', key)
    try {
      value = JSON.parse(value)
    } finally {
      return value
    }
  },
  async set(key: string, value: any) {
    const { invoke } = window.ipcRenderer
    let val = value
    try {
      if (value && typeof value === 'object') {
        val = JSON.stringify(value)
      }
    } finally {
      await invoke('electron-store', 'set', key, val)
    }
  },
};

数据的存储其实是利用了electron-store这个第三方库,我们可以在main/samples文件夹中找到electron-store.ts这个文件,具体的代码如下:

/**
 * Example of 'electron-store' usage.
 */
import { ipcMain } from 'electron'
import Store from 'electron-store'

/**
 * Expose 'electron-store' to Renderer-process through 'ipcMain.handle'
 */
const store = new Store()
ipcMain.handle(
  'electron-store',
  async (_event, methodSign: string, ...args: any[]) => {
    if (typeof (store as any)[methodSign] === 'function') {
      return (store as any)[methodSign](...args)
    }
    return (store as any)[methodSign]
  }
)

因为渲染进程本身的无法操作硬盘上的数据的,因此我们需要借助主进程来保存数据,所以在渲染进程中,当我们点击保存按钮时,会执行store.set('emailSetting', res),而store.set会将数据通过JSON.stringify转换为字符串,然后发送通知给主进程await invoke('electron-store', 'set', key, val),最终主进程接收到该命令后就会将数据保存在该应用在硬盘中的一个默认文件config.json中,具体的文件路径如下:/Users/mac/Library/Application Support/KindleHelper/config.json,大家可以在renderer/src/samples文件夹下找electron-store.ts文件,这个文件里们可以打印存储的文件具体的地址。

在邮箱设置界面,我们可以看到发送邮箱的类型、发送邮箱地址、发送邮箱秘钥已经接收邮箱地址四个输入框,其中发送邮箱类型是我们提前设置好的,其中包括qq邮箱163邮箱126邮箱,我们根据用户选择的邮箱类型来设置发送邮件的host,关于邮件的发送会在后面介绍到。

接下来我们还需要设置发送邮箱的秘钥,假设我们使用的是163邮箱进行邮件的发送,那么我们需要打开163邮箱,然后找到设置界面,在设置中找到POP3/SMTP/IMAP选项,如图所示:

image.png

进入到该界面中,开启POP3/SMTP服务,然后获取到对应的秘钥,如下所示:

image.png

当获取到邮箱的发送秘钥后,我们再到设置界面中对应的输入框内填入对应的秘钥,点击保存即可。

发送

在发送界面,程序会自动获取到当前已经下载过的文件,并展示在表格中,那么我们如何获取到已经下载文件的信息呢?具体代码如下:

// pages/Send/index.tsx
import { FC, useEffect, useState } from 'react';
import { message, Modal, Table } from 'antd';
import type { TableRowSelection } from 'antd/lib/table/interface';
import { store } from '@/utils/store';
import TableColumns, { DataType } from './columns';

const Send: FC = () => {
  const [data, setData] = useState([]);
  const getBookData = async () => {
    const bookList = await store.get('books');
    // console.log('bookList', bookList);
    const mergeArray: any = [];
    Object.values(bookList).forEach((value: any) => {
      mergeArray.push(...(value.filter((item: Record<string, any>) => !!!item.sendStatus)));
    });
    setData(mergeArray);
  }

  // 发送文件到邮箱
  const handleSend = async (record: DataType) => {
    const emailSetting = await store.get('emailSetting');
    if (emailSetting) {
      window.ipcRenderer.send('sendFileToKindle', {
        bookInfo: record,
        emailInfo: emailSetting
      });
      message.success('发送任务进行中,请耐心等待邮件服务执行,请勿重复点击');
    } else {
      Modal.warning({
        title: '注意',
        content: (
          <div>
            <p>发送文件前请先设置发件邮箱及收件邮箱!</p>
          </div>
        )
      });
    }
  };

  // 删除已发送文件(修改发送状态为true) 
  const handleDelete = async (record: DataType) => {
    // console.log('record', record);
    const { downloadTime, uuid } = record;
    const books = await store.get('books');
    // console.log('books', books);
    if (books[downloadTime]) {
      (books[downloadTime] || []).map((item: Record<string, any>) => {
        // console.log('item', item);
        if (item.uuid === uuid) {
          item.sendStatus = true;
        }
        return item;
      });
    }
    await store.set('books', books);
    console.log('ssssss');
    getBookData();
    console.log('ccccc');
  };

  useEffect(() => {
    getBookData();
  }, []);

  return (
    <Table
      rowKey={"uuid"}
      columns={TableColumns({ handleSend, handleDelete })}
      rowSelection={{ checkStrictly: false }}
      dataSource={data}
      size="small"
    />
  )
};

export default Send;

上述代码中,我们将表头的信息单独写在了一个columns文件中,之所以单独拆分,也是为了更加方便的管理和维护我们的代码,代码如下所示:

import { Space, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/lib/table';

export interface DataType {
  key: React.ReactNode;
  id: string;
  filename: string;
  filesize: string;
  downloadTime: string;
  uuid: string;
  sendStatus?: boolean;
  children?: DataType[];
}

interface TableColumnsProps {
  handleSend: (record: DataType) => void;
  handleDelete: (record: DataType) => void;
}

const TableColumns = ({ handleSend, handleDelete }: TableColumnsProps) => {
  const columns: ColumnsType<DataType> = [
    {
      title: '文件名',
      dataIndex: 'filename',
      key: 'filename',
      fixed: 'left',
      ellipsis: true,
      render: (text: string) => <Tooltip title={text}>{text}</Tooltip>
    },
    {
      title: '文件大小',
      dataIndex: 'filesize',
      key: 'filesize',
      width: '14%',
    },
    {
      title: '文件格式',
      dataIndex: 'filetype',
      key: 'filetype',
      width: '12%',
    },
    {
      title: '操作',
      dataIndex: 'action',
      width: '16%',
      key: 'action',
      fixed: 'right',
      render: (_, record) => (
        <Space>
          <a type="link" onClick={() => handleSend(record)}>send</a>
          <a type="link" onClick={() => handleDelete(record)}>Delete</a>
        </Space>
      )
    },
  ];
  return columns;
};

export default TableColumns;

最终完成后的代码展示界面即为最初我们展示的那样。我们可以点击send按钮,然后对应的图书就会自动发送到我们设置好的邮箱中;当然我们也可以点击后面的Delete按钮将图书“删除”,这个删除并不是真正的删除,只是将这本书的信息从发送邮件的列表中剔除,这样当我们发送过某本书后能够及时的清理已经发送过的内容,避免重复的发送,当然你也可以自己增加一个已发送的标记,这里就不做过多的陈述了。

在上面的代码中我们可以看到,点击按钮的时候会先从store中获取之前用户设置的邮箱,如果用户没有设置过,则会提示用户需要先设置对应的邮箱才能发送,代码如下:

// Send/index.tsx
...other code

// 发送文件到邮箱
const handleSend = async (record: DataType) => {
    const emailSetting = await store.get('emailSetting');
    if (emailSetting) {
      window.ipcRenderer.send('sendFileToKindle', {
        bookInfo: record,
        emailInfo: emailSetting
      });
      message.success('发送任务进行中,请耐心等待邮件服务执行,请勿重复点击');
    } else {
      Modal.warning({
        title: '注意',
        content: (
          <div>
            <p>发送文件前请先设置发件邮箱及收件邮箱!</p>
          </div>
        )
      });
    }
};

...other code

当检测到用户已经设置相关的邮箱信息后,渲染程序会给主程序发送邮件发送的通知,相关的代码如下:

// packages/main/event.ts

...other code

// 发送邮件
ipcMain.on('sendFileToKindle', async (event, args) => {
    // console.log('args', args);
    try {
        const response = await sendEmail(args);
        // console.log('sendFileToKindle', response);
        event.sender.send('sendFileSuccess', response);
    } catch (err) {
        console.error('error', err);
    }
});

这里我们使用了一个sendEmail的方法,它接收从主进程发来的数据,并完成邮件的发送,代码如下:

// packages/main/utils/email.ts
const nodemailer = require('nodemailer');
const dayjs = require('dayjs');

const emailHost = new Map([
  ['163', 'smtp.163.com'],
  ['126', 'smtp.126.com'],
  ['qq', 'smtp.qq.com'],
]);

// async..await is not allowed in global scope, must use a wrapper
const sendEmail = ({ bookInfo, emailInfo }: any) => {
    const sendDefault = {
        host: emailHost.get(emailInfo?.type),
        port: 465,
        secure: true,
        auth: {
            user: emailInfo?.sendEmail, 
            pass: emailInfo?.passKey,
        },
        tls: { rejectUnauthorized: false },
    };

    const transporter = nodemailer.createTransport(sendDefault);
    const startTime = dayjs().format('YYYY-MM-DD HH:mm:ss');

    const mailOptions = {
        from: `${emailInfo?.sendEmail}`, // sender address
        to: `${emailInfo?.receiveEmail}`, // list of receivers
        subject: `${bookInfo?.filename} - Kindle文件 ✔`, // Subject line
        text: 'Kindle助手发来的文件', // plain text body
        html: `<b>发送时间:${startTime}</b>`, // html body
        attachments: [
            {
                filename: bookInfo?.filename,
                path: `${bookInfo?.dirPath}/${bookInfo?.filename}`,
            },
        ],
    };

    return new Promise((resolve, reject) => {
        transporter.sendMail(mailOptions, (error: any, info: any) => {
            if (error) {
                reject({
                  code: -1001,
                  success: false,
                  message: error.message,
                });
            } else {
                resolve({
                  code: 200,
                  success: true,
                });
            }
        });
    });
};

export default sendEmail;

上述代码中,我们使用nodemailer这个包来帮助我们完成邮件的发送,具体的设置可以去看一下nodemailer官网的说明文档,使用其实比较简单,这里就不做过多的讲解了,有兴趣的小伙伴可以自行去查看。

完成设置以及发送后,我们基本已经将这个软件的主要功能都已经全部开发完毕了。当然,我们还剩下打包、发布以及自动更新还没有做,这部分内容将在下一节给大家分享。

最后

本教程最终完成的工具是可以实际使用的,虽然这个教程写的比较简单,但麻雀虽小五脏俱全,这个教程包含了Electron应用的基本开发内容,包括主进程与渲染进程直接的通信、文件的操作、数据的下载及存储等内容,在实际的开发中,我们用到的大部分工具或方法实际在这个应用中都包含了。当然,我们这个应用还有很多可优化的空间,例如:切换页面后的数据保存,即Keep-Alive,图书下载的流程控制等。因为图书下载是基于某网盘的,而当用户没有开通会员时,只能一本一本的排队下载,因此这里也可以做相关的排队机制,这些就交给大家自行来完成了,后续我也会不断的去更新这个软件的,有兴趣的可以持续关注我们的这个应用,具体地址:KindleHelper

最后,本期的分享内容就到这里了,如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者,谢谢大家

往期回顾

手摸手,带你用Electron+React开发一款桌面应用(1)

手摸手,带你用Electron+React开发一款桌面应用(2)

手摸手,带你用Electron+React开发一款桌面应用(2.5)

基于Github Actions完成Electron自动打包、发布及更新