完整 JavaFX Todo List 应用教程

这是一个从零开始构建一个简单的“Smart Todo List”应用的完整教程,使用 JavaFX 实现。应用功能包括:添加、编辑、删除任务;标记完成;本地文件存储(简单文本格式,可扩展到 JSON)。我们假设您使用 IntelliJ IDEA、JDK 17 和 OpenJFX 17(因为您的环境配置如此)。如果您是初学者,我会一步步解释,包括代码和潜在问题修复。

前提条件

  1. 安装 JDK 17:从 Oracle 或 Adoptium 下载并安装。设置环境变量 JAVA_HOME 和 Path。
  2. 下载 OpenJFX 17:从 GluonHQ 下载 SDK(您的路径:D:\Program Files\openjfx-17.0.15_windows-x64_bin-sdk\javafx-sdk-17.0.15)。
  3. IntelliJ IDEA:社区版或 Ultimate。安装后,创建新 Java 项目。
  4. Scene Builder(可选但推荐):下载 Gluon Scene Builder 17,用于可视化编辑 FXML 文件。从 GluonHQ 获取。
  5. 项目设置
    • 创建新项目:File → New → Project → Java → Next → 命名 CAT201W
    • 添加模块:右键 src → New → module-info.java。
    • 配置 JavaFX:File → Project Structure → Libraries → + → Java → 选择 OpenJFX lib 目录下的所有 JAR(javafx-base.jar 等)。
    • 运行配置:Edit Configurations → Add → Application → Main class: mytodo.MainApp → VM options: --module-path "D:\Program Files\openjfx-17.0.15_windows-x64_bin-sdk\javafx-sdk-17.0.15\lib" --add-modules javafx.controls,javafx.fxml

如果遇到模块问题(如“程序包不可见”),参考后续修复。

步骤 1: 创建 Task 模型类

src/mytodo 包下创建 Task.java。这是任务的数据模型,使用简单 getter/setter。

package mytodo;

import java.time.LocalDate;

public class Task {

    private String title;
    private String description;
    private LocalDate dueDate;
    private String category;
    private String priority;
    private boolean completed;

    public Task(String title, String description, LocalDate dueDate, String category, String priority, boolean completed) {
        this.title = title;
        this.description = description;
        this.dueDate = dueDate;
        this.category = category;
        this.priority = priority;
        this.completed = completed;
    }

    public Task() { }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }

    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }

    public String getPriority() { return priority; }
    public void setPriority(String priority) { this.priority = priority; }

    public boolean isCompleted() { return completed; }
    public void setCompleted(boolean completed) { this.completed = completed; }
}
  • 解释:使用 LocalDate 处理日期。默认构造函数用于反射或空任务。

步骤 2: 创建 FXML 文件(UI 布局)

src/mytodo 下创建 main.fxml。使用 Scene Builder 拖拽组件,或直接复制以下代码。

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.DatePicker?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Menu?>
<?import javafx.scene.control.MenuBar?>
<?import javafx.scene.control.MenuItem?>
<?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.RowConstraints?>
<?import javafx.scene.layout.VBox?>

<BorderPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="mytodo.MainController">
   <top>
      <MenuBar prefHeight="0.0" prefWidth="607.0" BorderPane.alignment="CENTER">
        <menus>
          <Menu mnemonicParsing="false" text="File">
            <items>
              <MenuItem mnemonicParsing="false" text="Close" />
            </items>
          </Menu>
          <Menu mnemonicParsing="false" text="Edit">
            <items>
              <MenuItem mnemonicParsing="false" text="Delete" />
            </items>
          </Menu>
          <Menu mnemonicParsing="false" text="Help">
            <items>
              <MenuItem mnemonicParsing="false" text="About" />
            </items>
          </Menu>
        </menus>
      </MenuBar>
   </top>
   <right>
      <VBox prefHeight="375.0" prefWidth="145.0" BorderPane.alignment="CENTER" spacing="10.0">
         <children>
            <Button fx:id="addButton" mnemonicParsing="false" onAction="#onAddButtonClick" text="Add" />
            <Button fx:id="editButton" mnemonicParsing="false" onAction="#onEditButtonClick" text="Edit" />
            <Button fx:id="deleteButton" mnemonicParsing="false" onAction="#onDeleteButtonClick" text="Delete" />
            <Button fx:id="markCompletedButton" mnemonicParsing="false" onAction="#onMarkCompletedButtonClick" text="Mark Completed" />
         </children>
      </VBox>
   </right>
   <bottom>
      <GridPane hgap="10.0" vgap="10.0" BorderPane.alignment="CENTER">
        <columnConstraints>
          <ColumnConstraints hgrow="NEVER" minWidth="10.0" prefWidth="100.0" />
          <ColumnConstraints hgrow="ALWAYS" minWidth="10.0" prefWidth="300.0" />
        </columnConstraints>
        <rowConstraints>
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
        </rowConstraints>
         <children>
            <Label text="Title:" GridPane.rowIndex="0" />
            <Label text="Description:" GridPane.rowIndex="1" />
            <Label text="Due Date:" GridPane.rowIndex="2" />
            <Label text="Category:" GridPane.rowIndex="3" />
            <Label text="Priority:" GridPane.rowIndex="4" />
            <TextField fx:id="titleField" GridPane.columnIndex="1" GridPane.rowIndex="0" />
            <TextField fx:id="descriptionField" GridPane.columnIndex="1" GridPane.rowIndex="1" />
            <DatePicker fx:id="dueDatePicker" GridPane.columnIndex="1" GridPane.rowIndex="2" />
            <ComboBox fx:id="categoryCombo" prefWidth="150.0" GridPane.columnIndex="1" GridPane.rowIndex="3" />
            <ComboBox fx:id="priorityCombo" prefWidth="150.0" GridPane.columnIndex="1" GridPane.rowIndex="4" />
         </children>
      </GridPane>
   </bottom>
   <center>
      <TableView fx:id="taskTable" prefHeight="200.0" prefWidth="200.0" BorderPane.alignment="CENTER">
        <columns>
          <TableColumn fx:id="titleColumn" prefWidth="75.0" text="Title" />
          <TableColumn fx:id="descriptionColumn" prefWidth="75.0" text="Description" />
          <TableColumn fx:id="dueDateColumn" prefWidth="75.0" text="Due Date" />
          <TableColumn fx:id="categoryColumn" prefWidth="75.0" text="Category" />
          <TableColumn fx:id="priorityColumn" prefWidth="75.0" text="Priority" />
          <TableColumn fx:id="completedColumn" prefWidth="75.0" text="Completed" />
        </columns>
      </TableView>
   </center>
</BorderPane>
  • 解释:使用 BorderPane 布局。添加 fx:id 用于控制器绑定,onAction 链接按钮事件。xmlns 设为 17 以匹配运行时。

步骤 3: 创建 MainController 类

src/mytodo 下创建 MainController.java。这是 UI 逻辑,包括事件处理和数据存储。

package mytodo;

import java.time.LocalDate;
import java.util.List;
import java.nio.file.Files;
import java.nio.file.Path;

import javafx.fxml.FXML;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;

import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.DatePicker;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Button;
import javafx.scene.control.Alert;
import javafx.scene.control.cell.PropertyValueFactory;

public class MainController {

    // ====== TableView + Columns ======
    @FXML private TableView<Task> taskTable;
    @FXML private TableColumn<Task, String> titleColumn;
    @FXML private TableColumn<Task, String> descriptionColumn;
    @FXML private TableColumn<Task, LocalDate> dueDateColumn;
    @FXML private TableColumn<Task, String> categoryColumn;
    @FXML private TableColumn<Task, String> priorityColumn;
    @FXML private TableColumn<Task, Boolean> completedColumn;

    // ====== Form ======
    @FXML private TextField titleField;
    @FXML private TextField descriptionField;
    @FXML private DatePicker dueDatePicker;
    @FXML private ComboBox<String> categoryCombo;
    @FXML private ComboBox<String> priorityCombo;

    // ====== Buttons ======
    @FXML private Button addButton;
    @FXML private Button editButton;
    @FXML private Button deleteButton;
    @FXML private Button markCompletedButton;

    // ====== 数据结构 ======
    private final ObservableList<Task> taskList = FXCollections.observableArrayList();
    private FilteredList<Task> filteredTasks;
    private SortedList<Task> sortedTasks;

    // 先用一个简单文本文件代替 JSON(你可以自己改成真正 JSON)
    private final Path dataPath = Path.of("tasks.txt");

