electron-react-boilerplate 模版项目介绍 | 快速构建跨平台Todo待办事项桌面应用


前言

在本教程中,我们将使用 Electron React Boilerplate(ERB)和 Ant Design 来构建一个简单的 Todo 待办事项软件。Electron 是一个允许你使用 HTML、CSS 和 JavaScript 构建跨平台桌面应用的框架。而 React 是一个用于构建用户界面的 JavaScript 库,它能够让你创建可复用的组件。

准备工作

在开始之前,请确保你已经安装了 Node.js 和 npm。然后,我们将克隆示例项目

git clone https://github.com/couchette/coudos.git
cd coudos

现在,让我们安装项目依赖

npm install

结构分析

UI 结构

右侧导航栏显示任务表标题,在右侧显示该任务表下的待办事项,并提供一个可以添加待办事项的输入口。
请添加图片描述

数据结构

由于代表事项将围绕一个大的数据主体进行修改和渲染,设计数据结构如下
请添加图片描述

编写代码

项目源码见: coudos

1. 创建数据

按照设计创建数据,代码位于 plan.tsx

import React, { useState, useEffect } from 'react';

export interface TaskDataInterface {
  task: string;
  status: string;
}

interface TaskTableDataInterface {
  name: string;
  tasks: TaskDataInterface[];
}

interface PlanDataInterface {
  userId: string;
  taskTables: TaskTableDataInterface[];
}

export function Plan() {
  var planJsonString = localStorage.getItem('myPlan');
  var planObject;
  if (planJsonString && JSON.parse(planJsonString)) {
    planObject = JSON.parse(planJsonString);
  } else {
    planObject = {
      userId: '',
      version: '0.0.1',
      taskTables: [
        {
          version: '0.0.1',
          name: 'Hello todo-gpt',
          key: '0',
          tasks: [
            { key: '0', status: ['incomplete'], task: 'add your first task' },
          ],
        },
      ],
    };
  }
  const [plan, setPlan] = useState<PlanDataInterface>(planObject);
  const [displayedTaskTableName, setDisplayedTaskTableName] =
    useState<string>('');

  // 由于拖拽api需要适用于原生set函数因此,将displayTaskTableTasks独立出来并用useEffect同步数据
  const [displayTaskTableTasks, setDisplayTaskTableTasks] = useState([]);

  useEffect(() => {
    localStorage.setItem('myPlan', JSON.stringify(plan));
  }, [plan]);

  useEffect(() => {
    if (displayedTaskTableName !== '') {
      setDisplayTaskTableTasks(getDisplayedTaskTable().tasks);
    } else {
      setDisplayTaskTableTasks([]);
    }
  }, [displayedTaskTableName]);

  useEffect(() => {
    if (displayedTaskTableName !== '') {
      const planTemp = { ...plan };
      const index = planTemp.taskTables.findIndex(
        (taskTable) => taskTable.name === displayedTaskTableName,
      );
      if (index !== -1) {
        if (plan.taskTables[index].tasks !== displayTaskTableTasks) {
          planTemp.taskTables[index].tasks = displayTaskTableTasks;
          setPlan(planTemp);
        } else {
        }
      } else {
        console.log(
          '未找到name为' + displayedTaskTableName + '的tasktable对象',
        );
      }
    }
  }, [displayTaskTableTasks]);

  function getTaskTableNames() {
    const planTemp = { ...plan };
    return planTemp.taskTables.map((taskTable) => {
      return taskTable.name;
    });
  }

  function selectDisplayedTaskTable(taskTableName: string) {
    const planTemp = { ...plan };

    const index = planTemp.taskTables.findIndex(
      (taskTable) => taskTable.name === taskTableName,
    );
    setDisplayedTaskTableName(taskTableName);
  }

  function getDisplayedTaskTableName() {
    return displayedTaskTableName;
  }

  function getPlan() {
    return plan;
  }

  function getDisplayedTaskTable() {
    const planTemp = { ...plan };

    const index = planTemp.taskTables.findIndex(
      (taskTable) => taskTable.name === displayedTaskTableName,
    );

    if (index !== -1) {
      return planTemp.taskTables[index];
    } else {
      console.log('未找到name为' + displayedTaskTableName + '的tasktable对象');
      return { name: '', tasks: [] };
    }
  }

  function generateUniqueKey() {
    return Math.random().toString(36).substr(2, 9);
  }

  function addTaskTable(newTaskTableName: string) {
    const updatedPlan = { ...plan };
    const isExists = updatedPlan.taskTables.some(
      (taskTable) => taskTable.name === newTaskTableName,
    );

    if (!isExists) {
      const newTaskTable = {
        version: '0.0.1',
        key: generateUniqueKey(),
        name: newTaskTableName,
        tasks: [],
      };
      updatedPlan.taskTables.push(newTaskTable);
      setPlan(updatedPlan);
    } else {
      console.log('名为' + newTaskTableName + '的tasktable已存在');
    }
  }

  function setTaskTableName(
    oldTaskTableName: string,
    newTaskTableName: string,
  ) {
    console.log('oldTaskTableName', oldTaskTableName);
    console.log('newTaskTableName', newTaskTableName);
    const updatedPlan = { ...plan };

    const index = updatedPlan.taskTables.findIndex(
      (taskTable) => taskTable.name === oldTaskTableName,
    );

    if (index !== -1) {
      const isExists = updatedPlan.taskTables.some(
        (taskTable) => taskTable.name === newTaskTableName,
      );
      if (isExists) {
        console.log('已存在name为' + newTaskTableName + '的tasktable对象');
      } else {
        updatedPlan.taskTables[index].name = newTaskTableName;
        setPlan(updatedPlan);
      }
    } else {
      console.log('未找到name为' + oldTaskTableName + '的tasktable对象');
    }
  }

  function deleteTaskTable(taskTableName: string) {
    const updatedPlan = { ...plan };
    const index = updatedPlan.taskTables.findIndex(
      (taskTable) => taskTable.name === taskTableName,
    );

    if (index !== -1) {
      updatedPlan.taskTables.splice(index, 1);
      setPlan(updatedPlan);
      setDisplayedTaskTableName('');
    } else {
      console.log('未找到name为' + taskTableName + '的tasktable对象');
    }
  }

  return {
    getPlan,
    setPlan,
    addTaskTable,
    setTaskTableName,
    deleteTaskTable,
    selectDisplayedTaskTable,
    displayTaskTableTasks,
    setDisplayTaskTableTasks,
    getDisplayedTaskTable,
    getDisplayedTaskTableName,
    getTaskTableNames,
  };
}

