前言
在本教程中,我们将使用 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 应用程序的简单教程。希望对你有所帮助!