    @FXML
    private void initialize() {
        // 1. 列绑定(必须 + 和 Task 的 getter 名一致)
        titleColumn.setCellValueFactory(new PropertyValueFactory<>("title"));
        descriptionColumn.setCellValueFactory(new PropertyValueFactory<>("description"));
        dueDateColumn.setCellValueFactory(new PropertyValueFactory<>("dueDate"));
        categoryColumn.setCellValueFactory(new PropertyValueFactory<>("category"));
        priorityColumn.setCellValueFactory(new PropertyValueFactory<>("priority"));
        completedColumn.setCellValueFactory(new PropertyValueFactory<>("completed"));

        // 2. 下拉框初始值(按你自己喜好改)
        categoryCombo.setItems(FXCollections.observableArrayList(
                "Work", "Study", "Personal", "Other"
        ));
        priorityCombo.setItems(FXCollections.observableArrayList(
                "Low", "Medium", "High"
        ));

        // 3. FilteredList + SortedList
        filteredTasks = new FilteredList<>(taskList, task -> true);
        sortedTasks = new SortedList<>(filteredTasks);
        sortedTasks.comparatorProperty().bind(taskTable.comparatorProperty());
        taskTable.setItems(sortedTasks);

        // 4. 从本地文件加载一次数据
        loadTasksFromFile();
    }

    // ====== Add 按钮 ======
    @FXML
    private void onAddButtonClick() {
        String title = titleField.getText();
        String description = descriptionField.getText();
        LocalDate dueDate = dueDatePicker.getValue();
        String category = categoryCombo.getValue();
        String priority = priorityCombo.getValue();

        if (title == null || title.isBlank()) {
            showAlert("Validation Error", "Title cannot be empty.");
            return;
        }
        if (dueDate == null) {
            showAlert("Validation Error", "Please select a due date.");
            return;
        }

        Task newTask = new Task(title, description, dueDate, category, priority, false);
        taskList.add(newTask);
        clearForm();
        saveTasksToFile();   // 这里简单保存成文本格式
    }

    // ====== Edit 按钮(你可以在这基础上自己改写/简化) ======
    @FXML
    private void onEditButtonClick() {
        Task selected = taskTable.getSelectionModel().getSelectedItem();
        if (selected == null) {
            showAlert("No selection", "Select a task to edit.");
            return;
        }

        // 下面几行逻辑你可以自己重新写一遍,保证自己理解
        String title = titleField.getText();
        String description = descriptionField.getText();
        LocalDate dueDate = dueDatePicker.getValue();
        String category = categoryCombo.getValue();
        String priority = priorityCombo.getValue();

        if (title == null || title.isBlank() || dueDate == null) {
            showAlert("Validation Error", "Title and due date cannot be empty.");
            return;
        }

        selected.setTitle(title);
        selected.setDescription(description);
        selected.setDueDate(dueDate);
        selected.setCategory(category);
        selected.setPriority(priority);

        taskTable.refresh();
        clearForm();
        saveTasksToFile();
    }

    // ====== Delete 按钮 ======
    @FXML
    private void onDeleteButtonClick() {
        Task selected = taskTable.getSelectionModel().getSelectedItem();
        if (selected == null) {
            showAlert("No selection", "Select a task to delete.");
            return;
        }

        taskList.remove(selected);
        clearForm();
        saveTasksToFile();
    }

    // ====== Mark Completed 按钮 ======
    @FXML
    private void onMarkCompletedButtonClick() {
        Task selected = taskTable.getSelectionModel().getSelectedItem();
        if (selected == null) {
            showAlert("No selection", "Select a task to mark.");
            return;
        }

        selected.setCompleted(true);
        taskTable.refresh();
        saveTasksToFile();
    }

    // ====== 简单 I/O:用“|”分隔的文本,代替 JSON,逻辑你可以自己重写一遍 ======

    private void saveTasksToFile() {
        try {
            List<String> lines = taskList.stream()
                    .map(task -> String.join("|",
                            safe(task.getTitle()),
                            safe(task.getDescription()),
                            task.getDueDate() == null ? "" : task.getDueDate().toString(),
                            safe(task.getCategory()),
                            safe(task.getPriority()),
                            Boolean.toString(task.isCompleted())
                    ))
                    .toList();

            Files.write(dataPath, lines);
        } catch (Exception e) {
            e.printStackTrace();
            showAlert("Error", "Failed to save tasks.");
        }
    }