export interface PlanInterface {
  addTaskTable: (newTaskTableName: string) => void;
  setTaskTableName: (
    oldTaskTableName: string,
    newTaskTableName: string,
  ) => void;
  deleteTaskTable: (taskTableName: string) => void;
  selectDisplayedTaskTable: (taskTableName: string) => void;
  setDisplayTaskTableTasks: (newTasks: TaskDataInterface[]) => void;
  getDisplayedTaskTable: () => TaskTableDataInterface;
  getDisplayedTaskTableName: () => string;
  getTaskTableNames: () => string[];
}

2.使用antd创建导航菜单组件

antd 示例代码位于 导航菜单
以下代码位于 index.tsx 该代码获取tasktables标题,渲染为导航菜单,并创建一个添加tasktable的按钮和弹窗用于创建tasktable。

import React, { useEffect, useState } from 'react';
import { Menu, Button, Row, Col, Dropdown } from 'antd';
import { PageContext } from '../../Page';
import NavMenuItem from './NavMenuItem';
import { PlusCircleFilled, CommentOutlined } from '@ant-design/icons';
import { CreateTaskTablePopup } from './Popup';
import { PlanInterface } from '../plan';

export const NavigationMenu: React.FC<{
  user: any;
  subject: PlanInterface;
}> = ({ user, subject }) => {
  const [createTaskTablePopupVisible, setCreateTaskTablePopupVisible] =
    useState(false);
  const { messageApi, intl } = React.useContext(PageContext);

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        width: '20%',
        minWidth: '150px',
      }}
    >
      <Menu
        onClick={(e) => {
          subject.selectDisplayedTaskTable(e.key);
        }}
        selectedKeys={[subject.getDisplayedTaskTable().name]}
        style={{
          borderRadius: '5px',
          overflow: 'auto',
          flex: 1,
        }}
        defaultSelectedKeys={['1']}
        defaultOpenKeys={['sub1']}
        mode="inline"
      >
        <Menu.SubMenu
          key={'sub1'}
          title={intl.formatMessage({ id: 'plan' })}
          icon={<CommentOutlined />}
        >
          <Menu.ItemGroup
            key={'g1'}
            title={intl.formatMessage({ id: 'recently' })}
          >
            {subject.getTaskTableNames().map((name) => (
              <Menu.Item key={name}>
                <NavMenuItem
                  title={name}
                  setTitle={(option) => {
                    if (option !== null) {
                      subject.setTaskTableName(name, option);
                      subject.selectDisplayedTaskTable(option);
                    } else {
                      subject.deleteTaskTable(name);
                      subject.selectDisplayedTaskTable('');
                    }
                  }}
                />
              </Menu.Item>
            ))}
          </Menu.ItemGroup>
        </Menu.SubMenu>
      </Menu>
      <Button
        onClick={() => {
          setCreateTaskTablePopupVisible(true);
        }}
      >
        <Row align={'middle'} justify={'center'}>
          <Col className="gutter-row" span={24}>
            <div>
              <PlusCircleFilled />
            </div>
          </Col>
        </Row>
      </Button>

      <CreateTaskTablePopup
        visible={createTaskTablePopupVisible}
        setVisible={setCreateTaskTablePopupVisible}
        createTaskTableCallback={(newTaskTableName) => {
          subject.addTaskTable(newTaskTableName);
          subject.selectDisplayedTaskTable(newTaskTableName);
        }}
      />
    </div>
  );
};

