entries;
+
+ @PostConstruct
+ private void postConstruct() {
+ entries = table.getItems();
+ }
+}
diff --git a/src/main/java/com/imyeyu/hosts/service/HostsFileReader.java b/src/main/java/com/imyeyu/hosts/service/HostsFileReader.java
new file mode 100644
index 0000000..de25bf6
--- /dev/null
+++ b/src/main/java/com/imyeyu/hosts/service/HostsFileReader.java
@@ -0,0 +1,59 @@
+package com.imyeyu.hosts.service;
+
+import com.imyeyu.hosts.model.Host;
+import com.imyeyu.hosts.util.HostsParser;
+import com.imyeyu.inject.annotation.Component;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+/**
+ * Hosts 文件读取器
+ *
+ * 负责从系统 hosts 文件中读取并解析 hosts 映射
+ *
+ * @author 夜雨
+ * @since 2026-01-13 17:20
+ */
+@Slf4j
+@Component
+public class HostsFileReader {
+
+ /**
+ * 读取并解析系统 hosts 文件
+ *
+ * @return Host 对象列表
+ * @throws IOException 文件读取失败时抛出
+ */
+ public List read() throws IOException {
+ Path hostsPath = getHostsFilePath();
+ log.info("read hosts file: {}", hostsPath);
+
+ List lines = Files.readAllLines(hostsPath, StandardCharsets.UTF_8);
+ List hosts = HostsParser.parse(lines);
+
+ log.info("read {} hosts mapping", hosts.size());
+ return hosts;
+ }
+
+ /**
+ * 获取系统 hosts 文件路径
+ *
+ * @return hosts 文件路径
+ */
+ private Path getHostsFilePath() {
+ String os = System.getProperty("os.name").toLowerCase();
+ if (os.contains("win")) {
+ // Windows 系统
+ return Paths.get(System.getenv("SystemRoot"), "System32", "drivers", "etc", "hosts");
+ } else {
+ // Linux/Mac 系统
+ return Paths.get("/etc/hosts");
+ }
+ }
+}
diff --git a/src/main/java/com/imyeyu/hosts/service/HostsFileWriter.java b/src/main/java/com/imyeyu/hosts/service/HostsFileWriter.java
new file mode 100644
index 0000000..3332eea
--- /dev/null
+++ b/src/main/java/com/imyeyu/hosts/service/HostsFileWriter.java
@@ -0,0 +1,67 @@
+package com.imyeyu.hosts.service;
+
+import com.imyeyu.hosts.model.Host;
+import com.imyeyu.hosts.util.HostsParser;
+import com.imyeyu.inject.annotation.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * Hosts 文件写入器
+ *
+ * 负责将 hosts 映射写入到系统 hosts 文件
+ *
+ * @author 夜雨
+ * @since 2025-01-13
+ */
+@Component
+public class HostsFileWriter {
+
+ private static final Logger log = LoggerFactory.getLogger(HostsFileWriter.class);
+ private static final DateTimeFormatter BACKUP_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ /**
+ * 将 Host 列表写入系统 hosts 文件
+ *
+ * @param hosts Host 对象列表
+ * @throws IOException 文件写入失败时抛出
+ */
+ public void write(List hosts) throws IOException {
+ Path hostsPath = getHostsFilePath();
+ // 格式化为文本行
+ List lines = HostsParser.format(hosts);
+ // 写入文件
+ try {
+ Files.write(hostsPath, lines, StandardCharsets.UTF_8);
+ log.info("wrote {} hosts mapping", hosts.size());
+ } catch (AccessDeniedException e) {
+ log.error("wrote hosts fail for access denied", e);
+ throw new IOException("权限不足,请以管理员身份运行本程序", e);
+ }
+ }
+
+ /**
+ * 获取系统 hosts 文件路径
+ *
+ * @return hosts 文件路径
+ */
+ private Path getHostsFilePath() {
+ String os = System.getProperty("os.name").toLowerCase();
+ if (os.contains("win")) {
+ // Windows 系统
+ return Paths.get(System.getenv("SystemRoot"), "System32", "drivers", "etc", "hosts");
+ } else {
+ // Linux/Mac 系统
+ return Paths.get("/etc/hosts");
+ }
+ }
+}
diff --git a/src/main/java/com/imyeyu/hosts/ui/FXApplication.java b/src/main/java/com/imyeyu/hosts/ui/FXApplication.java
new file mode 100644
index 0000000..a5ecf49
--- /dev/null
+++ b/src/main/java/com/imyeyu/hosts/ui/FXApplication.java
@@ -0,0 +1,34 @@
+package com.imyeyu.hosts.ui;
+
+import com.imyeyu.fx.inject.FXTimiInject;
+import com.imyeyu.fx.ui.TimiFXUI;
+import com.imyeyu.hosts.ui.ctrl.Main;
+import com.imyeyu.inject.annotation.Inject;
+import com.imyeyu.inject.annotation.TimiInjectApplication;
+import javafx.application.Application;
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+
+/**
+ * TimiHosts 应用入口。
+ *
+ * @author 夜雨
+ * @since 2026-01-13 16:06
+ */
+@TimiInjectApplication("com.imyeyu.hosts")
+public class FXApplication extends Application implements TimiFXUI {
+
+ @Inject
+ private Main main;
+
+ @Override
+ public void start(Stage stage) {
+ FXTimiInject.run(FXApplication.class, this, stage);
+
+ Scene scene = new Scene(main, 900, 560);
+ scene.getStylesheets().addAll(CSS_STYLE, CSS_FONT, "style.css");
+ stage.setTitle("TimiHosts");
+ stage.setScene(scene);
+ stage.show();
+ }
+}
diff --git a/src/main/java/com/imyeyu/hosts/ui/components/HostTable.java b/src/main/java/com/imyeyu/hosts/ui/components/HostTable.java
new file mode 100644
index 0000000..8f3f850
--- /dev/null
+++ b/src/main/java/com/imyeyu/hosts/ui/components/HostTable.java
@@ -0,0 +1,260 @@
+package com.imyeyu.hosts.ui.components;
+
+import com.imyeyu.fx.icon.TimiFXIcon;
+import com.imyeyu.fx.ui.TimiFXUI;
+import com.imyeyu.fx.ui.components.IconButton;
+import com.imyeyu.hosts.model.Host;
+import com.imyeyu.inject.annotation.Component;
+import com.imyeyu.inject.annotation.PostConstruct;
+import com.imyeyu.lang.multi.ResourcesMultilingual;
+import javafx.beans.binding.DoubleBinding;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.StringProperty;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.TableCell;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.control.TextField;
+import javafx.scene.control.cell.PropertyValueFactory;
+import javafx.scene.input.ScrollEvent;
+import lombok.RequiredArgsConstructor;
+
+/**
+ *
+ * @author 夜雨
+ * @since 2022-04-25 18:04
+ */
+@Component
+@RequiredArgsConstructor
+public class HostTable extends TableView implements TimiFXUI {
+
+ private final ResourcesMultilingual multilingual;
+
+ @PostConstruct
+ private void postConstruct() {
+ // 选择
+ TableColumn activated = new TableColumn<>(multilingual.text("main.activated"));
+ activated.setCellValueFactory(new PropertyValueFactory<>("activated"));
+ activated.setPrefWidth(60);
+ activated.setSortable(false);
+ activated.setCellFactory(cell -> new TableCell<>() {
+
+ private final CheckBox checkBox;
+ private BooleanProperty nowBind; // 当前绑定
+
+ {
+ checkBox = new CheckBox();
+ checkBox.focusedProperty().addListener((obs, o, isFocused) -> {
+ if (isFocused) {
+ getSelectionModel().clearAndSelect(getIndex());
+ }
+ });
+ setAlignment(Pos.CENTER);
+ }
+
+ @Override
+ protected void updateItem(Boolean item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) {
+ setGraphic(null);
+ } else {
+ // 动态绑定
+ BooleanProperty thisBind = getTableRow().getItem().activatedProperty();
+ if (nowBind == null) {
+ nowBind = thisBind;
+ checkBox.selectedProperty().bindBidirectional(nowBind);
+ } else {
+ if (nowBind != thisBind) {
+ checkBox.selectedProperty().unbindBidirectional(nowBind);
+ nowBind = thisBind;
+ checkBox.selectedProperty().bindBidirectional(nowBind);
+ }
+ }
+ setGraphic(checkBox);
+ }
+ }
+ });
+ getColumns().add(activated);
+ // IP
+ TableColumn ip = new TableColumn<>("IP");
+ ip.setCellValueFactory(new PropertyValueFactory<>("ip"));
+ ip.setSortable(false);
+ ip.setPrefWidth(140);
+ ip.setCellFactory(cell -> new TableCell<>() {
+
+ private final TextField tf;
+ private StringProperty nowBind; // 当前绑定
+
+ {
+ tf = new TextField();
+ tf.getStyleClass().add(CSS.BORDER_N);
+ tf.focusedProperty().addListener((obs, o, isFocused) -> {
+ if (isFocused) {
+ getSelectionModel().clearAndSelect(getIndex());
+ }
+ });
+ setPadding(Insets.EMPTY);
+ }
+
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) {
+ setGraphic(null);
+ } else {
+ // 动态绑定
+ StringProperty thisBind = getTableRow().getItem().ipProperty();
+ if (nowBind == null) {
+ nowBind = thisBind;
+ tf.textProperty().bindBidirectional(nowBind);
+ } else {
+ if (nowBind != thisBind) {
+ tf.textProperty().unbindBidirectional(nowBind);
+ nowBind = thisBind;
+ tf.textProperty().bindBidirectional(nowBind);
+ }
+ }
+ setGraphic(tf);
+ }
+ }
+ });
+ getColumns().add(ip);
+ // 域名列表
+ TableColumn hosts = new TableColumn<>(multilingual.text("main.host"));
+ hosts.setCellValueFactory(new PropertyValueFactory<>("host"));
+ hosts.setSortable(false);
+ hosts.setCellFactory(cell -> new TableCell<>() {
+
+ private final TextField tf;
+ private StringProperty nowBind; // 当前绑定
+
+ {
+ tf = new TextField();
+ tf.getStyleClass().add(CSS.BORDER_N);
+ tf.focusedProperty().addListener((obs, o, isFocused) -> {
+ if (isFocused) {
+ getSelectionModel().clearAndSelect(getIndex());
+ }
+ });
+ setPadding(Insets.EMPTY);
+ }
+
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) {
+ setGraphic(null);
+ } else {
+ // 动态绑定
+ StringProperty thisBind = getTableRow().getItem().hostProperty();
+ if (nowBind == null) {
+ nowBind = thisBind;
+ tf.textProperty().bindBidirectional(nowBind);
+ } else {
+ if (nowBind != thisBind) {
+ tf.textProperty().unbindBidirectional(nowBind);
+ nowBind = thisBind;
+ tf.textProperty().bindBidirectional(nowBind);
+ }
+ }
+ setGraphic(tf);
+ }
+ }
+ });
+ getColumns().add(hosts);
+
+ // 说明
+ TableColumn description = new TableColumn<>(multilingual.text("main.description"));
+ description.setCellValueFactory(new PropertyValueFactory<>("description"));
+ description.setSortable(false);
+ description.setPrefWidth(260);
+ description.setCellFactory(cell -> new TableCell<>() {
+
+ private final TextField tf;
+ private StringProperty nowBind; // 当前绑定
+
+ {
+ tf = new TextField();
+ tf.getStyleClass().add(CSS.BORDER_N);
+ tf.focusedProperty().addListener((obs, o, isFocused) -> {
+ if (isFocused) {
+ getSelectionModel().clearAndSelect(getIndex());
+ }
+ });
+ setPadding(Insets.EMPTY);
+ }
+
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) {
+ setGraphic(null);
+ } else {
+ // 动态绑定
+ StringProperty thisBind = getTableRow().getItem().descriptionProperty();
+ if (nowBind == null) {
+ nowBind = thisBind;
+ tf.textProperty().bindBidirectional(nowBind);
+ } else {
+ if (nowBind != thisBind) {
+ tf.textProperty().unbindBidirectional(nowBind);
+ nowBind = thisBind;
+ tf.textProperty().bindBidirectional(nowBind);
+ }
+ }
+ setGraphic(tf);
+ }
+ }
+ });
+ getColumns().add(description);
+
+ // 删除列
+ TableColumn delete = new TableColumn<>();
+ delete.setPrefWidth(32);
+ delete.setSortable(false);
+ delete.setResizable(false);
+ delete.setReorderable(false);
+ delete.setCellFactory(cell -> new TableCell<>() {
+
+ private final IconButton remove;
+
+ {
+ remove = new IconButton(TimiFXIcon.fromName("FAIL", Colorful.RED)).autoSize();
+ remove.setOnAction(e -> getItems().remove(getTableRow().getItem()));
+ setAlignment(Pos.CENTER);
+ }
+
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || getTableRow() == null || getTableRow().getItem() == null) {
+ setGraphic(null);
+ } else {
+ if (remove.visibleProperty().isBound()) {
+ remove.visibleProperty().unbind();
+ }
+ remove.visibleProperty().bind(getTableRow().hoverProperty());
+ setGraphic(remove);
+ }
+ }
+ });
+ getColumns().add(delete);
+
+ DoubleBinding db = widthProperty().subtract(8);
+ db = db.subtract(activated.widthProperty());
+ db = db.subtract(ip.widthProperty());
+ db = db.subtract(description.widthProperty());
+ db = db.subtract(delete.widthProperty());
+ hosts.prefWidthProperty().bind(db);
+
+ getStyleClass().addAll(CSS.BORDER_N, CSS.EDITABLE_TABLE, "host-table");
+
+ // ---------- 事件 ----------
+
+ // 滚动时编辑区主动失去焦点
+ addEventFilter(ScrollEvent.ANY, e -> requestFocus());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/imyeyu/hosts/ui/ctrl/Main.java b/src/main/java/com/imyeyu/hosts/ui/ctrl/Main.java
new file mode 100644
index 0000000..f94123e
--- /dev/null
+++ b/src/main/java/com/imyeyu/hosts/ui/ctrl/Main.java
@@ -0,0 +1,90 @@
+package com.imyeyu.hosts.ui.ctrl;
+
+import com.imyeyu.hosts.config.Config;
+import com.imyeyu.hosts.model.Host;
+import com.imyeyu.hosts.service.HostService;
+import com.imyeyu.hosts.ui.view.MainView;
+import com.imyeyu.inject.annotation.Component;
+import com.imyeyu.inject.annotation.PostConstruct;
+import com.imyeyu.java.bean.Callback;
+import javafx.collections.ListChangeListener;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.NoSuchFileException;
+
+/**
+ * 主控制器
+ *
+ * @author 夜雨
+ * @since 2026-01-13 10:41
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class Main extends MainView {
+
+ private final Config config;
+
+ private final HostService hostService;
+
+ /**
+ * 初始化控制器
+ */
+ @PostConstruct
+ private void init() {
+ add.setOnAction(e -> table.getItems().add(new Host()));
+ // 绑定保存按钮事件
+ save.setOnAction(e -> {
+ try {
+ int count = hostService.save();
+ labelTips.info("已保存 %d 条映射".formatted(count));
+ } catch (AccessDeniedException ex) {
+ log.error("写入 hosts 失败: 权限不足", ex);
+ labelTips.error("保存失败: 权限不足,请以管理员身份运行");
+ } catch (IOException ex) {
+ log.error("写入 hosts 失败", ex);
+ labelTips.error("保存失败: %s".formatted(ex.getMessage()));
+ }
+ });
+ // 加载初始数据
+ try {
+ int count = hostService.refresh();
+ labelTips.info("已加载 %d 条映射".formatted(count));
+ } catch (AccessDeniedException e) {
+ log.error("读取 hosts 失败: 权限不足", e);
+ labelTips.error("读取失败: 权限不足,请以管理员身份运行");
+ } catch (NoSuchFileException e) {
+ log.error("读取 hosts 失败: 文件不存在", e);
+ labelTips.error("读取失败: hosts 文件不存在");
+ } catch (IOException e) {
+ log.error("读取 hosts 失败", e);
+ labelTips.error("读取失败: %s".formatted(e.getMessage()));
+ }
+ }
+
+ @PostConstruct
+ private void label() {
+ Callback updateStatus = () -> {
+ int total = table.getItems().size();
+ long activated = table.getItems().stream().filter(Host::isActivated).count();
+ labelTotal.setText("总数:%s".formatted(total));
+ labelActivated.setText("激活:%s".formatted(activated));
+ };
+ // 监听列表变化
+ table.getItems().addListener((ListChangeListener) c -> {
+ while (c.next()) {
+ for (Host host : c.getAddedSubList()) {
+ host.activatedProperty().addListener((obs, o, n) -> updateStatus.handler());
+ }
+ }
+ updateStatus.handler();
+ });
+ for (Host host : table.getItems()) {
+ host.activatedProperty().addListener((obs, o, n) -> updateStatus.handler());
+ }
+ updateStatus.handler();
+ }
+}
diff --git a/src/main/java/com/imyeyu/hosts/ui/view/MainView.java b/src/main/java/com/imyeyu/hosts/ui/view/MainView.java
new file mode 100644
index 0000000..d97ef85
--- /dev/null
+++ b/src/main/java/com/imyeyu/hosts/ui/view/MainView.java
@@ -0,0 +1,49 @@
+package com.imyeyu.hosts.ui.view;
+
+import com.imyeyu.fx.ui.TimiFXUI;
+import com.imyeyu.fx.ui.components.LabelTips;
+import com.imyeyu.hosts.ui.components.HostTable;
+import com.imyeyu.inject.annotation.Inject;
+import com.imyeyu.inject.annotation.PostConstruct;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+
+/**
+ * 主界面。
+ *
+ * @author 夜雨
+ * @since 2026-01-13 10:45
+ */
+public abstract class MainView extends BorderPane implements TimiFXUI {
+
+ @Inject
+ protected HostTable table;
+
+ protected final Label labelTotal = new Label();
+ protected final Label labelActivated = new Label();
+ protected final LabelTips labelTips = new LabelTips();
+ protected final Button add = new Button("新增");
+ protected final Button save = new Button("保存");
+
+ @PostConstruct
+ private void build() {
+ setCenter(table);
+ setBottom(new BorderPane() {{
+ setBorder(Stroke.TOP);
+ setPadding(new Insets(4, 6, 4, 6));
+ setLeft(new HBox(24) {{
+ setAlignment(Pos.CENTER_LEFT);
+ getChildren().addAll(labelTotal, labelActivated);
+ }});
+ setRight(new HBox(6) {{
+ setAlignment(Pos.CENTER_RIGHT);
+ getChildren().addAll(labelTips, add, save);
+ }});
+ }});
+ setBorder(Stroke.TOP);
+ }
+}
diff --git a/src/main/java/com/imyeyu/hosts/util/HostsParser.java b/src/main/java/com/imyeyu/hosts/util/HostsParser.java
new file mode 100644
index 0000000..0525a52
--- /dev/null
+++ b/src/main/java/com/imyeyu/hosts/util/HostsParser.java
@@ -0,0 +1,175 @@
+package com.imyeyu.hosts.util;
+
+import com.imyeyu.hosts.model.Host;
+import com.imyeyu.utils.Regex;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Hosts 文件解析器
+ *
+ * 提供 hosts 文件内容与 Host 模型的双向转换功能
+ *
+ * @author 夜雨
+ * @since 2025-01-13
+ */
+@Slf4j
+public class HostsParser {
+
+ // IPv4 正则表达式
+ private static final Pattern IPV4_PATTERN = Pattern.compile(Regex.IPv4);
+
+ // IPv6 正则表达式(简化版)
+ private static final Pattern IPV6_PATTERN = Pattern.compile(Regex.IPv6);
+
+ // 域名正则表达式(RFC 1123)
+ private static final Pattern DOMAIN_PATTERN = Pattern.compile(Regex.DOMAIN);
+
+ /**
+ * 解析 hosts 文件内容为 Host 对象列表
+ *
+ * @param lines hosts 文件的所有行
+ * @return Host 对象列表
+ */
+ public static List parse(List lines) {
+ List hosts = new ArrayList<>();
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i).trim();
+ // 跳过空行
+ if (line.isEmpty()) {
+ continue;
+ }
+ // 跳过纯注释行(不包含 IP 和域名)
+ if (line.startsWith("#")) {
+ String content = line.substring(1).trim();
+ // 如果去掉 # 后还是注释或空行,跳过
+ if (!content.contains(" ")) {
+ continue;
+ }
+ }
+ try {
+ List parsed = parseLine(line);
+ hosts.addAll(parsed);
+ } catch (Exception e) {
+ log.warn("skip invalid hosts line {}: {}", i + 1, line, e);
+ }
+ }
+ return hosts;
+ }
+
+ /**
+ * 解析单行 hosts 配置
+ *
+ * @param line 单行内容
+ * @return Host 对象列表(一个 IP 可能对应多个域名)
+ */
+ private static List parseLine(String line) {
+ List hosts = new ArrayList<>();
+ // 检查是否被注释(未激活)
+ boolean activated = true;
+ if (line.startsWith("#")) {
+ activated = false;
+ line = line.substring(1).trim();
+ }
+ // 提取行尾注释
+ String description = "";
+ int commentIndex = line.indexOf('#');
+ if (commentIndex > 0) {
+ description = line.substring(commentIndex + 1).trim();
+ line = line.substring(0, commentIndex).trim();
+ }
+ // 分割 IP 和域名
+ String[] parts = line.split("\\s+");
+ if (parts.length < 2) {
+ return hosts; // 格式不正确,返回空列表
+ }
+ String ip = parts[0];
+ // 验证 IP 格式
+ if (!isValidIP(ip)) {
+ log.warn("invalid IP address: {}", ip);
+ return hosts;
+ }
+ // 一个 IP 可能对应多个域名,拆分为多条记录
+ for (int i = 1; i < parts.length; i++) {
+ String host = parts[i];
+ // 验证域名格式
+ if (!isValidHost(host)) {
+ log.warn("invalid domain: {}", host);
+ continue;
+ }
+ Host hostObj = new Host(activated, ip, host, description);
+ hosts.add(hostObj);
+ }
+ return hosts;
+ }
+
+ /**
+ * 格式化 Host 对象列表为 hosts 文件内容
+ *
+ * @param hosts Host 对象列表
+ * @return hosts 文件的文本行
+ */
+ public static List format(List hosts) {
+ List lines = new ArrayList<>();
+ for (Host host : hosts) {
+ String line = formatLine(host);
+ lines.add(line);
+ }
+ return lines;
+ }
+
+ /**
+ * 格式化单个 Host 对象为 hosts 文件的一行
+ *
+ * @param host Host 对象
+ * @return 格式化后的文本行
+ */
+ private static String formatLine(Host host) {
+ StringBuilder sb = new StringBuilder();
+ // 未激活时添加 # 前缀
+ if (!host.isActivated()) {
+ sb.append("# ");
+ }
+ // IP 和域名,使用格式化对齐
+ sb.append(String.format("%-20s %s", host.getIP(), host.getHost()));
+ // 添加备注
+ String description = host.getDescription();
+ if (description != null && !description.isEmpty()) {
+ sb.append(" # ").append(description);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * 验证 IP 地址格式(支持 IPv4 和 IPv6)
+ *
+ * @param ip IP 地址字符串
+ * @return 是否为有效的 IP 地址
+ */
+ private static boolean isValidIP(String ip) {
+ if (ip == null || ip.isEmpty()) {
+ return false;
+ }
+ return IPV4_PATTERN.matcher(ip).matches() || IPV6_PATTERN.matcher(ip).matches();
+ }
+
+ /**
+ * 验证域名格式(符合 RFC 1123 规范)
+ *
+ * @param host 域名字符串
+ * @return 是否为有效的域名
+ */
+ private static boolean isValidHost(String host) {
+ if (host == null || host.isEmpty()) {
+ return false;
+ }
+ // localhost 是特殊情况
+ if ("localhost".equals(host)) {
+ return true;
+ }
+ return DOMAIN_PATTERN.matcher(host).matches();
+ }
+}
diff --git a/src/main/resources/TimiHosts.yaml b/src/main/resources/TimiHosts.yaml
new file mode 100644
index 0000000..9f68531
--- /dev/null
+++ b/src/main/resources/TimiHosts.yaml
@@ -0,0 +1 @@
+lang: zh_cn
\ No newline at end of file
diff --git a/src/main/resources/lang/zh_CN.lang b/src/main/resources/lang/zh_CN.lang
new file mode 100644
index 0000000..1a5b3e5
--- /dev/null
+++ b/src/main/resources/lang/zh_CN.lang
@@ -0,0 +1,6 @@
+lang=zh_CN
+
+main.title=Hosts Manager
+main.host=地址
+main.activated=激活
+main.description=说明
\ No newline at end of file
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
new file mode 100644
index 0000000..b0faf08
--- /dev/null
+++ b/src/main/resources/logback.xml
@@ -0,0 +1,23 @@
+
+
+
+ UTF-8
+ [%d{HH:mm:ss.SSS}][%-5level][%-30logger{30}] %msg%n
+
+
+
+ ./logs/debug.log
+
+ UTF-8
+ [%d{HH:mm:ss.SSS}][%-5level][%-30logger{30}] %msg%n
+
+
+ ./logs/debug/debug.%d{yyyy-MM-dd}.log
+ 30
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/style.css b/src/main/resources/style.css
new file mode 100644
index 0000000..6dfef90
--- /dev/null
+++ b/src/main/resources/style.css
@@ -0,0 +1,26 @@
+/* 表格 */
+.host-table .table-row-cell:odd {
+ -fx-background-color: -fx-table-cell-border-color, -fx-control-inner-background;
+}
+.host-table .table-row-cell:filled:selected,
+.host-table .table-row-cell:filled:selected:focused {
+ -fx-padding: 0;
+ -fx-text-fill: -fx-selection-bar-text;
+ -fx-background: #99D1FF;
+ -fx-border-width: 0;
+ -fx-background-color: #99D1FF;
+ -fx-background-insets: 0;
+ -fx-table-cell-border-color: #99D1FF;
+}
+.host-table .table-cell {
+ -fx-padding: 0;
+ -fx-cell-size: 0;
+ -fx-border-width: .083333em .083333em .083333em 0; /* 1 1 1 0 */
+}
+.host-table .table-row-cell .text-field {
+ -fx-background-color: -timi-fx-color, #FFF;
+ -fx-background-insets: 0, 0;
+}
+.host-table .table-row-cell .text-field:focused {
+ -fx-background-insets: 0, 1;
+}
\ No newline at end of file