    private void loadTasksFromFile() {
        try {
            if (!Files.exists(dataPath)) {
                return;
            }
            List<String> lines = Files.readAllLines(dataPath);
            taskList.clear();
            for (String line : lines) {
                String[] parts = line.split("\\|", -1);
                if (parts.length < 6) continue;

                String title = parts[0];
                String description = parts[1];
                LocalDate dueDate = parts[2].isEmpty() ? null : LocalDate.parse(parts[2]);
                String category = parts[3];
                String priority = parts[4];
                boolean completed = Boolean.parseBoolean(parts[5]);

                Task t = new Task(title, description, dueDate, category, priority, completed);
                taskList.add(t);
            }
        } catch (Exception e) {
            e.printStackTrace();
            showAlert("Error", "Failed to load tasks.");
        }
    }

    private String safe(String s) {
        return s == null ? "" : s.replace("|", "/");
    }

    // ====== 小工具方法 ======

    private void clearForm() {
        titleField.clear();
        descriptionField.clear();
        dueDatePicker.setValue(null);
        categoryCombo.setValue(null);
        priorityCombo.setValue(null);
    }

    private void showAlert(String title, String message) {
        Alert alert = new Alert(Alert.AlertType.INFORMATION);
        alert.setTitle(title);
        alert.setHeaderText(null);
        alert.setContentText(message);
        alert.showAndWait();
    }
}
  • 解释
    • @FXML 注解注入 UI 元素。
    • initialize():设置表列、ComboBox、加载数据。
    • 按钮事件:添加/编辑/删除/标记,使用 ObservableList 自动更新 UI。
    • I/O:简单文本保存(tasks.txt 生成在项目根或 out 目录)。可扩展用 Gson 做 JSON。

步骤 4: 创建 MainApp 入口类

src/mytodo 下创建 MainApp.java

package mytodo;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class MainApp extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception {
        FXMLLoader loader = new FXMLLoader(
                getClass().getResource("main.fxml")
        );

        Scene scene = new Scene(loader.load());
        primaryStage.setTitle("Smart Todo List");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}
  • 解释:加载 FXML,设置场景。

步骤 5: 配置 module-info.java

src 根目录创建 module-info.java

module CAT201W {
    requires javafx.controls;
    requires javafx.fxml;
    exports mytodo;
    opens mytodo to javafx.fxml;  // 允许 FXML 反射访问
}
  • 解释requires 声明依赖;exports 暴露包;opens 解决私有字段注入问题(常见错误:InaccessibleObjectException)。

步骤 6: 运行和调试

  1. 编译:Build → Build Project。
  2. 运行:右键 MainApp → Run。
  3. 测试
    • 添加任务:填写 form,点击 Add → 表更新,文件保存。
    • 编辑:选中行,修改 form,点击 Edit。
    • 删除/标记:选中后点击。
    • 重启:数据从 tasks.txt 加载。
  4. 常见错误修复
    • 程序包不可见:添加 requires javafx.fxml;
    • InaccessibleObjectException:添加 opens mytodo to javafx.fxml;
    • FXML 版本警告:FXML xmlns 设为 17,或升级 JavaFX。
    • NullPointerException:检查 fx:id 拼写匹配 @FXML 字段。
    • 文件 I/O 失败:检查路径权限;dataPath 可改为绝对路径如 Path.of(System.getProperty("user.home") + "/tasks.txt");
    • 表不显示:确保 getter 名匹配 PropertyValueFactory。

步骤 7: 扩展建议

  • 添加过滤:在 FilteredList 中添加谓词(如基于 category)。
  • JSON 存储:添加 Gson 依赖(Maven: com.google.code.gson:gson),替换 I/O 方法。
  • 选中填充 form:在 initialize() 添加 taskTable.getSelectionModel().selectedItemProperty().addListener((obs, old, new) -> { if (new != null) { titleField.setText(new.getTitle()); ... } });
  • 样式:添加 CSS 文件到 Scene。
  • 打包:用 jpackage 或 Maven Shade 打包成可执行 JAR。

这个教程基于您的代码和错误,提供完整、可运行版本。如果遇到具体问题,提供日志,我可以进一步优化!

写文章用