3. 使用antd创建TodoBoard组件

antd 示例代码位于 表格
以下代码位于 index.tsx 该代码获取当前被选中的tasktable数据并渲染,创建了一个输入组件用于添加新的待办事项。

import React, { useState, useRef, useEffect } from 'react';
import './index.css';
import { PageContext } from '../../Page';
import {
  Dropdown,
  Upload,
  Input,
  Tag,
  Popconfirm,
  Button,
  Table as AntTable,
} from 'antd';
import DraggableTodoItem, { EditableCell } from './TodoItem';
import {
  PlusOutlined,
  DownloadOutlined,
  UploadOutlined,
  GlobalOutlined,
} from '@ant-design/icons';
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { DndContext } from '@dnd-kit/core';

import { UploadFilePopup } from './UploadFilePopup';

function TodoBoard({ subject, setUserSelectedLanguage }) {
  const [parentHeight, setParentHeight] = useState(0);
  const ref = useRef(null);
  const { messageApi, intl } = React.useContext(PageContext);
  const [uploadFilePopupVisible, setUploadFilePopupVisible] = useState(false);

  useEffect(() => {
    setParentHeight(ref.current.offsetHeight);
  }, []);

  const [inputValue, setInputValue] = useState('');
  function generateUniqueKey() {
    return Math.random().toString(36).substr(2, 9);
  }
  const handleDelete = (key) => {
    const newData = subject
      .getDisplayedTaskTable()
      .tasks.filter((item) => item.key !== key);
    subject.setDisplayTaskTableTasks(newData);
  };

  const handleAdd = (content) => {
    const newData = {
      key: generateUniqueKey(),
      task: content,
      status: ['incomplete'],
    };
    subject.setDisplayTaskTableTasks([
      ...subject.displayTaskTableTasks,
      newData,
    ]);
  };

  const handleToggle = (key) => {
    const prev = [...subject.displayTaskTableTasks];
    subject.setDisplayTaskTableTasks(
      prev.map((obj) => {
        if (obj.key === key) {
          if (!obj.status.includes('completed'))
            return {
              ...obj,
              status: ['completed'],
            };
          else
            return {
              ...obj,
              status: ['incomplete'],
            };
        } else {
          return obj;
        }
      }),
    );
  };

  const defaultColumns = [
    {
      key: 'sort',
    },
    {
      title: intl.formatMessage({ id: 'Task' }),
      dataIndex: 'task',
      key: 'task',
      editable: true,
    },
    {
      title: intl.formatMessage({ id: 'Status' }),
      key: 'status',
      dataIndex: 'status',
      filters: [
        {
          text: 'incomplete',
          value: 'incomplete',
        },
        {
          text: 'completed',
          value: 'completed',
        },
      ],
      filterSearch: true,
      onFilter: (value, record) => record.status.includes(value),
      render: (status) => (
        <span>
          {status.map((item) => {
            let color = 'geekblue';
            if (item === 'incomplete') {
              color = 'volcano';
            } else if (item === 'completed') {
              color = 'green';
            } else {
            }
            return (
              <Tag color={color} key={item}>
                {item.toUpperCase()}
              </Tag>
            );
          })}
        </span>
      ),
    },
    {
      title: intl.formatMessage({ id: 'Action' }),
      dataIndex: '',
      key: 'x',
      render: (_, record) =>
        subject.displayTaskTableTasks.length >= 1 ? (
          <div>
            <Button onClick={() => handleToggle(record.key)}>
              {intl.formatMessage({ id: 'Toggle' })}
            </Button>
            <Popconfirm
              title={intl.formatMessage({ id: 'Sure to delete?' })}
              onConfirm={() => handleDelete(record.key)}
            >
              <Button>{intl.formatMessage({ id: 'Delete' })}</Button>
            </Popconfirm>
          </div>
        ) : null,
    },
  ];
  let selectedTodoItemKeys = [];
  const [selectionType, setSelectionType] = useState('checkbox');

  const handleSave = (row) => {
    const newData = [...subject.displayTaskTableTasks];
    const index = newData.findIndex((item) => row.key === item.key);
    const item = newData[index];
    newData.splice(index, 1, {
      ...item,
      ...row,
    });
    subject.setDisplayTaskTableTasks(newData);
  };

  const onDragEnd = ({ active, over }) => {
    if (active.id !== over?.id) {
      subject.setDisplayTaskTableTasks((previous) => {
        const activeIndex = previous.findIndex((i) => i.key === active.id);
        const overIndex = previous.findIndex((i) => i.key === over?.id);
        return arrayMove(previous, activeIndex, overIndex);
      });
    }
  };

  const components = {
    body: {
      row: DraggableTodoItem,
      cell: EditableCell,
    },
  };

  const columns = defaultColumns.map((col) => {
    if (!col.editable) {
      return col;
    }
    return {
      ...col,
      onCell: (record) => ({
        record,
        editable: col.editable,
        dataIndex: col.dataIndex,
        title: col.title,
        handleSave,
      }),
    };
  });

  const TodoItemSelection = {
    onChange: (selectedRowKeys, selectedRows) => {
      selectedTodoItemKeys = selectedRowKeys;
      console.log(
        `selectedTodoItemKeys: ${selectedRowKeys}`,
        'selectedTodoItems: ',
        selectedRows,
      );
    },
    getCheckboxProps: (record) => ({
      disabled: record.name === 'Disabled User',
      // Column configuration not to be checked
      name: record.name,
    }),
  };

  const items = [
    {
      key: 'en',
      label: 'en',
    },
    {
      key: 'zh',
      label: 'zh',
    },
  ];
  const handleMenuClick = (e) => {
    setUserSelectedLanguage(e.key);
  };

  const menuProps = {
    items,
    onClick: handleMenuClick,
  };

  return (
    <div
      ref={ref}
      style={{
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        height: '100%',
        width: '100%',
      }}
    >
      {subject.getDisplayedTaskTableName !== '' &&
      subject.displayTaskTableTasks.length > 0 ? (
        <DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>
          <SortableContext
            // rowKey array
            items={subject.displayTaskTableTasks.map((i) => i.key)}
            strategy={verticalListSortingStrategy}
          >
            <AntTable
              rowSelection={{
                type: selectionType,
                ...TodoItemSelection,
              }}
              columns={columns}
              dataSource={subject.displayTaskTableTasks}
              components={components}
              rowKey="key"
              pagination={false}
              scroll={{
                y: 0.8 * parentHeight,
              }}
            />
          </SortableContext>
        </DndContext>
      ) : (
        <div />
      )}
      <div
        style={{
          marginTop: 'auto',
          height: '10%',
        }}
      >
        <div
          style={{
            display: 'flex',
            height: 'auto',
            flexDirection: 'row-reverse',
          }}
        >
          <Button
            icon={
              <DownloadOutlined
                onClick={() => {
                  const jsonDataStr = JSON.stringify(
                    subject.getPlan(),
                    null,
                    2,
                  );
                  const blob = new Blob([jsonDataStr], {
                    type: 'application/json',
                  });
                  const url = URL.createObjectURL(blob);

                  const a = document.createElement('a');
                  a.href = url;
                  a.download = 'plan.json';
                  a.click();

                  URL.revokeObjectURL(url);
                }}
              />
            }
          />

          <Button
            icon={<UploadOutlined />}
            onClick={() => {
              setUploadFilePopupVisible(true);
            }}
          />

          <Dropdown menu={menuProps} placement="top">
            <Button icon={<GlobalOutlined />} />
          </Dropdown>
        </div>
        <Input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Add task"
          onPressEnter={(e) => {
            if (
              inputValue !== '' &&
              subject.getDisplayedTaskTableName() !== ''
            ) {
              handleAdd(inputValue);
              setInputValue('');
            }
          }}
          prefix={<PlusOutlined />}
        />
      </div>
      <UploadFilePopup
        visible={uploadFilePopupVisible}
        setVisible={setUploadFilePopupVisible}
        setDataCallback={subject.setPlan}
      />
    </div>
  );
}

export default TodoBoard;

4. 运行应用程序

现在,我们的应用程序已经准备就绪。运行以下命令启动应用程序:

npm start

你将会看到一个简单的待办事项列表应用程序如下:
请添加图片描述


结语

通过使用 Electron React Boilerplate 和 Ant Design,我们创建了一个简单的 Todo 应用程序。你可以根据自己的需要扩展这个应用程序,并添加更多的功能和样式。祝你编程愉快!

以上就是使用 Electron React Boilerplate 和 Ant Design 构建 Todo 应用程序的简单教程。希望对你有所帮助!

  • 16
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

买药弟弟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值