AIGC标识 摸鱼笔记[9]-基于quickjs的存图清理

摘要

在windows10上使用Java+QuickJS实现工控机存图目录的自动清理, 删除判定逻辑支持用JavaScript脚本热更新.

声明

本文内容由 AI 辅助生成, 已经人工审核和编辑。

简介

需求背景

工业视觉检测的工控机(IPC)上, 相机拍照存图目录会随时间不断膨胀, 最终撑爆磁盘导致产线停机. 需要一个常驻后台的小工具: 按可配置周期扫描监控目录, 满足删除条件时自动删掉最旧的图片(或整目录), 并且删除规则要足够灵活——工控现场条件多变, 改规则重新编译发布太麻烦, 最好能用脚本随时改.

原版工具是 C# WinForms 写的(.NET Framework 4.6.2), 只支持写死的"AND 组合条件". 本文介绍用 Java 重写的版本, 在保持与原版行为一致的前提下, 把删除判定逻辑做成了 QuickJS 脚本扩展点: 在 jar 旁边放一个 delete_conditions.js, 或者直接在界面里编辑脚本, 判定逻辑立即生效, 不用重启程序.

QuickJS简介

[https://bellard.org/quickjs/]
QuickJS 是 Fabrice Bellard(FFmpeg 作者)发布的一个小型可嵌入 JavaScript 引擎, 完整支持 ES2020 规范(模块、异步生成器、Proxy、BigInt 等), 代码量仅约 20 万行 C 代码.

它的核心特点:

  • 极小: x86 下编译出的可执行文件只有几百 KB, 非常适合嵌入桌面程序、移动端、工控机环境
  • 无依赖: 纯 C 实现, 不依赖 V8 那种庞大的构建链, 交叉编译到 Linux/Windows/Android/Termux 都很容易
  • 可嵌入: 提供 C API, 宿主程序可以注入对象/函数给 JS 调用, 也可以从 JS 取回值
  • 支持字节码编译: JS 源码可以预编译成字节码再执行, 保护脚本源码
  • 单线程模型: 每个 JSRuntime 绑定一个线程, 天然规避了多线程竞争, 宿主只要保证"创建、使用、销毁在同一线程"即可

在"只需要一个脚本扩展点, 而不想引入 V8/Node 这种重量级运行时"的场景下, QuickJS 几乎是标准答案.

cn.net.zhijian.quickjs简介

[https://github.com/ZhiJianMesh/QuickJS]
cn.net.zhijian.quickjs 是 QuickJS 的 JVM/Android 绑定库(QuickJS For JVM & Android), 基于 HarlonWang 的 quickjs-android 改进而来, 主要增强:

  • QuickJS-NG 替换了原版 QuickJS 内核, 修了一些 bug
  • 增加 NativeLibraryLoader(自动加载对应平台的 native 库)、Logger 支持
  • 支持 ESModule(import/export)
  • 支持 Linux/Windows/Termux/Android 交叉编译

Java 侧的核心 API 非常简洁:

JavaScript Java
null / undefined null
boolean Boolean
Number Long / Integer / Double
string String
Array JSArray
object JSObject
Function JSFunction
ArrayBuffer byte[]
QuickJSContext context = QuickJSContext.create();
context.evaluate("function evaluate(c) { return c.expiredCount > 0; }");
JSFunction fn = context.getGlobalObject().getJSFunction("evaluate");
Object result = fn.call(context.parse("{\"expiredCount\": 3}"));
context.close();

线程约束: QuickJSContext 只能在创建它的那个线程里使用, 跨线程调用会直接崩溃. 所以工程里把所有脚本操作都固定提交到一个专用单线程执行器上(见下文 ScriptService).

工程

核心原理

整个程序是一个 Swing 托盘应用, 围绕"扫描 → 评估 → 删除"循环构建, QuickJS 只出现在"评估"这一步:

1. 监控调度 — 500ms 心跳 + 防重入

MonitorEngine 用一个单线程调度器每 500ms 检查一次所有监控目录: 距上次运行超过各自"监控周期"(默认 2 秒)的目录, 交给对应的 FolderWorker 执行. 每个 worker 用 AtomicInteger 做防重入, 上一轮没跑完就跳过本轮, 避免扫描慢目录时任务堆积.

2. 两种清理模式

  • 图片清理: 递归扫描 8 种图片扩展名(.jpg/.jpeg/.png/.bmp/.tif/.tiff/.gif/.webp), 按文件创建时间升序
  • 文件夹清理: 只收集目录名以指定前缀(默认 MX)开头的文件夹, 按"层级最深 → 创建时间最旧"排序, 命中后整目录递归删除; 监控根目录本身永不删除

创建时间取自 BasicFileAttributes.creationTime()(Windows 上是真正的创建时间, 不是修改时间), 读取失败回退 lastModified().

3. 删除判定 — 内置 AND 逻辑 + QuickJS 脚本裁决

默认逻辑与 C# 原版完全一致(多条件 AND):

AllowDelete = (存在创建时间早于保存时间阈值的目标)          -- 必须满足
          AND (未启用数量条件 OR 当前数量 > 阈值)
          AND (未启用磁盘条件 OR 磁盘剩余 < 阈值 GB)

判定流程: 每删一个目标就重新评估一次, 直到条件不再满足; 删除失败的目标跳过而不是中断任务.

QuickJS 扩展点: 如果 jar 同目录存在 delete_conditions.js(或配置里的 DeleteScript 字段非空), 则改由脚本说了算. Java 侧把上下文序列化成 JSON, 用 context.parse() 变成 JS 对象传给 evaluate(context) 函数, 返回值必须是 boolean; 脚本出错、找不到 evaluate 函数、或返回非布尔值时, 自动回退到内置 AND 逻辑, 保证程序永远可用.

4. QuickJS 上下文管理 — 专用线程 + 控制台垫片

ScriptService 里所有 QuickJS 操作都提交到一个名为 quickjs-script 的单线程执行器上(QuickJSContext 的线程约束). 另外两个细节:

  • 控制台垫片: 该绑定库的 console 多参数序列化有缺陷(数字丢失、对象错乱), 在用户脚本执行前先注入一段 prelude, 把 console.log(a, b) 的多参数拼成单字符串再转发
  • 在线测试: 界面上可以编辑脚本 + 自定义上下文, 点"测试"后在独立的临时 context 里跑(捕获 console 输出), 不影响当前生效的脚本

5. 安全与工程化

  • 单实例锁: 临时目录下锁文件 + FileLock, 进程退出自动释放
  • 数据目录降级: 程序目录只读时自动降级到 %LocalAppData%\ImageCleaner, 再不行用临时目录; 首次降级时把程序目录自带的配置拷贝过去
  • 原子写配置: appsettings.json 先写 .tmpATOMIC_MOVE 替换, 界面改动防抖 500ms 落盘, 字段名与 C# 版完全兼容(Gson @SerializedName)
  • 按天滚动日志: logs/ImageCleaner_yyyyMMdd.log, 删除操作单独记 DELETE 级别
  • 开机自启: 调 reg 命令维护 HKCU\...\Run\ImageCleaner, 值为 javaw -jar "<jar路径>"
  • Windows 中文乱码修复: 托盘 AWT 原生菜单逐条 setFont 选可用中文字体; Swing 侧遍历 UIManager 字体键替换为支持中文的字体

核心代码

QuickJS 脚本服务(ScriptService.java)

/** 调用脚本判定;脚本未加载或出错时返回 null(调用方回退到内置逻辑) */
public Boolean evaluateDecision(Map<String, Object> ctx) {
    return submit(() -> {
        if (evaluateFunction == null) {
            return null;
        }
        try {
            Object arg = context.parse(GSON.toJson(ctx));
            Object result = evaluateFunction.call(arg);
            if (result instanceof Boolean) {
                lastError = null;
                return (Boolean) result;
            }
            lastError = "evaluate(context) 必须返回 true/false";
            return null;
        } catch (QuickJSException e) {
            lastError = e.getMessage();
            LogService.getInstance().warn("删除判定脚本执行错误: " + e.getMessage());
            return null;
        }
    });
}

删除条件评估(DeleteConditionEvaluator.java)

DeleteEvaluationResult evaluate(MonitorFolderProfile profile, List<CleanupTarget> items, DiskInfo diskInfo,
                                CleanupMode cleanupMode) {
    DeleteConditions conditions = profile.getDeleteConditions();
    long nowMs = System.currentTimeMillis();
    long thresholdMs = (long) (Math.max(1.0, conditions.getStorageTimeSeconds()) * 1000.0);

    DeleteEvaluationResult result = new DeleteEvaluationResult();
    for (CleanupTarget f : items) {
        if (nowMs - f.getCreationTimeUtcMs() > thresholdMs) {
            result.expiredFiles.add(f);
        }
    }
    result.expiredFiles.sort(ComparatorHolder.INSTANCE);
    result.ageConditionMet = !result.expiredFiles.isEmpty();
    result.imageCountConditionMet = !conditions.isImageCountEnabled()
            || items.size() > conditions.getImageCountThreshold();
    result.diskSpaceConditionMet = !conditions.isDiskSpaceEnabled()
            || diskInfo.getFreeSpaceGb() < conditions.getDiskSpaceThresholdGb();

    // 脚本裁决优先,脚本不可用则回退内置 AND 逻辑
    Boolean scriptDecision = scriptService == null ? null
            : scriptService.evaluateDecision(buildContext(profile, items, diskInfo, result, nowMs, cleanupMode));
    result.allowDelete = scriptDecision != null
            ? scriptDecision
            : (result.ageConditionMet && result.imageCountConditionMet && result.diskSpaceConditionMet);
    return result;
}

删除循环(FolderWorker.java)

private int deleteExpiredImages(List<CleanupTarget> files, DiskInfo disk) {
    int deleted = 0;
    DeleteEvaluationResult evaluation = evaluator.evaluate(profile, files, disk, cleanupMode);
    while (evaluation.allowDelete && !evaluation.expiredFiles.isEmpty()) {
        CleanupTarget target = evaluation.expiredFiles.get(0);
        if (tryDeleteFile(target.getFullPath())) {
            files.remove(target);
            deleted++;
            disk = diskInfoService.getDiskInfo(profile.getPath());
        } else {
            files.remove(target);   // 删除失败也跳过,不中断任务
        }
        evaluation = evaluator.evaluate(profile, files, disk, cleanupMode);
    }
    return deleted;
}

删除判定脚本示例(delete_conditions.js)

// 约定:定义 evaluate(context) 函数,返回 true 表示允许继续删除最旧目标,false 表示停止。
// context 字段:
//   nowMs / path / cleanupMode("Image"|"Folder") / storageTimeSeconds
//   expiredCount / imageCount / imageCountEnabled / imageCountThreshold
//   freeSpaceGb / diskSpaceEnabled / diskSpaceThresholdGb
function evaluate(c) {
    var ageMet = c.expiredCount > 0;
    var countMet = !c.imageCountEnabled || c.imageCount > c.imageCountThreshold;
    var diskMet = !c.diskSpaceEnabled || c.freeSpaceGb < c.diskSpaceThresholdGb;
    return ageMet && countMet && diskMet;
}

// 自定义示例:磁盘低于 5GB 时无视保存时间直接清理最旧文件夹
// function evaluate(c) {
//     if (c.freeSpaceGb < 5) return c.imageCount > 0;
//     return c.expiredCount > 0 && (!c.imageCountEnabled || c.imageCount > c.imageCountThreshold);
// }

完整工程代码

src/main/java/com/autoweld/imagecleaner/
  App.java                         入口:环境自检、单实例、FlatLaf、托盘+主窗口
  model/                           配置与数据模型
  config/ConfigManager.java        配置读写(原子写入 + 防抖保存,兼容 C# 字段名)
  service/
    MonitorEngine.java             调度器:500ms 定时检查各目录是否到期
    FolderWorker.java              单目录扫描+删除执行器,防重入,根目录保护
    ImageScanner.java              图片文件扫描(8 种扩展名,按创建时间升序)
    FolderScanner.java             前缀文件夹扫描(层级/同路径判断)
    DeleteConditionEvaluator.java  删除条件评估(AND 逻辑 + QuickJS 脚本裁决)
    ScriptService.java             QuickJS 上下文管理(专用线程,外部文件 > 配置字段)
    ConfigManager/AppPaths/LogService/DiskInfoService/AutoStartService/SingleInstanceLock
  i18n/Lang.java                   中英文资源切换
  ui/
    MainFrame.java                 主窗口(模式/路径/条件/脚本编辑/信息面板)
    TrayController.java            托盘图标(监控时绿色闪烁)+ 右键菜单
    TrayIconImages.java            GDI 风格圆点图标生成
src/main/resources/com/autoweld/imagecleaner/i18n/  Strings_zh_CN / Strings_en_US
build.gradle                       离线 flatDir 仓库,fatJar 打包含依赖与资源
启动.bat                           工控机启动器(检查 Java 版本后 javaw -jar)
delete_conditions.js               删除判定脚本示例(放置到 jar 同目录生效)

App.java

package com.autoweld.imagecleaner;

import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;

import com.autoweld.imagecleaner.config.ConfigManager;
import com.autoweld.imagecleaner.i18n.Lang;
import com.autoweld.imagecleaner.model.AppConfig;
import com.autoweld.imagecleaner.service.AppPaths;
import com.autoweld.imagecleaner.service.LogService;
import com.autoweld.imagecleaner.service.MonitorEngine;
import com.autoweld.imagecleaner.service.ScriptService;
import com.autoweld.imagecleaner.service.SingleInstanceLock;
import com.autoweld.imagecleaner.ui.MainFrame;
import com.autoweld.imagecleaner.ui.TrayController;
import com.formdev.flatlaf.FlatDarkLaf;

/**
 * 程序入口:运行时检查 → 单实例 → FlatLaf → 托盘/主窗口。
 * 对应 C# Program.cs + TrayApplicationContext。
 */
public final class App {

    private App() {
    }

    public static void main(String[] args) {
        // 0. AWT/托盘 编码与字体:在任何 GUI 类实例化前设置,规避 Windows 中文乱码
        applyEncodingAndFontHints();

        // 1. 路径与日志先行(配置加载依赖)
        AppPaths.initialize();
        LogService.getInstance().info("启动环境: " + buildSnapshot());

        // 2. 环境检查
        String blockReason = checkEnvironment();
        if (blockReason != null) {
            showStartupError(blockReason);
            return;
        }

        // 3. 单实例
        SingleInstanceLock lock = SingleInstanceLock.tryAcquire();
        if (lock == null) {
            JOptionPane.showMessageDialog(null, Lang.t("AlreadyRunning"), Lang.t("AppTitle"),
                    JOptionPane.INFORMATION_MESSAGE);
            return;
        }

        try {
            ConfigManager configManager = new ConfigManager();
            AppConfig cfg = configManager.getConfig();
            Lang.init(cfg.getLanguage());

            ScriptService scriptService = new ScriptService();
            scriptService.reload(cfg.getDeleteScript());

            MonitorEngine engine = new MonitorEngine(scriptService);

            SwingUtilities.invokeLater(() -> {
                installLookAndFeel();
                TrayController tray = new TrayController(new TrayController.Callbacks() {
                    @Override
                    public void onShowWindow() {
                        frameRef[0].showFromTray();
                    }

                    @Override
                    public void onToggleMonitoring() {
                        if (frameRef[0].isMonitoring()) {
                            frameRef[0].stopMonitoring();
                        } else {
                            frameRef[0].startMonitoring();
                        }
                        trayRef[0].updateState();
                    }

                    @Override
                    public void onExit() {
                        configManager.saveNow();
                        trayRef[0].dispose();
                        System.exit(0);
                    }

                    @Override
                    public boolean isMonitoring() {
                        return frameRef[0] != null && frameRef[0].isMonitoring();
                    }
                });

                boolean trayAvailable = tray.install();
                if (!trayAvailable) {
                    LogService.getInstance().warn("未检测到系统托盘,将强制显示主窗口。");
                }

                MainFrame frame = new MainFrame(configManager, engine, scriptService);
                final TrayController trayRefFinal = tray;
                final MainFrame frameRefFinal = frame;
                frameRef[0] = frameRefFinal;
                trayRef[0] = trayRefFinal;

                frame.setTrayActions(new MainFrame.TrayActions() {
                    @Override
                    public void hideToTray() {
                        frameRefFinal.setVisible(false);
                    }

                    @Override
                    public boolean isTrayAvailable() {
                        return trayAvailable;
                    }
                });

                Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                    configManager.saveNow();
                    engine.close();
                    trayRefFinal.dispose();
                }));

                engine.startScheduler();
                if (cfg.isAutoStartMonitoring()) {
                    frame.startMonitoring();
                }

                if (!frame.shouldStartHidden() || !trayAvailable) {
                    frame.setVisible(true);
                } else {
                    tray.updateState();
                }
            });
        } catch (Exception ex) {
            LogService.getInstance().error("启动失败: " + ex);
            showStartupError("程序启动失败。\r\n\r\n" + ex.getClass().getSimpleName() + ": " + ex.getMessage()
                    + "\r\n\r\n详细信息已写入 logs 目录。");
        }
    }

    private static final MainFrame[] frameRef = new MainFrame[1];
    private static final TrayController[] trayRef = new TrayController[1];

    /**
     * 设置系统属性,在任何 AWT/Swing 类加载前尽早调用,最大程度规避 Windows 中文乱码。
     * 说明:
     * - sun.jnu.encoding:影响 JNI 层文件名/系统字符串的解码(如托盘菜单相关的文本往返);
     * - file.encoding:影响 Java 18+ 的默认字符集(JEP 400 后默认 UTF-8,但 Windows 原生菜单仍依赖系统代码页);
     * - 托盘菜单的 AWT 组件缺字问题主要通过 TrayController 中对每个 MenuItem setFont(中文字体) 解决,
     *   这里设置属性作为兜底,并在 FlatDarkLaf 初始化后把 Swing 默认字体也替换为支持中文的字体。
     */
    private static void applyEncodingAndFontHints() {
        trySetProperty("sun.jnu.encoding", "GBK");
        trySetProperty("file.encoding", "UTF-8");
        trySetProperty("sun.awt.font.debug", "false");
    }

    private static void trySetProperty(String key, String value) {
        try {
            if (System.getProperty(key) == null) {
                System.setProperty(key, value);
            }
        } catch (Throwable ignored) {
        }
    }

    private static void installLookAndFeel() {
        try {
            FlatDarkLaf.setup();
            // 为 Swing 控件设置中文友好的默认字体(防止 FlatLaf 回退字体不含中文)
            installChineseFriendlyDefaults();
        } catch (Exception e1) {
            try {
                UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
            } catch (Exception ignored) {
            }
        }
    }

    /**
     * 遍历 UIManager 中的 defaultFont 键,若原字体不支持中文则替换为可用中文字体。
     * 对 Swing 组件标题、按钮、标签等生效(托盘菜单是AWT原生组件,在 TrayController 中单独处理)。
     */
    private static void installChineseFriendlyDefaults() {
        try {
            String[] fontKeys = {
                    "Label.font", "Button.font", "ToggleButton.font", "CheckBox.font",
                    "RadioButton.font", "ComboBox.font", "List.font", "Table.font",
                    "TableHeader.font", "MenuBar.font", "Menu.font", "MenuItem.font",
                    "PopupMenu.font", "OptionPane.messageFont", "OptionPane.buttonFont",
                    "ToolTip.font", "Tree.font", "Panel.font", "TabbedPane.font",
                    "TextField.font", "PasswordField.font", "TextArea.font", "TextPane.font",
                    "EditorPane.font", "Spinner.font", "ColorChooser.font",
            };
            java.awt.Font reference = javax.swing.UIManager.getFont("Label.font");
            if (reference == null) return;
            java.awt.Font replacement = reference;
            // 检查参考字体能否显示中文样例
            if (!canDisplayChinese(reference)) {
                replacement = findSuitableFont(reference);
            }
            if (replacement == reference) return; // 原字体支持中文,不需要替换
            for (String k : fontKeys) {
                Object existing = javax.swing.UIManager.get(k);
                if (existing instanceof java.awt.Font) {
                    javax.swing.UIManager.put(k, replacement.deriveFont(((java.awt.Font) existing).getStyle(),
                            ((java.awt.Font) existing).getSize2D()));
                }
            }
        } catch (Throwable ignored) {
        }
    }

    private static boolean canDisplayChinese(java.awt.Font f) {
        if (f == null) return false;
        String sample = "显示停止退出监控";
        for (int i = 0; i < sample.length(); i++) {
            if (!f.canDisplay(sample.charAt(i))) return false;
        }
        return true;
    }

    private static java.awt.Font findSuitableFont(java.awt.Font reference) {
        String[] candidates = {
                "Microsoft YaHei UI", "Microsoft YaHei",
                "SimSun", "PingFang SC", "Source Han Sans CN", "Noto Sans CJK SC",
        };
        try {
            java.awt.GraphicsEnvironment ge = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment();
            java.util.Set<String> installed = new java.util.HashSet<>(
                    java.util.Arrays.asList(ge.getAvailableFontFamilyNames()));
            for (String name : candidates) {
                if (installed.contains(name)) {
                    java.awt.Font f = new java.awt.Font(name, reference.getStyle(), reference.getSize());
                    if (canDisplayChinese(f)) return f;
                }
            }
        } catch (Throwable ignored) {
        }
        return reference;
    }

    private static String checkEnvironment() {
        String os = System.getProperty("os.name", "").toLowerCase();
        if (!os.contains("win")) {
            return "本程序仅支持 Windows 系统。";
        }
        if (!AppPaths.isDataDirectoryWritable()) {
            return "程序目录和用户目录均无法写入配置/日志。\r\n请把软件拷到本地可写磁盘,或改用有写入权限的 Windows 用户运行。";
        }
        if (AppPaths.isUsingFallbackDirectory()) {
            LogService.getInstance().warn("程序目录只读,配置和日志已改存: " + AppPaths.getDataDirectory());
        }
        return null;
    }

    private static String buildSnapshot() {
        return "OS=" + System.getProperty("os.name") + " " + System.getProperty("os.version")
                + "; Arch=" + System.getProperty("os.arch")
                + "; Java=" + System.getProperty("java.version")
                + "; ProgramDir=" + AppPaths.getProgramDirectory()
                + "; DataDir=" + AppPaths.getDataDirectory()
                + "; Fallback=" + AppPaths.isUsingFallbackDirectory()
                + "; User=" + System.getProperty("user.name")
                + "; Machine=" + System.getenv("COMPUTERNAME");
    }

    private static void showStartupError(String message) {
        try {
            JOptionPane.showMessageDialog(null, message, "图片清理 - 无法启动", JOptionPane.ERROR_MESSAGE);
        } catch (Exception ignored) {
        }
    }
}

model/AppConfig.java

package com.autoweld.imagecleaner.model;

import java.util.ArrayList;
import java.util.List;

import com.google.gson.annotations.SerializedName;

/** 根配置,与 C# 版 appsettings.json 字段保持兼容 */
public class AppConfig {

    @SerializedName("Language")
    private String language = "zh-CN";

    @SerializedName("MinimizeToTrayOnClose")
    private boolean minimizeToTrayOnClose = true;

    @SerializedName("RunInBackground")
    private boolean runInBackground = false;

    @SerializedName("AutoStartMonitoring")
    private boolean autoStartMonitoring = true;

    @SerializedName("AutoStartWithWindows")
    private boolean autoStartWithWindows = false;

    @SerializedName("Folders")
    private List<MonitorFolderProfile> folders = new ArrayList<>();

    @SerializedName("UseSharedDeleteConditions")
    private boolean useSharedDeleteConditions = false;

    @SerializedName("SharedDeleteConditions")
    private DeleteConditions sharedDeleteConditions = new DeleteConditions();

    @SerializedName("CleanupMode")
    private CleanupMode cleanupMode = CleanupMode.Image;

    @SerializedName("FolderPrefix")
    private String folderPrefix = "MX";

    /** 可选:自定义删除判定 JS 脚本(定义 evaluate(c) 函数),空则用内置默认逻辑 */
    @SerializedName("DeleteScript")
    private String deleteScript = "";

    public String getLanguage() {
        return language == null || language.isEmpty() ? "zh-CN" : language;
    }

    public void setLanguage(String language) {
        this.language = language;
    }

    public boolean isMinimizeToTrayOnClose() {
        return minimizeToTrayOnClose;
    }

    public void setMinimizeToTrayOnClose(boolean minimizeToTrayOnClose) {
        this.minimizeToTrayOnClose = minimizeToTrayOnClose;
    }

    public boolean isRunInBackground() {
        return runInBackground;
    }

    public void setRunInBackground(boolean runInBackground) {
        this.runInBackground = runInBackground;
    }

    public boolean isAutoStartMonitoring() {
        return autoStartMonitoring;
    }

    public void setAutoStartMonitoring(boolean autoStartMonitoring) {
        this.autoStartMonitoring = autoStartMonitoring;
    }

    public boolean isAutoStartWithWindows() {
        return autoStartWithWindows;
    }

    public void setAutoStartWithWindows(boolean autoStartWithWindows) {
        this.autoStartWithWindows = autoStartWithWindows;
    }

    public List<MonitorFolderProfile> getFolders() {
        if (folders == null) {
            folders = new ArrayList<>();
        }
        return folders;
    }

    public void setFolders(List<MonitorFolderProfile> folders) {
        this.folders = folders;
    }

    public boolean isUseSharedDeleteConditions() {
        return useSharedDeleteConditions;
    }

    public void setUseSharedDeleteConditions(boolean useSharedDeleteConditions) {
        this.useSharedDeleteConditions = useSharedDeleteConditions;
    }

    public DeleteConditions getSharedDeleteConditions() {
        if (sharedDeleteConditions == null) {
            sharedDeleteConditions = new DeleteConditions();
        }
        return sharedDeleteConditions;
    }

    public void setSharedDeleteConditions(DeleteConditions sharedDeleteConditions) {
        this.sharedDeleteConditions = sharedDeleteConditions;
    }

    public CleanupMode getCleanupMode() {
        return cleanupMode == null ? CleanupMode.Image : cleanupMode;
    }

    public void setCleanupMode(CleanupMode cleanupMode) {
        this.cleanupMode = cleanupMode;
    }

    public String getFolderPrefix() {
        return folderPrefix;
    }

    public void setFolderPrefix(String folderPrefix) {
        this.folderPrefix = folderPrefix;
    }

    public String getEffectiveFolderPrefix() {
        if (folderPrefix != null && !folderPrefix.trim().isEmpty()) {
            return folderPrefix.trim();
        }
        return "MX";
    }

    public String getDeleteScript() {
        return deleteScript == null ? "" : deleteScript;
    }

    public void setDeleteScript(String deleteScript) {
        this.deleteScript = deleteScript;
    }

    /** 取得对某个监控目录生效的删除条件(统一设置或单独设置) */
    public DeleteConditions getEffectiveDeleteConditions(MonitorFolderProfile folder) {
        if (useSharedDeleteConditions) {
            return getSharedDeleteConditions();
        }
        return folder != null ? folder.getDeleteConditions() : new DeleteConditions();
    }
}

model/DeleteConditions.java

package com.autoweld.imagecleaner.model;

import com.google.gson.annotations.SerializedName;

/** 删除条件,多条件之间为 AND 关系 */
public class DeleteConditions {

    @SerializedName("StorageTimeValue")
    private int storageTimeValue = 20;

    @SerializedName("StorageTimeUnit")
    private TimeUnit storageTimeUnit = TimeUnit.Seconds;

    @SerializedName("ImageCountEnabled")
    private boolean imageCountEnabled = false;

    @SerializedName("ImageCountThreshold")
    private int imageCountThreshold = 3000;

    @SerializedName("DiskSpaceEnabled")
    private boolean diskSpaceEnabled = false;

    @SerializedName("DiskSpaceThresholdGb")
    private double diskSpaceThresholdGb = 8.0;

    public int getStorageTimeValue() {
        return storageTimeValue;
    }

    public void setStorageTimeValue(int storageTimeValue) {
        this.storageTimeValue = storageTimeValue;
    }

    public TimeUnit getStorageTimeUnit() {
        return storageTimeUnit == null ? TimeUnit.Seconds : storageTimeUnit;
    }

    public void setStorageTimeUnit(TimeUnit storageTimeUnit) {
        this.storageTimeUnit = storageTimeUnit;
    }

    public boolean isImageCountEnabled() {
        return imageCountEnabled;
    }

    public void setImageCountEnabled(boolean imageCountEnabled) {
        this.imageCountEnabled = imageCountEnabled;
    }

    public int getImageCountThreshold() {
        return imageCountThreshold;
    }

    public void setImageCountThreshold(int imageCountThreshold) {
        this.imageCountThreshold = imageCountThreshold;
    }

    public boolean isDiskSpaceEnabled() {
        return diskSpaceEnabled;
    }

    public void setDiskSpaceEnabled(boolean diskSpaceEnabled) {
        this.diskSpaceEnabled = diskSpaceEnabled;
    }

    public double getDiskSpaceThresholdGb() {
        return diskSpaceThresholdGb;
    }

    public void setDiskSpaceThresholdGb(double diskSpaceThresholdGb) {
        this.diskSpaceThresholdGb = diskSpaceThresholdGb;
    }

    /** 保存时间折算为秒 */
    public double getStorageTimeSeconds() {
        switch (getStorageTimeUnit()) {
            case Minutes:
                return storageTimeValue * 60.0;
            case Hours:
                return storageTimeValue * 3600.0;
            case Days:
                return storageTimeValue * 86400.0;
            default:
                return storageTimeValue;
        }
    }

    public DeleteConditions copy() {
        DeleteConditions c = new DeleteConditions();
        c.storageTimeValue = storageTimeValue;
        c.storageTimeUnit = storageTimeUnit;
        c.imageCountEnabled = imageCountEnabled;
        c.imageCountThreshold = imageCountThreshold;
        c.diskSpaceEnabled = diskSpaceEnabled;
        c.diskSpaceThresholdGb = diskSpaceThresholdGb;
        return c;
    }
}

model/MonitorFolderProfile.java

package com.autoweld.imagecleaner.model;

import java.util.UUID;

import com.google.gson.annotations.SerializedName;

/** 受监控的文件夹档案 */
public class MonitorFolderProfile {

    @SerializedName("Id")
    private String id = UUID.randomUUID().toString();

    @SerializedName("Path")
    private String path = "";

    @SerializedName("Enabled")
    private boolean enabled = true;

    @SerializedName("MonitorIntervalSeconds")
    private int monitorIntervalSeconds = 2;

    @SerializedName("IncludeSubdirectories")
    private boolean includeSubdirectories = true;

    @SerializedName("DeleteConditions")
    private DeleteConditions deleteConditions = new DeleteConditions();

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getPath() {
        return path == null ? "" : path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public int getMonitorIntervalSeconds() {
        return monitorIntervalSeconds;
    }

    public void setMonitorIntervalSeconds(int monitorIntervalSeconds) {
        this.monitorIntervalSeconds = monitorIntervalSeconds;
    }

    public boolean isIncludeSubdirectories() {
        return includeSubdirectories;
    }

    public void setIncludeSubdirectories(boolean includeSubdirectories) {
        this.includeSubdirectories = includeSubdirectories;
    }

    public DeleteConditions getDeleteConditions() {
        if (deleteConditions == null) {
            deleteConditions = new DeleteConditions();
        }
        return deleteConditions;
    }

    public void setDeleteConditions(DeleteConditions deleteConditions) {
        this.deleteConditions = deleteConditions;
    }

    public MonitorFolderProfile copy() {
        MonitorFolderProfile p = new MonitorFolderProfile();
        p.id = id;
        p.path = path;
        p.enabled = enabled;
        p.monitorIntervalSeconds = monitorIntervalSeconds;
        p.includeSubdirectories = includeSubdirectories;
        p.deleteConditions = getDeleteConditions().copy();
        return p;
    }
}

model/CleanupTarget.java

package com.autoweld.imagecleaner.model;

import com.google.gson.annotations.SerializedName;

/** 一次扫描/评估中的候选删除目标 */
public class CleanupTarget {

    @SerializedName("Path")
    private String fullPath = "";

    /** 创建时间(UTC 毫秒) */
    @SerializedName("CreationTimeUtcMs")
    private long creationTimeUtcMs;

    /** 相对监控根目录的层级深度(文件夹模式用) */
    @SerializedName("Depth")
    private int depth;

    public String getFullPath() {
        return fullPath;
    }

    public void setFullPath(String fullPath) {
        this.fullPath = fullPath;
    }

    public long getCreationTimeUtcMs() {
        return creationTimeUtcMs;
    }

    public void setCreationTimeUtcMs(long creationTimeUtcMs) {
        this.creationTimeUtcMs = creationTimeUtcMs;
    }

    public int getDepth() {
        return depth;
    }

    public void setDepth(int depth) {
        this.depth = depth;
    }
}

model/CleanupMode.java

package com.autoweld.imagecleaner.model;

/** 清理模式 */
public enum CleanupMode {
    /** 图片清理:按扩展名扫描图片文件 */
    Image,
    /** 文件夹清理:按前缀扫描文件夹,整目录删除 */
    Folder
}

model/TimeUnit.java

package com.autoweld.imagecleaner.model;

/** 保存时间单位 */
public enum TimeUnit {
    Seconds,
    Minutes,
    Hours,
    Days
}

model/DiskInfo.java

package com.autoweld.imagecleaner.model;

/** 磁盘信息 */
public class DiskInfo {

    private String driveLetter = "";
    private double freeSpaceGb;
    private double totalSpaceGb;
    private boolean valid;

    public String getDriveLetter() {
        return driveLetter;
    }

    public void setDriveLetter(String driveLetter) {
        this.driveLetter = driveLetter == null ? "" : driveLetter;
    }

    public double getFreeSpaceGb() {
        return freeSpaceGb;
    }

    public void setFreeSpaceGb(double freeSpaceGb) {
        this.freeSpaceGb = freeSpaceGb;
    }

    public double getTotalSpaceGb() {
        return totalSpaceGb;
    }

    public void setTotalSpaceGb(double totalSpaceGb) {
        this.totalSpaceGb = totalSpaceGb;
    }

    public boolean isValid() {
        return valid;
    }

    public void setValid(boolean valid) {
        this.valid = valid;
    }
}

model/MonitorStats.java

package com.autoweld.imagecleaner.model;

/** 单个监控目录的实时统计 */
public class MonitorStats {

    private String folderId = "";
    private String path = "";
    private int imageCount;
    private long scanElapsedMs;
    private long lastScanUtcMs;
    private String driveLetter = "";
    private double freeSpaceGb;
    private double totalSpaceGb;
    private int deletedCountLastRun;
    private long actualIntervalMs;

    public String getFolderId() {
        return folderId;
    }

    public void setFolderId(String folderId) {
        this.folderId = folderId == null ? "" : folderId;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path == null ? "" : path;
    }

    public int getImageCount() {
        return imageCount;
    }

    public void setImageCount(int imageCount) {
        this.imageCount = imageCount;
    }

    public long getScanElapsedMs() {
        return scanElapsedMs;
    }

    public void setScanElapsedMs(long scanElapsedMs) {
        this.scanElapsedMs = scanElapsedMs;
    }

    public long getLastScanUtcMs() {
        return lastScanUtcMs;
    }

    public void setLastScanUtcMs(long lastScanUtcMs) {
        this.lastScanUtcMs = lastScanUtcMs;
    }

    public String getDriveLetter() {
        return driveLetter;
    }

    public void setDriveLetter(String driveLetter) {
        this.driveLetter = driveLetter == null ? "" : driveLetter;
    }

    public double getFreeSpaceGb() {
        return freeSpaceGb;
    }

    public void setFreeSpaceGb(double freeSpaceGb) {
        this.freeSpaceGb = freeSpaceGb;
    }

    public double getTotalSpaceGb() {
        return totalSpaceGb;
    }

    public void setTotalSpaceGb(double totalSpaceGb) {
        this.totalSpaceGb = totalSpaceGb;
    }

    public int getDeletedCountLastRun() {
        return deletedCountLastRun;
    }

    public void setDeletedCountLastRun(int deletedCountLastRun) {
        this.deletedCountLastRun = deletedCountLastRun;
    }

    public long getActualIntervalMs() {
        return actualIntervalMs;
    }

    public void setActualIntervalMs(long actualIntervalMs) {
        this.actualIntervalMs = actualIntervalMs;
    }
}

model/GlobalMonitorStats.java

package com.autoweld.imagecleaner.model;

/** 全局监控汇总 */
public class GlobalMonitorStats {

    private boolean monitoring;
    private int totalFolders;
    private int enabledFolders;
    private int totalImageCount;

    public boolean isMonitoring() {
        return monitoring;
    }

    public void setMonitoring(boolean monitoring) {
        this.monitoring = monitoring;
    }

    public int getTotalFolders() {
        return totalFolders;
    }

    public void setTotalFolders(int totalFolders) {
        this.totalFolders = totalFolders;
    }

    public int getEnabledFolders() {
        return enabledFolders;
    }

    public void setEnabledFolders(int enabledFolders) {
        this.enabledFolders = enabledFolders;
    }

    public int getTotalImageCount() {
        return totalImageCount;
    }

    public void setTotalImageCount(int totalImageCount) {
        this.totalImageCount = totalImageCount;
    }
}

config/ConfigManager.java

package com.autoweld.imagecleaner.config;

import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;

import com.autoweld.imagecleaner.model.AppConfig;
import com.autoweld.imagecleaner.service.LogService;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

/** 配置读写:appsettings.json,与 C# 版字段兼容,原子写入 + 防抖保存 */
public class ConfigManager {

    private static final String CONFIG_FILE_NAME = "appsettings.json";

    private final Gson gson = new GsonBuilder().serializeNulls().create();
    private final Path configPath;
    private final Object saveLock = new Object();
    private final java.util.concurrent.ScheduledExecutorService saveExecutor =
            java.util.concurrent.Executors.newSingleThreadScheduledExecutor(r -> {
                Thread t = new Thread(r, "config-save");
                t.setDaemon(true);
                return t;
            });
    private volatile java.util.concurrent.ScheduledFuture<?> pendingSave;
    private AppConfig config;
    private volatile Runnable changeListener;

    public ConfigManager() {
        this.configPath = resolveConfigPath();
        this.config = load();
    }

    public AppConfig getConfig() {
        return config;
    }

    public Path getConfigPath() {
        return configPath;
    }

    public void setChangeListener(Runnable listener) {
        this.changeListener = listener;
    }

    private static Path resolveConfigPath() {
        Path jarDir = getJarDirectory();
        if (jarDir != null) {
            return jarDir.resolve(CONFIG_FILE_NAME);
        }
        return Paths.get(CONFIG_FILE_NAME).toAbsolutePath();
    }

    private static Path getJarDirectory() {
        try {
            URI uri = ConfigManager.class.getProtectionDomain().getCodeSource().getLocation().toURI();
            Path path = Paths.get(uri);
            if (Files.isRegularFile(path)) {
                return path.getParent();
            }
            return path;
        } catch (Exception e) {
            return null;
        }
    }

    public AppConfig load() {
        if (Files.exists(configPath)) {
            try {
                String json = new String(Files.readAllBytes(configPath), StandardCharsets.UTF_8);
                AppConfig loaded = gson.fromJson(json, AppConfig.class);
                if (loaded != null) {
                    normalize(loaded);
                    config = loaded;
                    return config;
                }
            } catch (Exception e) {
                LogService.getInstance().warn("加载配置失败: " + e.getMessage());
            }
        }
        config = new AppConfig();
        return config;
    }

    private static void normalize(AppConfig cfg) {
        if (cfg.getFolders() == null) {
            cfg.setFolders(new java.util.ArrayList<>());
        }
        if (cfg.getFolderPrefix() == null || cfg.getFolderPrefix().trim().isEmpty()) {
            cfg.setFolderPrefix("MX");
        }
        for (com.autoweld.imagecleaner.model.MonitorFolderProfile folder : cfg.getFolders()) {
            if (folder.getDeleteConditions() == null) {
                folder.setDeleteConditions(new com.autoweld.imagecleaner.model.DeleteConditions());
            }
        }
    }

    /** 防抖保存:500ms 内多次调用只落盘一次 */
    public void scheduleSave() {
        java.util.concurrent.ScheduledFuture<?> old = pendingSave;
        if (old != null) {
            old.cancel(false);
        }
        pendingSave = saveExecutor.schedule(this::saveNow, 500, java.util.concurrent.TimeUnit.MILLISECONDS);
    }

    public void saveNow() {
        synchronized (saveLock) {
            try {
                String json = gson.toJson(config);
                Path temp = configPath.resolveSibling(CONFIG_FILE_NAME + ".tmp");
                Files.write(temp, json.getBytes(StandardCharsets.UTF_8));
                try {
                    Files.move(temp, configPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
                } catch (Exception e) {
                    Files.move(temp, configPath, StandardCopyOption.REPLACE_EXISTING);
                }
                Runnable listener = changeListener;
                if (listener != null) {
                    try {
                        listener.run();
                    } catch (Exception ignored) {
                    }
                }
            } catch (Exception e) {
                LogService.getInstance().error("保存配置失败: " + e.getMessage());
            }
        }
    }
}

service/AppPaths.java

package com.autoweld.imagecleaner.service;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
 * 应用路径解析:优先程序目录,只读时降级到 %LocalAppData%\ImageCleaner,
 * 再不行用临时目录;配置/日志都写到选定的数据目录。
 */
public final class AppPaths {

    private static final Object SYNC = new Object();
    private static boolean initialized;

    private static Path programDirectory;
    private static Path dataDirectory;
    private static Path logDirectory;
    private static Path configPath;
    private static boolean usingFallbackDirectory;
    private static boolean dataDirectoryWritable;

    private AppPaths() {
    }

    public static Path getProgramDirectory() {
        initialize();
        return programDirectory;
    }

    public static Path getDataDirectory() {
        initialize();
        return dataDirectory;
    }

    public static Path getLogDirectory() {
        initialize();
        return logDirectory;
    }

    public static Path getConfigPath() {
        initialize();
        return configPath;
    }

    public static boolean isUsingFallbackDirectory() {
        initialize();
        return usingFallbackDirectory;
    }

    public static boolean isDataDirectoryWritable() {
        initialize();
        return dataDirectoryWritable;
    }

    public static void initialize() {
        if (initialized) {
            return;
        }
        synchronized (SYNC) {
            if (initialized) {
                return;
            }
            try {
                Path base = Paths.get(AppPaths.class.getProtectionDomain()
                        .getCodeSource().getLocation().toURI());
                programDirectory = Files.isRegularFile(base) ? base.getParent() : base;
            } catch (Exception e) {
                programDirectory = Paths.get(".").toAbsolutePath().normalize();
            }
            dataDirectory = chooseWritableDirectory();
            dataDirectoryWritable = dataDirectory != null && canWriteTo(dataDirectory);
            usingFallbackDirectory = dataDirectoryWritable && !pathsEqual(dataDirectory, programDirectory);
            if (dataDirectoryWritable) {
                logDirectory = dataDirectory.resolve("logs");
                tryCreateDirectory(logDirectory);
                configPath = dataDirectory.resolve("appsettings.json");
                seedConfigFromProgramDirectory();
            } else {
                logDirectory = null;
                configPath = programDirectory.resolve("appsettings.json");
            }
            initialized = true;
        }
    }

    private static Path chooseWritableDirectory() {
        String localAppData = System.getenv("LOCALAPPDATA");
        Path[] candidates = new Path[]{
                programDirectory,
                localAppData != null ? Paths.get(localAppData, "ImageCleaner") : null,
                Paths.get(System.getProperty("java.io.tmpdir"), "ImageCleaner")
        };
        for (Path dir : candidates) {
            if (dir != null && canWriteTo(dir)) {
                return dir;
            }
        }
        return programDirectory;
    }

    private static void seedConfigFromProgramDirectory() {
        try {
            if (usingFallbackDirectory && configPath != null && !Files.exists(configPath)) {
                Path bundled = programDirectory.resolve("appsettings.json");
                if (Files.exists(bundled)) {
                    Files.copy(bundled, configPath);
                }
            }
        } catch (Exception ignored) {
        }
    }

    public static boolean canWriteTo(Path directory) {
        if (directory == null) {
            return false;
        }
        Path probe = null;
        try {
            if (!Files.exists(directory)) {
                Files.createDirectories(directory);
            }
            probe = directory.resolve(".imagecleaner_write_probe");
            Files.write(probe, "ok".getBytes("UTF-8"));
            return true;
        } catch (Exception e) {
            return false;
        } finally {
            if (probe != null) {
                try {
                    Files.deleteIfExists(probe);
                } catch (Exception ignored) {
                }
            }
        }
    }

    private static void tryCreateDirectory(Path directory) {
        try {
            if (directory != null && !Files.exists(directory)) {
                Files.createDirectories(directory);
            }
        } catch (IOException ignored) {
        }
    }

    private static boolean pathsEqual(Path a, Path b) {
        if (a == null || b == null) {
            return false;
        }
        try {
            return a.toAbsolutePath().normalize().equals(b.toAbsolutePath().normalize());
        } catch (Exception e) {
            return false;
        }
    }
}

service/LogService.java

package com.autoweld.imagecleaner.service;

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.nio.file.StandardOpenOption;
import java.text.SimpleDateFormat;
import java.util.Date;

/** 按天滚动的文件日志(logs/ImageCleaner_yyyyMMdd.log) */
public final class LogService {

    private static final LogService INSTANCE = new LogService();

    public static LogService getInstance() {
        return INSTANCE;
    }

    private final Object lock = new Object();
    private final Path logDirectory;

    private LogService() {
        Path dir = null;
        try {
            AppPaths.initialize();
            dir = AppPaths.getLogDirectory();
            if (dir != null) {
                Files.createDirectories(dir);
            }
        } catch (Exception ignored) {
            dir = null;
        }
        this.logDirectory = dir;
    }

    public void info(String message) {
        write("INFO", message);
    }

    public void warn(String message) {
        write("WARN", message);
    }

    public void error(String message) {
        write("ERROR", message);
    }

    public void delete(String folderPath, String filePath) {
        write("DELETE", folderPath + " | " + filePath);
    }

    private void write(String level, String message) {
        if (logDirectory == null) {
            return;
        }
        String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(new Date());
        String line = time + " [" + level + "] " + message;
        String file = "ImageCleaner_" + new SimpleDateFormat("yyyyMMdd").format(new Date()) + ".log";
        synchronized (lock) {
            try {
                Files.write(logDirectory.resolve(file), (line + System.lineSeparator()).getBytes(StandardCharsets.UTF_8),
                        StandardOpenOption.CREATE, StandardOpenOption.APPEND);
            } catch (IOException ignored) {
            }
        }
    }
}

service/SingleInstanceLock.java

package com.autoweld.imagecleaner.service;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

/** 单实例锁:基于锁文件,进程退出自动释放 */
public class SingleInstanceLock implements AutoCloseable {

    private final FileLock lock;
    private final File lockFile;
    private final RandomAccessFile raf;

    private SingleInstanceLock(File lockFile, RandomAccessFile raf, FileLock lock) {
        this.lockFile = lockFile;
        this.raf = raf;
        this.lock = lock;
    }

    /** 尝试获取单实例锁;返回 null 表示已有实例在运行 */
    public static SingleInstanceLock tryAcquire() {
        Path lockPath = Paths.get(System.getProperty("java.io.tmpdir"),
                "ImageCleaner.SingleInstance.lock");
        try {
            Files.createDirectories(lockPath.getParent());
            File file = lockPath.toFile();
            RandomAccessFile raf = new RandomAccessFile(file, "rw");
            FileChannel channel = raf.getChannel();
            FileLock fileLock = channel.tryLock();
            if (fileLock == null) {
                raf.close();
                return null;
            }
            return new SingleInstanceLock(file, raf, fileLock);
        } catch (IOException e) {
            // 锁文件不可用时放行(与 C# 版互斥锁失败继续启动的行为一致)
            return new SingleInstanceLock(null, null, null);
        }
    }

    @Override
    public void close() {
        try {
            if (lock != null) {
                lock.release();
            }
        } catch (IOException ignored) {
        }
        try {
            if (raf != null) {
                raf.close();
            }
        } catch (IOException ignored) {
        }
        try {
            if (lockFile != null) {
                Files.deleteIfExists(lockFile.toPath());
            }
        } catch (IOException ignored) {
        }
    }
}

service/AutoStartService.java

package com.autoweld.imagecleaner.service;

import java.nio.charset.StandardCharsets;
import java.util.Scanner;

/** 开机自启:通过 reg 命令维护 HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ImageCleaner */
public class AutoStartService {

    private static final String REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
    private static final String APP_NAME = "ImageCleaner";

    public boolean isEnabled() {
        String value = readValue();
        return value != null && !value.isEmpty();
    }

    public void setEnabled(boolean enabled) {
        try {
            String command = getLaunchCommand();
            if (command.isEmpty()) {
                throw new IllegalStateException("无法确定程序启动命令");
            }
            if (enabled) {
                run("reg", "add", REG_KEY, "/v", APP_NAME, "/t", "REG_SZ",
                        "/d", command, "/f");
            } else {
                run("reg", "delete", REG_KEY, "/v", APP_NAME, "/f");
            }
        } catch (Exception ex) {
            LogService.getInstance().error("设置开机自启失败: " + ex.getMessage());
        }
    }

    private static String readValue() {
        try {
            Process p = new ProcessBuilder("reg", "query", REG_KEY, "/v", APP_NAME)
                    .redirectErrorStream(true).start();
            String out;
            try (Scanner scanner = new Scanner(p.getInputStream(), StandardCharsets.UTF_8.name()).useDelimiter("\\A")) {
                out = scanner.hasNext() ? scanner.next() : "";
            }
            p.waitFor();
            for (String line : out.split("\\r?\\n")) {
                line = line.trim();
                if (line.startsWith(APP_NAME) && line.contains("REG_SZ")) {
                    int idx = line.indexOf("REG_SZ");
                    return line.substring(idx + 6).trim();
                }
            }
        } catch (Exception ignored) {
        }
        return null;
    }

    private static void run(String... cmd) throws Exception {
        Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
        try (Scanner scanner = new Scanner(p.getInputStream(), StandardCharsets.UTF_8.name()).useDelimiter("\\A")) {
            if (scanner.hasNext()) {
                scanner.next();
            }
        }
        p.waitFor();
    }

    /** 从 jar 运行时返回 "javaw -jar \"xx.jar\"",否则返回类路径目录信息 */
    private static String getLaunchCommand() {
        try {
            java.net.URI uri = AutoStartService.class.getProtectionDomain()
                    .getCodeSource().getLocation().toURI();
            java.nio.file.Path path = java.nio.file.Paths.get(uri);
            if (java.nio.file.Files.isRegularFile(path)) {
                return "javaw -jar \"" + path.toAbsolutePath().toString() + "\"";
            }
        } catch (Exception ignored) {
        }
        return "";
    }
}

service/DiskInfoService.java

package com.autoweld.imagecleaner.service;

import java.io.File;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import com.autoweld.imagecleaner.model.DiskInfo;

/** 磁盘空间查询 */
public class DiskInfoService {

    public DiskInfo getDiskInfo(String folderPath) {
        DiskInfo result = new DiskInfo();
        if (folderPath == null || folderPath.trim().isEmpty()) {
            return result;
        }
        try {
            Path path = Paths.get(folderPath);
            if (!path.isAbsolute()) {
                path = path.toAbsolutePath().normalize();
            }
            Path root = path.getRoot();
            if (root == null) {
                return result;
            }
            FileStore store = Files.getFileStore(root);
            result.setDriveLetter(root.toString().replace("\\", "").replace("/", ""));
            result.setFreeSpaceGb(round2(store.getUsableSpace() / 1024.0 / 1024.0 / 1024.0));
            result.setTotalSpaceGb(round2(store.getTotalSpace() / 1024.0 / 1024.0 / 1024.0));
            result.setValid(true);
        } catch (Exception ignored) {
        }
        return result;
    }

    private static double round2(double v) {
        return Math.round(v * 100.0) / 100.0;
    }
}

service/MonitorEngine.java

package com.autoweld.imagecleaner.service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import com.autoweld.imagecleaner.model.AppConfig;
import com.autoweld.imagecleaner.model.GlobalMonitorStats;
import com.autoweld.imagecleaner.model.MonitorFolderProfile;
import com.autoweld.imagecleaner.model.MonitorStats;

/** 监控调度引擎:500ms 检查一次各目录是否到期,到期的在线程池中执行 */
public class MonitorEngine implements AutoCloseable {

    private final Object sync = new Object();
    private final Map<String, FolderWorker> workers = new HashMap<>();
    private final ScriptService scriptService;
    private final ScheduledExecutorService scheduler =
            Executors.newSingleThreadScheduledExecutor(r -> {
                Thread t = new Thread(r, "monitor-scheduler");
                t.setDaemon(true);
                return t;
            });
    private final List<Runnable> statsListeners = new ArrayList<>();

    private volatile AppConfig config;
    private volatile boolean monitoring;

    public MonitorEngine(ScriptService scriptService) {
        this.scriptService = scriptService;
    }

    public boolean isMonitoring() {
        return monitoring;
    }

    public void addStatsListener(Runnable listener) {
        synchronized (sync) {
            statsListeners.add(listener);
        }
    }

    public void start(AppConfig cfg) {
        synchronized (sync) {
            config = cfg;
            syncWorkers();
            monitoring = true;
        }
        LogService.getInstance().info(cfg.getCleanupMode() == com.autoweld.imagecleaner.model.CleanupMode.Folder
                ? "监控已启动(文件清理,前缀 " + cfg.getEffectiveFolderPrefix() + ")"
                : "监控已启动(图片清理)");
    }

    public void stop() {
        monitoring = false;
        LogService.getInstance().info("监控已停止");
    }

    public void updateConfig(AppConfig cfg) {
        synchronized (sync) {
            config = cfg;
            syncWorkers();
        }
    }

    public void startScheduler() {
        scheduler.scheduleWithFixedDelay(this::onSchedulerTick, 0, 500, TimeUnit.MILLISECONDS);
    }

    public MonitorStats getStats(String folderId) {
        synchronized (sync) {
            FolderWorker worker = workers.get(folderId);
            if (worker != null) {
                return worker.getLastStats();
            }
        }
        MonitorStats stats = new MonitorStats();
        stats.setFolderId(folderId);
        return stats;
    }

    public GlobalMonitorStats getGlobalStats() {
        synchronized (sync) {
            GlobalMonitorStats stats = new GlobalMonitorStats();
            stats.setMonitoring(monitoring);
            AppConfig cfg = config;
            if (cfg != null) {
                stats.setTotalFolders(cfg.getFolders().size());
                int enabled = 0;
                for (MonitorFolderProfile f : cfg.getFolders()) {
                    if (f.isEnabled()) {
                        enabled++;
                    }
                }
                stats.setEnabledFolders(enabled);
            }
            int total = 0;
            for (FolderWorker w : workers.values()) {
                total += w.getLastStats().getImageCount();
            }
            stats.setTotalImageCount(total);
            return stats;
        }
    }

    public List<MonitorStats> getAllStats() {
        synchronized (sync) {
            List<MonitorStats> list = new ArrayList<>();
            for (FolderWorker w : workers.values()) {
                list.add(w.getLastStats());
            }
            return list;
        }
    }

    private void syncWorkers() {
        AppConfig cfg = config;
        if (cfg == null || cfg.getFolders() == null) {
            return;
        }
        Set<String> activeIds = new HashSet<>();
        for (MonitorFolderProfile f : cfg.getFolders()) {
            if (f.isEnabled()) {
                activeIds.add(f.getId());
            }
        }
        workers.keySet().removeIf(id -> !activeIds.contains(id));

        for (MonitorFolderProfile folder : cfg.getFolders()) {
            if (!folder.isEnabled()) {
                continue;
            }
            com.autoweld.imagecleaner.model.DeleteConditions effective =
                    cfg.getEffectiveDeleteConditions(folder);
            FolderWorker worker = workers.get(folder.getId());
            if (worker != null) {
                worker.updateProfile(folder, effective, cfg.getCleanupMode(), cfg.getEffectiveFolderPrefix());
            } else {
                workers.put(folder.getId(), new FolderWorker(
                        folder, scriptService, effective, cfg.getCleanupMode(), cfg.getEffectiveFolderPrefix()));
            }
        }
    }

    private void onSchedulerTick() {
        if (!monitoring) {
            return;
        }
        List<FolderWorker> dueWorkers;
        synchronized (sync) {
            if (config == null || config.getFolders() == null) {
                return;
            }
            long now = System.currentTimeMillis();
            dueWorkers = new ArrayList<>();
            for (FolderWorker w : workers.values()) {
                int intervalSeconds = Math.max(1, w.getProfile().getMonitorIntervalSeconds());
                if (now - w.getLastRunUtcMs() >= intervalSeconds * 1000L) {
                    dueWorkers.add(w);
                }
            }
        }
        for (FolderWorker worker : dueWorkers) {
            try {
                worker.tryRun();
                fireStatsUpdated();
            } catch (Exception ex) {
                LogService.getInstance().error("监控任务异常: " + ex);
            }
        }
    }

    private void fireStatsUpdated() {
        List<Runnable> listeners;
        synchronized (sync) {
            listeners = new ArrayList<>(statsListeners);
        }
        for (Runnable r : listeners) {
            try {
                r.run();
            } catch (Exception ignored) {
            }
        }
    }

    @Override
    public void close() {
        scheduler.shutdownNow();
        scriptService.close();
    }
}

service/FolderWorker.java

package com.autoweld.imagecleaner.service;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import com.autoweld.imagecleaner.model.CleanupMode;
import com.autoweld.imagecleaner.model.CleanupTarget;
import com.autoweld.imagecleaner.model.DeleteConditions;
import com.autoweld.imagecleaner.model.DiskInfo;
import com.autoweld.imagecleaner.model.MonitorFolderProfile;
import com.autoweld.imagecleaner.model.MonitorStats;

/** 单个监控目录的扫描+删除执行器,防重入 */
class FolderWorker {

    private final MonitorFolderProfile profile;
    private final ImageScanner imageScanner = new ImageScanner();
    private final FolderScanner folderScanner = new FolderScanner();
    private final DeleteConditionEvaluator evaluator;
    private final DiskInfoService diskInfoService = new DiskInfoService();

    private CleanupMode cleanupMode = CleanupMode.Image;
    private String folderPrefix = "MX";
    private final AtomicInteger isRunning = new AtomicInteger();

    private volatile MonitorStats lastStats = new MonitorStats();
    private volatile long lastRunUtcMs;

    FolderWorker(MonitorFolderProfile profile, ScriptService scriptService,
                 DeleteConditions effectiveDeleteConditions, CleanupMode cleanupMode, String folderPrefix) {
        this.profile = profile.copy();
        applyEffectiveDeleteConditions(effectiveDeleteConditions);
        applyCleanupSettings(cleanupMode, folderPrefix);
        this.evaluator = new DeleteConditionEvaluator(scriptService);
        this.lastStats.setFolderId(profile.getId());
        this.lastStats.setPath(profile.getPath());
    }

    MonitorFolderProfile getProfile() {
        return profile;
    }

    long getLastRunUtcMs() {
        return lastRunUtcMs;
    }

    MonitorStats getLastStats() {
        return lastStats;
    }

    void updateProfile(MonitorFolderProfile newProfile, DeleteConditions effectiveDeleteConditions,
                       CleanupMode cleanupMode, String folderPrefix) {
        profile.setId(newProfile.getId());
        profile.setPath(newProfile.getPath());
        profile.setEnabled(newProfile.isEnabled());
        profile.setMonitorIntervalSeconds(newProfile.getMonitorIntervalSeconds());
        profile.setIncludeSubdirectories(newProfile.isIncludeSubdirectories());
        applyEffectiveDeleteConditions(effectiveDeleteConditions);
        applyCleanupSettings(cleanupMode, folderPrefix);
        lastStats.setPath(newProfile.getPath());
    }

    private void applyEffectiveDeleteConditions(DeleteConditions c) {
        profile.setDeleteConditions((c == null ? new DeleteConditions() : c).copy());
    }

    private void applyCleanupSettings(CleanupMode mode, String prefix) {
        cleanupMode = mode == null ? CleanupMode.Image : mode;
        folderPrefix = (prefix == null || prefix.trim().isEmpty()) ? "MX" : prefix.trim();
    }

    boolean tryRun() {
        if (isRunning.compareAndSet(0, 1) == false) {
            return false;
        }
        try {
            long previousRun = lastRunUtcMs;
            runInternal();
            lastRunUtcMs = System.currentTimeMillis();
            if (previousRun != 0) {
                lastStats.setActualIntervalMs(lastRunUtcMs - previousRun);
            }
            return true;
        } catch (Exception ex) {
            LogService.getInstance().error("路径任务失败 [" + profile.getPath() + "]: " + ex.getMessage());
            return false;
        } finally {
            isRunning.set(0);
        }
    }

    private void runInternal() {
        if (cleanupMode == CleanupMode.Folder) {
            runFolderCleanup();
        } else {
            runImageCleanup();
        }
    }

    private void runImageCleanup() {
        ScanResult scan = imageScanner.scan(profile);
        DiskInfo disk = diskInfoService.getDiskInfo(profile.getPath());
        List<CleanupTarget> items = new ArrayList<>(scan.items);
        int deleted = 0;
        if (profile.isEnabled() && !items.isEmpty()) {
            deleted = deleteExpiredImages(items, disk);
        }
        applyStats(items.size(), scan.elapsedMs, deleted);
    }

    private void runFolderCleanup() {
        ScanResult scan = folderScanner.scan(profile, folderPrefix);
        DiskInfo disk = diskInfoService.getDiskInfo(profile.getPath());
        List<CleanupTarget> items = new ArrayList<>(scan.items);
        int deleted = 0;
        if (profile.isEnabled() && !items.isEmpty()) {
            deleted = deleteExpiredFolders(items, disk);
        }
        applyStats(items.size(), scan.elapsedMs, deleted);
    }

    private void applyStats(int remainingCount, long scanElapsedMs, int deleted) {
        DiskInfo disk = diskInfoService.getDiskInfo(profile.getPath());
        MonitorStats stats = new MonitorStats();
        stats.setFolderId(profile.getId());
        stats.setPath(profile.getPath());
        stats.setImageCount(remainingCount);
        stats.setScanElapsedMs(scanElapsedMs);
        stats.setLastScanUtcMs(System.currentTimeMillis());
        stats.setDriveLetter(disk.getDriveLetter());
        stats.setFreeSpaceGb(disk.getFreeSpaceGb());
        stats.setTotalSpaceGb(disk.getTotalSpaceGb());
        stats.setDeletedCountLastRun(deleted);
        stats.setActualIntervalMs(lastStats.getActualIntervalMs());
        lastStats = stats;
    }

    private int deleteExpiredImages(List<CleanupTarget> files, DiskInfo disk) {
        int deleted = 0;
        DeleteEvaluationResult evaluation = evaluator.evaluate(profile, files, disk, cleanupMode);
        while (evaluation.allowDelete && !evaluation.expiredFiles.isEmpty()) {
            CleanupTarget target = evaluation.expiredFiles.get(0);
            if (tryDeleteFile(target.getFullPath())) {
                files.remove(target);
                deleted++;
                disk = diskInfoService.getDiskInfo(profile.getPath());
                evaluation = evaluator.evaluate(profile, files, disk, cleanupMode);
            } else {
                files.remove(target);
                evaluation = evaluator.evaluate(profile, files, disk, cleanupMode);
            }
        }
        return deleted;
    }

    private int deleteExpiredFolders(List<CleanupTarget> folders, DiskInfo disk) {
        int deleted = 0;
        DeleteEvaluationResult evaluation = evaluateFolders(folders, disk);
        while (evaluation.allowDelete && !evaluation.expiredFiles.isEmpty()) {
            CleanupTarget target = evaluation.expiredFiles.get(0);
            if (tryDeleteFolder(target.getFullPath())) {
                String removed = target.getFullPath();
                folders.removeIf(item -> FolderScanner.isSameOrUnder(item.getFullPath(), removed));
                deleted++;
                disk = diskInfoService.getDiskInfo(profile.getPath());
                evaluation = evaluateFolders(folders, disk);
            } else {
                folders.remove(target);
                evaluation = evaluateFolders(folders, disk);
            }
        }
        return deleted;
    }

    private DeleteEvaluationResult evaluateFolders(List<CleanupTarget> folders, DiskInfo disk) {
        DeleteEvaluationResult result = evaluator.evaluate(profile, folders, disk, cleanupMode);
        result.expiredFiles.sort(Comparator.comparingInt(CleanupTarget::getDepth).reversed()
                .thenComparingLong(CleanupTarget::getCreationTimeUtcMs));
        return result;
    }

    private boolean tryDeleteFile(String path) {
        try {
            File f = new File(path);
            if (!f.isFile()) {
                return false;
            }
            if (!f.delete()) {
                throw new IOException("delete 返回 false");
            }
            LogService.getInstance().delete(profile.getPath(), path);
            return true;
        } catch (Exception ex) {
            LogService.getInstance().warn("删除失败 [" + path + "]: " + ex.getMessage());
            return false;
        }
    }

    private boolean tryDeleteFolder(String path) {
        try {
            if (FolderScanner.isSamePath(path, profile.getPath())) {
                LogService.getInstance().warn("跳过监控根目录,永不删除 [" + path + "]");
                return false;
            }
            File dir = new File(path);
            if (!dir.isDirectory()) {
                return false;
            }
            deleteRecursively(dir);
            LogService.getInstance().delete(profile.getPath(), path);
            return true;
        } catch (Exception ex) {
            LogService.getInstance().warn("删除失败 [" + path + "]: " + ex.getMessage());
            return false;
        }
    }

    private static void deleteRecursively(File f) throws IOException {
        File[] children = f.listFiles();
        if (children != null) {
            for (File child : children) {
                deleteRecursively(child);
            }
        }
        if (!f.delete()) {
            throw new IOException("delete 返回 false: " + f.getAbsolutePath());
        }
    }
}

service/ImageScanner.java

package com.autoweld.imagecleaner.service;

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import com.autoweld.imagecleaner.model.CleanupTarget;
import com.autoweld.imagecleaner.model.MonitorFolderProfile;

/** 扫描结果:目标列表 + 耗时 */
class ScanResult {
    final List<CleanupTarget> items;
    final long elapsedMs;

    ScanResult(List<CleanupTarget> items, long elapsedMs) {
        this.items = items;
        this.elapsedMs = elapsedMs;
    }
}

/** 图片文件扫描(jpg/jpeg/png/bmp/tif/tiff/gif/webp),按创建时间升序 */
class ImageScanner {

    private static final Set<String> IMAGE_EXTENSIONS = new HashSet<>(Arrays.asList(
            ".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".gif", ".webp"));

    ScanResult scan(MonitorFolderProfile profile) {
        long start = System.nanoTime();
        List<CleanupTarget> files = new ArrayList<>();
        File dir = new File(profile.getPath());
        if (profile.getPath().trim().isEmpty() || !dir.isDirectory()) {
            return new ScanResult(files, elapsedMs(start));
        }
        try {
            collect(dir, profile.isIncludeSubdirectories(), files);
        } catch (Exception ex) {
            LogService.getInstance().warn("扫描路径失败 [" + profile.getPath() + "]: " + ex.getMessage());
        }
        files.sort(Comparator.comparingLong(CleanupTarget::getCreationTimeUtcMs));
        return new ScanResult(files, elapsedMs(start));
    }

    private static void collect(File dir, boolean recursive, List<CleanupTarget> out) {
        File[] children = dir.listFiles();
        if (children == null) {
            return;
        }
        for (File f : children) {
            try {
                if (f.isFile()) {
                    String name = f.getName();
                    int dot = name.lastIndexOf('.');
                    if (dot >= 0 && IMAGE_EXTENSIONS.contains(name.substring(dot).toLowerCase(Locale.ROOT))) {
                        CleanupTarget t = new CleanupTarget();
                        t.setFullPath(f.getAbsolutePath());
                        t.setCreationTimeUtcMs(creationTimeUtc(f));
                        out.add(t);
                    }
                } else if (recursive && f.isDirectory()) {
                    collect(f, recursive, out);
                }
            } catch (Exception ignored) {
            }
        }
    }

    static long creationTimeUtc(File f) {
        try {
            java.nio.file.attribute.BasicFileAttributes attrs = java.nio.file.Files.readAttributes(
                    f.toPath(), java.nio.file.attribute.BasicFileAttributes.class);
            return attrs.creationTime().toMillis();
        } catch (Exception e) {
            return f.lastModified();
        }
    }

    static long elapsedMs(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }
}

service/FolderScanner.java

package com.autoweld.imagecleaner.service;

import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;

import com.autoweld.imagecleaner.model.CleanupTarget;
import com.autoweld.imagecleaner.model.MonitorFolderProfile;

/** 前缀文件夹扫描:只收集目录名以指定前缀(默认 MX)开头的文件夹,深度优先按层级降序、创建时间升序 */
class FolderScanner {

    ScanResult scan(MonitorFolderProfile profile, String prefix) {
        long start = System.nanoTime();
        List<CleanupTarget> folders = new ArrayList<>();
        String matchPrefix = (prefix == null || prefix.trim().isEmpty()) ? "MX" : prefix.trim();
        File root = new File(profile.getPath());
        if (profile.getPath().trim().isEmpty() || !root.isDirectory()) {
            return new ScanResult(folders, ImageScanner.elapsedMs(start));
        }
        try {
            String normalizedRoot = normalizePath(root.getAbsolutePath());
            collect(normalizedRoot, normalizedRoot, matchPrefix, profile.isIncludeSubdirectories(), folders);
        } catch (Exception ex) {
            LogService.getInstance().warn("扫描文件夹失败 [" + profile.getPath() + "]: " + ex.getMessage());
        }
        folders.sort(Comparator.comparingInt(CleanupTarget::getDepth).reversed()
                .thenComparingLong(CleanupTarget::getCreationTimeUtcMs));
        return new ScanResult(folders, ImageScanner.elapsedMs(start));
    }

    private static void collect(String currentPath, String rootPath, String prefix,
                                boolean recursive, List<CleanupTarget> results) {
        File current = new File(currentPath);
        File[] children = current.listFiles();
        if (children == null) {
            return;
        }
        for (File dir : children) {
            try {
                if (!dir.isDirectory()) {
                    continue;
                }
                String fullPath = normalizePath(dir.getAbsolutePath());
                if (isSamePath(fullPath, rootPath)) {
                    continue;
                }
                String name = dir.getName();
                if (!name.isEmpty() && name.toLowerCase(Locale.ROOT)
                        .startsWith(prefix.toLowerCase(Locale.ROOT))) {
                    CleanupTarget t = new CleanupTarget();
                    t.setFullPath(fullPath);
                    t.setCreationTimeUtcMs(ImageScanner.creationTimeUtc(dir));
                    t.setDepth(getDepth(rootPath, fullPath));
                    results.add(t);
                }
                if (recursive) {
                    collect(fullPath, rootPath, prefix, true, results);
                }
            } catch (Exception ignored) {
            }
        }
    }

    static String normalizePath(String path) {
        if (path == null || path.trim().isEmpty()) {
            return "";
        }
        String p = new File(path).getAbsolutePath();
        while (p.endsWith("\\") || p.endsWith("/")) {
            p = p.substring(0, p.length() - 1);
        }
        return p;
    }

    static boolean isSamePath(String a, String b) {
        return normalizePath(a).equalsIgnoreCase(normalizePath(b));
    }

    static boolean isSameOrUnder(String path, String parent) {
        String normParent = normalizePath(parent);
        String normPath = normalizePath(path);
        if (normParent.isEmpty() || normPath.isEmpty()) {
            return false;
        }
        if (normPath.equalsIgnoreCase(normParent)) {
            return true;
        }
        String prefix = normParent + File.separator;
        return normPath.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT));
    }

    private static int getDepth(String root, String fullPath) {
        String normRoot = normalizePath(root);
        String normFull = normalizePath(fullPath);
        if (normFull.length() <= normRoot.length()) {
            return 0;
        }
        String relative = normFull.substring(normRoot.length());
        relative = relative.replace('\\', '/').replaceAll("^/+", "");
        if (relative.isEmpty()) {
            return 0;
        }
        int depth = 0;
        for (int i = 0; i < relative.length(); i++) {
            if (relative.charAt(i) == '/') {
                depth++;
            }
        }
        return depth + 1;
    }
}

service/DeleteConditionEvaluator.java

package com.autoweld.imagecleaner.service;

import java.util.ArrayList;
import java.util.List;

import com.autoweld.imagecleaner.model.CleanupTarget;
import com.autoweld.imagecleaner.model.DeleteConditions;
import com.autoweld.imagecleaner.model.DiskInfo;
import com.autoweld.imagecleaner.model.MonitorFolderProfile;

/** 删除条件评估结果 */
class DeleteEvaluationResult {
    boolean ageConditionMet;
    boolean imageCountConditionMet;
    boolean diskSpaceConditionMet;
    boolean allowDelete;
    final List<CleanupTarget> expiredFiles = new ArrayList<>();
}

/**
 * 删除条件评估(C# 原版 AND 逻辑):
 * AllowDelete = 存在过期目标 AND (未启用数量条件 OR 数量>阈值) AND (未启用磁盘条件 OR 余量<阈值)
 * 若配置了 QuickJS 判定脚本,则以脚本返回值为准。
 */
class DeleteConditionEvaluator {

    private final ScriptService scriptService;

    DeleteConditionEvaluator(ScriptService scriptService) {
        this.scriptService = scriptService;
    }

    DeleteEvaluationResult evaluate(MonitorFolderProfile profile, List<CleanupTarget> items, DiskInfo diskInfo,
                                    com.autoweld.imagecleaner.model.CleanupMode cleanupMode) {
        DeleteConditions conditions = profile.getDeleteConditions();
        long nowMs = System.currentTimeMillis();
        double thresholdSeconds = Math.max(1.0, conditions.getStorageTimeSeconds());
        long thresholdMs = (long) (thresholdSeconds * 1000.0);

        DeleteEvaluationResult result = new DeleteEvaluationResult();
        for (CleanupTarget f : items) {
            if (nowMs - f.getCreationTimeUtcMs() > thresholdMs) {
                result.expiredFiles.add(f);
            }
        }
        result.expiredFiles.sort(ComparatorHolder.INSTANCE);
        result.ageConditionMet = !result.expiredFiles.isEmpty();
        result.imageCountConditionMet = !conditions.isImageCountEnabled()
                || items.size() > conditions.getImageCountThreshold();
        result.diskSpaceConditionMet = !conditions.isDiskSpaceEnabled()
                || diskInfo.getFreeSpaceGb() < conditions.getDiskSpaceThresholdGb();

        Boolean scriptDecision = scriptService == null ? null
                : scriptService.evaluateDecision(buildContext(profile, items, diskInfo, result, nowMs, cleanupMode));
        result.allowDelete = scriptDecision != null
                ? scriptDecision
                : (result.ageConditionMet && result.imageCountConditionMet && result.diskSpaceConditionMet);
        return result;
    }

    private static java.util.Map<String, Object> buildContext(
            MonitorFolderProfile profile, List<CleanupTarget> items, DiskInfo diskInfo,
            DeleteEvaluationResult result, long nowMs, com.autoweld.imagecleaner.model.CleanupMode cleanupMode) {
        DeleteConditions c = profile.getDeleteConditions();
        java.util.Map<String, Object> ctx = new java.util.HashMap<>();
        ctx.put("nowMs", nowMs);
        ctx.put("path", profile.getPath());
        ctx.put("cleanupMode", cleanupMode.name());
        ctx.put("storageTimeSeconds", c.getStorageTimeSeconds());
        ctx.put("expiredCount", result.expiredFiles.size());
        ctx.put("imageCount", items.size());
        ctx.put("imageCountEnabled", c.isImageCountEnabled());
        ctx.put("imageCountThreshold", c.getImageCountThreshold());
        ctx.put("freeSpaceGb", diskInfo.getFreeSpaceGb());
        ctx.put("diskSpaceEnabled", c.isDiskSpaceEnabled());
        ctx.put("diskSpaceThresholdGb", c.getDiskSpaceThresholdGb());
        return ctx;
    }

    /** 过期目标排序:创建时间升序(同级再按层级降序) */
    private static final class ComparatorHolder {
        static final java.util.Comparator<CleanupTarget> INSTANCE =
                java.util.Comparator.comparingLong(CleanupTarget::getCreationTimeUtcMs)
                        .thenComparing(java.util.Comparator.comparingInt(CleanupTarget::getDepth).reversed());
    }
}

service/ScriptService.java

package com.autoweld.imagecleaner.service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import com.google.gson.Gson;

import cn.net.zhijian.quickjs.JSFunction;
import cn.net.zhijian.quickjs.QuickJSContext;
import cn.net.zhijian.quickjs.QuickJSException;

/**
 * QuickJS 删除判定脚本服务。
 * 脚本约定:定义 evaluate(context) 函数,返回 true/false 表示是否允许继续删除。
 * 脚本来源优先级:程序目录 delete_conditions.js 文件 > 配置 DeleteScript 字段 > 无(用内置 AND 逻辑)。
 *
 * QuickJS 上下文是单线程的,因此所有脚本操作都固定在一个专用线程上执行。
 */
public class ScriptService implements AutoCloseable {

    private static final Gson GSON = new Gson();
    private static final String SCRIPT_FILE_NAME = "delete_conditions.js";

    /** 内置默认判定脚本(等价于未放置脚本时的 AND 逻辑) */
    public static final String DEFAULT_SCRIPT =
            "// 删除判定脚本:定义 evaluate(context),返回 true/false 是否允许继续删除最旧目标\n"
                    + "function evaluate(c) {\n"
                    + "    var ageMet = c.expiredCount > 0;\n"
                    + "    var countMet = !c.imageCountEnabled || c.imageCount > c.imageCountThreshold;\n"
                    + "    var diskMet = !c.diskSpaceEnabled || c.freeSpaceGb < c.diskSpaceThresholdGb;\n"
                    + "    return ageMet && countMet && diskMet;\n"
                    + "}\n";

    /**
     * 控制台垫片:QuickJS 原生包装器的 console 多参数序列化有缺陷(数字丢失、对象错乱),
     * 在用户脚本执行前注入,把多参数拼接成单个字符串再转发给原生 console。
     */
    private static final String CONSOLE_PRELUDE =
            "try {\n"
                    + "  if (typeof console !== 'undefined' && console) {\n"
                    + "    (function () {\n"
                    + "      function fmt(v) {\n"
                    + "        if (v === null) return 'null';\n"
                    + "        if (v === undefined) return 'undefined';\n"
                    + "        if (typeof v === 'object') {\n"
                    + "          try { return JSON.stringify(v); } catch (e) { return String(v); }\n"
                    + "        }\n"
                    + "        return String(v);\n"
                    + "      }\n"
                    + "      function join(a) {\n"
                    + "        var s = '';\n"
                    + "        for (var i = 0; i < a.length; i++) { s += (i ? ' ' : '') + fmt(a[i]); }\n"
                    + "        return s;\n"
                    + "      }\n"
                    + "      var c = console;\n"
                    + "      var L = c.log ? c.log.bind(c) : null;\n"
                    + "      var I = c.info ? c.info.bind(c) : null;\n"
                    + "      var W = c.warn ? c.warn.bind(c) : null;\n"
                    + "      var E = c.error ? c.error.bind(c) : null;\n"
                    + "      var D = c.debug ? c.debug.bind(c) : null;\n"
                    + "      c.log = function () { if (L) L(join(arguments)); };\n"
                    + "      c.info = function () { if (I) I(join(arguments)); else if (L) L(join(arguments)); };\n"
                    + "      c.warn = function () { if (W) W(join(arguments)); else if (L) L(join(arguments)); };\n"
                    + "      c.error = function () { if (E) E(join(arguments)); else if (L) L(join(arguments)); };\n"
                    + "      c.debug = function () { if (D) D(join(arguments)); else if (L) L(join(arguments)); };\n"
                    + "    })();\n"
                    + "  }\n"
                    + "} catch (e) { /* 控制台垫片加载失败不影响脚本本身 */ }\n";

    /** 脚本测试结果 */
    public static final class TestResult {
        public final Boolean decision;
        public final String error;
        public final String console;

        TestResult(Boolean decision, String error, String console) {
            this.decision = decision;
            this.error = error;
            this.console = console;
        }
    }

    private final ExecutorService thread = Executors.newSingleThreadExecutor(r -> {
        Thread t = new Thread(r, "quickjs-script");
        t.setDaemon(true);
        return t;
    });

    private QuickJSContext context;
    private JSFunction evaluateFunction;
    private volatile String lastError;
    private volatile boolean active;

    public ScriptService() {
    }

    public String getLastError() {
        return lastError;
    }

    public boolean isScriptActive() {
        return active;
    }

    /** 根据配置重载脚本(配置或外部文件变化时调用) */
    public void reload(String configScript) {
        submit(() -> {
            closeContext();
            String source = resolveSource(configScript);
            if (source.isEmpty()) {
                active = false;
                return null;
            }
            try {
                context = QuickJSContext.create();
                evalConsolePrelude(context);
                context.evaluate(source, SCRIPT_FILE_NAME);
                Object fn = context.getProperty(context.getGlobalObject(), "evaluate");
                if (fn instanceof JSFunction) {
                    evaluateFunction = (JSFunction) fn;
                    active = true;
                    lastError = null;
                    LogService.getInstance().info("删除判定脚本已加载: " + SCRIPT_FILE_NAME);
                } else {
                    evaluateFunction = null;
                    active = false;
                    lastError = "脚本中未找到 evaluate(context) 函数";
                    LogService.getInstance().warn(lastError);
                }
            } catch (QuickJSException e) {
                evaluateFunction = null;
                active = false;
                lastError = e.getMessage();
                LogService.getInstance().warn("删除判定脚本错误: " + e.getMessage());
            }
            return null;
        });
    }

    /** 调用脚本判定;脚本未加载或出错时返回 null(调用方回退到内置逻辑) */
    public Boolean evaluateDecision(Map<String, Object> ctx) {
        return submit(() -> {
            if (evaluateFunction == null) {
                return null;
            }
            try {
                Object arg = context.parse(GSON.toJson(ctx));
                Object result = evaluateFunction.call(arg);
                if (result instanceof Boolean) {
                    lastError = null;
                    return (Boolean) result;
                }
                lastError = "evaluate(context) 必须返回 true/false";
                return null;
            } catch (QuickJSException e) {
                lastError = e.getMessage();
                LogService.getInstance().warn("删除判定脚本执行错误: " + e.getMessage());
                return null;
            }
        });
    }

    /**
     * 用给定脚本和上下文做一次性测试(不影响当前生效的脚本)。
     * 捕获 console.log/warn/error 输出,返回判定结果。
     */
    public TestResult testEvaluate(String script, Map<String, Object> ctx) {
        return submit(() -> {
            StringBuilder console = new StringBuilder();
            QuickJSContext testContext = null;
            try {
                testContext = QuickJSContext.create();
                QuickJSContext captured = testContext;
                testContext.setConsole(new cn.net.zhijian.quickjs.QuickJSContext.Console() {
                    @Override
                    public void debug(String msg) {
                        console.append("[debug] ").append(msg).append('\n');
                    }

                    @Override
                    public void info(String msg) {
                        console.append("[info] ").append(msg).append('\n');
                    }

                    @Override
                    public void warn(String msg) {
                        console.append("[warn] ").append(msg).append('\n');
                    }

                    @Override
                    public void error(String msg) {
                        console.append("[error] ").append(msg).append('\n');
                    }
                });
                if (script == null || script.trim().isEmpty()) {
                    return new TestResult(null, "脚本为空", "");
                }
                evalConsolePrelude(captured);
                captured.evaluate(script, "test.js");
                Object fn = captured.getProperty(captured.getGlobalObject(), "evaluate");
                if (!(fn instanceof JSFunction)) {
                    return new TestResult(null, "脚本中未找到 evaluate(context) 函数", console.toString());
                }
                Object arg = captured.parse(GSON.toJson(ctx));
                Object result = ((JSFunction) fn).call(arg);
                if (result instanceof Boolean) {
                    return new TestResult((Boolean) result, null, console.toString());
                }
                return new TestResult(null, "evaluate(context) 必须返回 true/false(实际: "
                        + (result == null ? "null" : result.getClass().getSimpleName()) + ")", console.toString());
            } catch (QuickJSException e) {
                return new TestResult(null, e.getMessage(), console.toString());
            } finally {
                if (testContext != null && !testContext.closed()) {
                    try {
                        testContext.close();
                    } catch (Exception ignored) {
                    }
                }
            }
        });
    }

    private <T> T submit(Callable<T> task) {
        try {
            return thread.submit(task).get();
        } catch (Exception e) {
            LogService.getInstance().warn("脚本服务执行失败: " + e.getMessage());
            return null;
        }
    }

    private String resolveSource(String configScript) {
        try {
            Path file = AppPaths.getProgramDirectory().resolve(SCRIPT_FILE_NAME);
            if (Files.exists(file)) {
                return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
            }
        } catch (IOException ignored) {
        }
        return configScript == null ? "" : configScript.trim();
    }

    private void evalConsolePrelude(QuickJSContext ctx) {
        try {
            ctx.evaluate(CONSOLE_PRELUDE, "<console-prelude>");
        } catch (QuickJSException e) {
            LogService.getInstance().warn("控制台垫片加载失败(忽略): " + e.getMessage());
        }
    }

    private void closeContext() {
        if (context != null && !context.closed()) {
            try {
                context.close();
            } catch (Exception ignored) {
            }
        }
        context = null;
        evaluateFunction = null;
    }

    @Override
    public void close() {
        submit(() -> {
            closeContext();
            active = false;
            return null;
        });
        thread.shutdown();
        try {
            thread.awaitTermination(2, TimeUnit.SECONDS);
        } catch (InterruptedException ignored) {
            Thread.currentThread().interrupt();
        }
    }
}

ui/MainFrame.java

package com.autoweld.imagecleaner.ui;

import java.awt.BorderLayout;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.File;

import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JSplitPane;
import javax.swing.JTabbedPane;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;

import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea;
import org.fife.ui.rtextarea.RTextScrollPane;
import org.fife.ui.rsyntaxtextarea.SyntaxConstants;
import org.fife.ui.rsyntaxtextarea.Theme;

import com.autoweld.imagecleaner.config.ConfigManager;
import com.autoweld.imagecleaner.i18n.Lang;
import com.autoweld.imagecleaner.model.AppConfig;
import com.autoweld.imagecleaner.model.CleanupMode;
import com.autoweld.imagecleaner.model.DeleteConditions;
import com.autoweld.imagecleaner.model.GlobalMonitorStats;
import com.autoweld.imagecleaner.model.MonitorFolderProfile;
import com.autoweld.imagecleaner.model.MonitorStats;
import com.autoweld.imagecleaner.model.TimeUnit;
import com.autoweld.imagecleaner.service.AutoStartService;
import com.autoweld.imagecleaner.service.DiskInfoService;
import com.autoweld.imagecleaner.service.LogService;
import com.autoweld.imagecleaner.service.MonitorEngine;
import com.autoweld.imagecleaner.service.ScriptService;
import com.google.gson.Gson;

/** 主窗口,对应 C# MainForm */
public class MainFrame extends JFrame {

    public interface TrayActions {
        void hideToTray();

        boolean isTrayAvailable();
    }

    private final ConfigManager configManager;
    private final MonitorEngine engine;
    private final ScriptService scriptService;
    private final AutoStartService autoStartService = new AutoStartService();
    private final DiskInfoService diskInfoService = new DiskInfoService();
    private TrayActions trayActions;

    private boolean loadingUi;

    // 顶部:清理模式 + 前缀
    private final JComboBox<String> cmbCleanupMode = new JComboBox<>();
    private final JTextFieldFixed txtFolderPrefix = new JTextFieldFixed("MX", 6);
    private final JCheckBox chkEnablePrefixEdit = new JCheckBox();

    // 监控路径
    private final JList<FolderItem> lstFolders = new JList<>();
    private final JButton btnAdd = new JButton();
    private final JButton btnRemove = new JButton();
    private final JButton btnToggleEnabled = new JButton();

    // 监控基本设置
    private final JSpinner numInterval = new JSpinner(new SpinnerNumberModel(2, 1, 86400, 1));
    private final JComboBox<String> cmbSubdirs = new JComboBox<>();

    // 语言 / 运行设置
    private final JComboBox<String> cmbLanguage = new JComboBox<>();
    private final JCheckBox chkBackground = new JCheckBox();
    private final JCheckBox chkMinimizeToTray = new JCheckBox();
    private final JCheckBox chkAutoStartMonitoring = new JCheckBox();
    private final JCheckBox chkAutoStartWindows = new JCheckBox();

    // 删除条件
    private final JRadioButton rdoDeleteShared = new JRadioButton();
    private final JRadioButton rdoDeletePerFolder = new JRadioButton();
    private final JSpinner numStorage = new JSpinner(new SpinnerNumberModel(20, 1, 999999, 1));
    private final JComboBox<String> cmbTimeUnit = new JComboBox<>();
    private final JCheckBox chkImageCount = new JCheckBox();
    private final JSpinner numImageCount = new JSpinner(new SpinnerNumberModel(3000, 1, 99999999, 100));
    private final JCheckBox chkDiskSpace = new JCheckBox();
    private final JSpinner numDiskSpace = new JSpinner(new SpinnerNumberModel(8.0, 0.1, 999999.0, 0.5));
    private final JLabel lblImageCountUnit = new JLabel();
    private final JLabel lblDeleteHint = new JLabel();

    // 信息显示
    private final JLabel valCurrentPath = new JLabel("-");
    private final JLabel valReadTime = new JLabel("-");
    private final JLabel valActualCycle = new JLabel("-");
    private final JLabel valImageCount = new JLabel("-");
    private final JLabel valDisk = new JLabel("-");
    private final JLabel valDiskFree = new JLabel("-");
    private final JLabel valDiskTotal = new JLabel("-");
    private final JLabel valDeleted = new JLabel("-");
    private final JLabel valTotalFolders = new JLabel("0");
    private final JLabel valEnabledFolders = new JLabel("0");
    private final JLabel valTotalImages = new JLabel("0");
    private final JLabel valMonitorStatus = new JLabel();
    private final JLabel valScriptStatus = new JLabel();

    private final JButton btnView = new JButton();
    private final JButton btnToggleMonitor = new JButton();

    private final Timer uiTimer;

    // 脚本编辑页
    private final JTabbedPane tabs = new JTabbedPane();
    private final RSyntaxTextArea txtScript = createCodeEditor(14);
    private final RSyntaxTextArea txtTestContext = createCodeEditor(4);
    private final RSyntaxTextArea txtConsole = createCodeEditor(5);
    private final JLabel lblScriptSource = new JLabel();
    private final JLabel lblScriptWarning = new JLabel();
    private final JLabel lblTestResult = new JLabel("-");
    private final JLabel scriptSourceCaption = new JLabel();
    private final JLabel testContextCaption = new JLabel();
    private final JLabel consoleCaption = new JLabel();
    private final JButton btnScriptApply = new JButton();
    private final JButton btnScriptResetDefault = new JButton();
    private final JButton btnScriptTest = new JButton();
    private static final Gson GSON = new Gson();

    /** 列表项:包装 profile,显示 [启用] 路径 (周期秒) */
    private static final class FolderItem {
        final MonitorFolderProfile profile;

        FolderItem(MonitorFolderProfile profile) {
            this.profile = profile;
        }

        @Override
        public String toString() {
            String flag = profile.isEnabled()
                    ? (Lang.isEnglish() ? "[Enabled] " : "[启用] ")
                    : (Lang.isEnglish() ? "[Disabled] " : "[禁用] ");
            return flag + profile.getPath() + "  (" + profile.getMonitorIntervalSeconds() + " "
                    + Lang.t("Seconds") + ")";
        }
    }

    public MainFrame(ConfigManager configManager, MonitorEngine engine, ScriptService scriptService) {
        this.configManager = configManager;
        this.engine = engine;
        this.scriptService = scriptService;
        setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                onWindowClosing();
            }
        });

        buildLayout();
        wireEvents();

        uiTimer = new Timer(500, e -> refreshInfo());
        uiTimer.start();

        engine.addStatsListener(() -> SwingUtilities.invokeLater(this::refreshInfo));

        loadConfigToUi();
        applyLocalizedText();
        refreshFolderList(true);
        loadSelectedFolderSettings();
        updateMonitorButtonText();
        refreshInfo();
    }

    public void setTrayActions(TrayActions trayActions) {
        this.trayActions = trayActions;
    }

    public boolean isMonitoring() {
        return engine.isMonitoring();
    }

    public boolean shouldStartHidden() {
        if (trayActions != null && !trayActions.isTrayAvailable()) {
            return false;
        }
        AppConfig cfg = configManager.getConfig();
        return cfg.isRunInBackground() && cfg.isAutoStartMonitoring();
    }

    public void showFromTray() {
        setVisible(true);
        setExtendedState(NORMAL);
        toFront();
        requestFocus();
    }

    public void applyLocalizedText() {
        setTitle(Lang.t("AppTitle"));
        chkEnablePrefixEdit.setText(Lang.t("EnablePrefixEdit"));
        btnAdd.setText(Lang.t("AddFolder"));
        btnRemove.setText(Lang.t("RemoveFolder"));
        btnToggleEnabled.setText(Lang.t("ToggleEnabled"));
        chkBackground.setText(Lang.t("RunInBackground"));
        chkMinimizeToTray.setText(Lang.t("MinimizeToTray"));
        chkAutoStartMonitoring.setText(Lang.t("AutoStartMonitoring"));
        chkAutoStartWindows.setText(Lang.t("AutoStartWindows"));
        rdoDeleteShared.setText(Lang.t("DeleteModeShared"));
        rdoDeletePerFolder.setText(Lang.t("DeleteModePerFolder"));
        chkImageCount.setText(Lang.t("ImageCount"));
        chkDiskSpace.setText(Lang.t("DiskSpace"));
        applyModeDependentText();
        rebindLanguageCombo();
        updateMonitorButtonText();
        refreshFolderList(true);
        tabs.setTitleAt(0, Lang.t("TabMonitor"));
        tabs.setTitleAt(1, Lang.t("TabScript"));
        scriptSourceCaption.setText(Lang.t("ScriptSourceLabel") + ":");
        testContextCaption.setText(Lang.t("ScriptTestContext"));
        consoleCaption.setText(Lang.t("ScriptConsole"));
        scriptTestGroup.setBorder(BorderFactory.createTitledBorder(Lang.t("ScriptTestRun")));
        btnScriptApply.setText(Lang.t("ScriptApply"));
        btnScriptResetDefault.setText(Lang.t("ScriptResetDefault"));
        btnScriptTest.setText(Lang.t("ScriptTestRun"));
        updateScriptSourceLabel();
    }

    // ---------- 布局 ----------

    private void buildLayout() {
        JPanel root = new JPanel();
        root.setLayout(new BoxLayout(root, BoxLayout.Y_AXIS));
        root.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));

        root.add(buildTopPanel());
        root.add(buildPathsPanel());
        root.add(buildMonitorPanel());
        root.add(buildLanguagePanel());
        root.add(buildRunPanel());
        root.add(buildDeletePanel());
        root.add(buildInfoPanel());

        JPanel bottom = new JPanel(new BorderLayout());
        bottom.setBorder(BorderFactory.createEmptyBorder(6, 0, 0, 0));
        bottom.add(btnView, BorderLayout.WEST);
        bottom.add(btnToggleMonitor, BorderLayout.EAST);
        root.add(bottom);

        JScrollPane scroll = new JScrollPane(root);
        scroll.setBorder(null);
        scroll.getVerticalScrollBar().setUnitIncrement(16);

        tabs.addTab("", scroll);
        tabs.addTab("", buildScriptPanel());
        add(tabs);

        setMinimumSize(new Dimension(560, 640));
        setSize(640, 800);
        setLocationRelativeTo(null);
    }

    // ---------- 脚本编辑页 ----------

    private static RSyntaxTextArea createCodeEditor(int rows) {
        RSyntaxTextArea area = new RSyntaxTextArea(rows, 40);
        area.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT);
        area.setCodeFoldingEnabled(true);
        area.setTabSize(4);
        try {
            java.io.InputStream in = MainFrame.class.getResourceAsStream(
                    "/org/fife/ui/rsyntaxtextarea/themes/dark.xml");
            if (in != null) {
                Theme theme = Theme.load(in);
                theme.apply(area);
            }
        } catch (Exception ignored) {
        }
        return area;
    }

    private JPanel buildScriptPanel() {
        txtConsole.setEditable(false);
        lblScriptWarning.setForeground(new java.awt.Color(255, 120, 120));

        JPanel p = new JPanel(new BorderLayout(6, 6));
        p.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));

        JPanel top = new JPanel();
        top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
        JPanel sourceRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
        sourceRow.add(scriptSourceCaption);
        sourceRow.add(lblScriptSource);
        top.add(sourceRow);
        top.add(lblScriptWarning);

        RTextScrollPane scriptScroll = new RTextScrollPane(txtScript);
        scriptScroll.setFoldIndicatorEnabled(true);

        p.add(top, BorderLayout.NORTH);
        p.add(scriptScroll, BorderLayout.CENTER);
        p.add(buildScriptTestPanel(), BorderLayout.SOUTH);
        scriptGroup = p;
        return p;
    }

    private JPanel scriptGroup;

    private JPanel buildScriptTestPanel() {
        JPanel p = new JPanel(new BorderLayout(6, 4));
        p.setBorder(BorderFactory.createTitledBorder(""));

        JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
        buttons.add(btnScriptApply);
        buttons.add(btnScriptResetDefault);
        buttons.add(Box.createHorizontalStrut(12));
        buttons.add(btnScriptTest);
        buttons.add(lblTestResult);

        JPanel ctx = new JPanel(new BorderLayout(2, 2));
        ctx.add(testContextCaption, BorderLayout.NORTH);
        ctx.add(new RTextScrollPane(txtTestContext), BorderLayout.CENTER);

        JPanel console = new JPanel(new BorderLayout(2, 2));
        console.add(consoleCaption, BorderLayout.NORTH);
        console.add(new RTextScrollPane(txtConsole), BorderLayout.CENTER);

        JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, ctx, console);
        split.setResizeWeight(0.4);

        p.add(buttons, BorderLayout.NORTH);
        p.add(split, BorderLayout.CENTER);
        scriptTestGroup = p;
        return p;
    }

    private JPanel scriptTestGroup;

    private void onScriptApply() {
        AppConfig cfg = configManager.getConfig();
        cfg.setDeleteScript(txtScript.getText());
        persistConfig(true);
        updateScriptSourceLabel();
        refreshInfo();
    }

    private void onScriptResetDefault() {
        txtScript.setText(ScriptService.DEFAULT_SCRIPT);
        txtScript.setCaretPosition(0);
    }

    private void onScriptTest() {
        final java.util.Map<String, Object> ctx;
        try {
            ctx = GSON.fromJson(txtTestContext.getText(), java.util.Map.class);
        } catch (Exception ex) {
            lblTestResult.setText("JSON: " + ex.getMessage());
            return;
        }
        final String script = txtScript.getText();
        // QuickJS 执行可能阻塞(如脚本死循环),放到后台线程避免卡死界面
        btnScriptTest.setEnabled(false);
        lblTestResult.setText(Lang.t("ScriptTestRunning"));
        Thread t = new Thread(() -> {
            final ScriptService.TestResult result = scriptService.testEvaluate(
                    script, ctx == null ? new java.util.HashMap<>() : ctx);
            SwingUtilities.invokeLater(() -> {
                txtConsole.setText(result.console == null ? "" : result.console);
                txtConsole.setCaretPosition(0);
                if (result.error != null) {
                    lblTestResult.setText(Lang.t("ScriptTestResult") + ": " + result.error);
                } else {
                    lblTestResult.setText(Lang.t("ScriptTestResult") + ": "
                            + (Boolean.TRUE.equals(result.decision)
                            ? Lang.t("ScriptResultTrue") : Lang.t("ScriptResultFalse")));
                }
                btnScriptTest.setEnabled(true);
            });
        }, "script-test");
        t.setDaemon(true);
        t.start();
    }

    private boolean externalScriptFileExists() {
        try {
            // 与 ScriptService 加载外部脚本的位置保持一致:程序目录
            return java.nio.file.Files.exists(
                    com.autoweld.imagecleaner.service.AppPaths.getProgramDirectory()
                            .resolve("delete_conditions.js"));
        } catch (Exception e) {
            return false;
        }
    }

    private void updateScriptSourceLabel() {
        if (externalScriptFileExists()) {
            lblScriptSource.setText(Lang.t("ScriptSourceFile"));
        } else if (scriptService.isScriptActive()) {
            lblScriptSource.setText(Lang.t("ScriptSourceConfig"));
        } else {
            lblScriptSource.setText(Lang.t("ScriptSourceNone"));
        }
        lblScriptWarning.setText(externalScriptFileExists() ? Lang.t("ScriptExternalWarning") : " ");
        lblScriptWarning.setVisible(externalScriptFileExists());
    }

    private JPanel buildTopPanel() {
        JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
        p.add(new JLabel());
        cmbCleanupMode.setPreferredSize(new Dimension(120, 24));
        p.add(cmbCleanupMode);
        p.add(Box.createHorizontalStrut(12));
        p.add(new JLabel());
        txtFolderPrefix.setPreferredSize(new Dimension(70, 24));
        p.add(txtFolderPrefix);
        p.add(chkEnablePrefixEdit);
        modeLabel = (JLabel) p.getComponent(0);
        prefixLabel = (JLabel) p.getComponent(3);
        return p;
    }

    private JLabel modeLabel;
    private JLabel prefixLabel;

    private JPanel buildPathsPanel() {
        JPanel p = new JPanel(new BorderLayout(6, 6));
        p.setBorder(BorderFactory.createTitledBorder(""));
        lstFolders.setVisibleRowCount(5);
        p.add(new JScrollPane(lstFolders), BorderLayout.CENTER);
        JPanel btns = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
        btns.add(btnAdd);
        btns.add(btnRemove);
        btns.add(btnToggleEnabled);
        p.add(btns, BorderLayout.SOUTH);
        pathsGroup = p;
        return p;
    }

    private JPanel pathsGroup;

    private JPanel buildMonitorPanel() {
        JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
        p.setBorder(BorderFactory.createTitledBorder(""));
        intervalLabel = new JLabel();
        p.add(intervalLabel);
        p.add(numInterval);
        intervalUnitLabel = new JLabel();
        p.add(intervalUnitLabel);
        p.add(Box.createHorizontalStrut(16));
        subdirsLabel = new JLabel();
        p.add(subdirsLabel);
        p.add(cmbSubdirs);
        monitorGroup = p;
        return p;
    }

    private JPanel monitorGroup;
    private JLabel intervalLabel;
    private JLabel intervalUnitLabel;
    private JLabel subdirsLabel;

    private JPanel buildLanguagePanel() {
        JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
        p.setBorder(BorderFactory.createTitledBorder(""));
        languageGroup = p;
        p.add(cmbLanguage);
        return p;
    }

    private JPanel languageGroup;

    private JPanel buildRunPanel() {
        JPanel p = new JPanel();
        p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
        p.setBorder(BorderFactory.createTitledBorder(""));
        p.add(chkBackground);
        p.add(chkMinimizeToTray);
        p.add(chkAutoStartMonitoring);
        p.add(chkAutoStartWindows);
        runGroup = p;
        return p;
    }

    private JPanel runGroup;

    private JPanel buildDeletePanel() {
        JPanel p = new JPanel(new GridBagLayout());
        p.setBorder(BorderFactory.createTitledBorder(""));
        GridBagConstraints gc = new GridBagConstraints();
        gc.anchor = GridBagConstraints.WEST;
        gc.insets = new Insets(2, 4, 2, 4);

        ButtonGroup group = new ButtonGroup();
        group.add(rdoDeleteShared);
        group.add(rdoDeletePerFolder);
        gc.gridx = 0; gc.gridy = 0; gc.gridwidth = 4;
        p.add(rdoDeleteShared, gc);
        gc.gridy = 1;
        p.add(rdoDeletePerFolder, gc);

        storageLabel = new JLabel();
        gc.gridy = 2; gc.gridwidth = 1;
        p.add(storageLabel, gc);
        gc.gridx = 1;
        p.add(numStorage, gc);
        gc.gridx = 2;
        p.add(cmbTimeUnit, gc);

        gc.gridy = 3; gc.gridx = 0; gc.gridwidth = 2;
        p.add(chkImageCount, gc);
        gc.gridx = 2; gc.gridwidth = 1;
        p.add(numImageCount, gc);
        gc.gridx = 3;
        p.add(lblImageCountUnit, gc);

        gc.gridy = 4; gc.gridx = 0; gc.gridwidth = 2;
        p.add(chkDiskSpace, gc);
        gc.gridx = 2; gc.gridwidth = 1;
        p.add(numDiskSpace, gc);
        gc.gridx = 3;
        p.add(new JLabel("GB"), gc);

        gc.gridy = 5; gc.gridx = 0; gc.gridwidth = 4;
        Font hintFont = lblDeleteHint.getFont().deriveFont(Font.PLAIN, lblDeleteHint.getFont().getSize() - 2f);
        lblDeleteHint.setFont(hintFont);
        p.add(lblDeleteHint, gc);

        deleteGroup = p;
        return p;
    }

    private JPanel deleteGroup;
    private JLabel storageLabel;

    private JPanel buildInfoPanel() {
        JPanel p = new JPanel(new GridBagLayout());
        p.setBorder(BorderFactory.createTitledBorder(""));
        GridBagConstraints gc = new GridBagConstraints();
        gc.anchor = GridBagConstraints.WEST;
        gc.insets = new Insets(1, 4, 1, 8);

        infoGroup = p;
        currentFolderLabel = new JLabel(); readTimeLabel = new JLabel(); actualCycleLabel = new JLabel();
        imageCountCaption = new JLabel(); monitoredDiskLabel = new JLabel(); diskFreeLabel = new JLabel();
        diskTotalLabel = new JLabel(); deletedLastRunLabel = new JLabel(); globalSummaryLabel = new JLabel();
        totalFoldersLabel = new JLabel(); enabledFoldersLabel = new JLabel(); totalImagesLabel = new JLabel();
        monitorStatusLabel = new JLabel(); scriptStatusLabel = new JLabel();

        int row = 0;
        addInfoRow(p, gc, row++, currentFolderLabel, valCurrentPath);
        addInfoRow(p, gc, row++, readTimeLabel, valReadTime);
        addInfoRow(p, gc, row++, actualCycleLabel, valActualCycle);
        addInfoRow(p, gc, row++, imageCountCaption, valImageCount);
        addInfoRow(p, gc, row++, monitoredDiskLabel, valDisk);
        addInfoRow(p, gc, row++, diskFreeLabel, valDiskFree);
        addInfoRow(p, gc, row++, diskTotalLabel, valDiskTotal);
        addInfoRow(p, gc, row++, deletedLastRunLabel, valDeleted);
        addInfoRow(p, gc, row++, globalSummaryLabel, new JLabel(""));
        addInfoRow(p, gc, row++, totalFoldersLabel, valTotalFolders);
        addInfoRow(p, gc, row++, enabledFoldersLabel, valEnabledFolders);
        addInfoRow(p, gc, row++, totalImagesLabel, valTotalImages);
        addInfoRow(p, gc, row++, monitorStatusLabel, valMonitorStatus);
        addInfoRow(p, gc, row, scriptStatusLabel, valScriptStatus);
        return p;
    }

    private JPanel infoGroup;
    private JLabel currentFolderLabel, readTimeLabel, actualCycleLabel, imageCountCaption;
    private JLabel monitoredDiskLabel, diskFreeLabel, diskTotalLabel, deletedLastRunLabel;
    private JLabel globalSummaryLabel, totalFoldersLabel, enabledFoldersLabel, totalImagesLabel;
    private JLabel monitorStatusLabel, scriptStatusLabel;

    private static void addInfoRow(JPanel p, GridBagConstraints gc, int row, JLabel label, JLabel value) {
        gc.gridx = 0; gc.gridy = row; gc.weightx = 0;
        p.add(label, gc);
        gc.gridx = 1; gc.weightx = 1;
        p.add(value, gc);
    }

    private void applyModeDependentText() {
        boolean folder = isFolderCleanupMode();
        modeLabel.setText(Lang.t("CleanupModeLabel"));
        prefixLabel.setText(Lang.t("FolderPrefix"));
        pathsGroup.setBorder(BorderFactory.createTitledBorder(Lang.t("MonitorPaths")));
        monitorGroup.setBorder(BorderFactory.createTitledBorder(Lang.t("MonitorBasicSettings")));
        intervalLabel.setText(Lang.t("MonitorInterval"));
        intervalUnitLabel.setText(Lang.t("Seconds"));
        subdirsLabel.setText(Lang.t("IncludeSubdirs"));
        languageGroup.setBorder(BorderFactory.createTitledBorder(Lang.t("LanguageSettings")));
        runGroup.setBorder(BorderFactory.createTitledBorder(Lang.t("RunSettings")));
        deleteGroup.setBorder(BorderFactory.createTitledBorder(
                Lang.t(folder ? "FolderDeleteConditions" : "DeleteConditions")));
        lblDeleteHint.setText(Lang.t(folder ? "FolderDeleteConditionsHint" : "DeleteConditionsHint"));
        chkImageCount.setText(Lang.t(folder ? "FolderCount" : "ImageCount"));
        lblImageCountUnit.setText(Lang.t(folder ? "FoldersUnit" : "ImagesUnit"));
        infoGroup.setBorder(BorderFactory.createTitledBorder(Lang.t("InfoDisplay")));
        currentFolderLabel.setText(Lang.t("CurrentFolder"));
        readTimeLabel.setText(Lang.t(folder ? "FolderScanTime" : "ReadTime"));
        actualCycleLabel.setText(Lang.t("ActualCycle"));
        imageCountCaption.setText(Lang.t(folder ? "FolderCountLabel" : "ImageCountLabel"));
        monitoredDiskLabel.setText(Lang.t("MonitoredDisk"));
        diskFreeLabel.setText(Lang.t("DiskFree"));
        diskTotalLabel.setText(Lang.t("DiskTotal"));
        deletedLastRunLabel.setText(Lang.t("DeletedLastRun"));
        globalSummaryLabel.setText(Lang.t("GlobalSummary"));
        totalFoldersLabel.setText(Lang.t("TotalFolders"));
        enabledFoldersLabel.setText(Lang.t("EnabledFolders"));
        totalImagesLabel.setText(Lang.t(folder ? "TotalFolderItems" : "TotalImages"));
        monitorStatusLabel.setText(Lang.t("MonitorStatus"));
        scriptStatusLabel.setText(Lang.t("ScriptStatus"));
        btnView.setText(Lang.t(folder ? "ViewFolders" : "ViewImages"));
    }

    // ---------- 事件 ----------

    private void wireEvents() {
        cmbCleanupMode.addActionListener(e -> {
            if (loadingUi) {
                return;
            }
            boolean folder = cmbCleanupMode.getSelectedIndex() == 1;
            configManager.getConfig().setCleanupMode(folder ? CleanupMode.Folder : CleanupMode.Image);
            applyModeDependentText();
            persistConfig(true);
            refreshInfo();
        });

        txtFolderPrefix.getDocument().addDocumentListener(new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) { onChanged(); }

            @Override
            public void removeUpdate(DocumentEvent e) { onChanged(); }

            @Override
            public void changedUpdate(DocumentEvent e) { onChanged(); }

            private void onChanged() {
                if (!loadingUi) {
                    configManager.getConfig().setFolderPrefix(txtFolderPrefix.getText());
                    persistConfig(true);
                }
            }
        });

        chkEnablePrefixEdit.addActionListener(e ->
                txtFolderPrefix.setEditable(chkEnablePrefixEdit.isSelected()));

        lstFolders.addListSelectionListener(e -> {
            if (!e.getValueIsAdjusting()) {
                loadSelectedFolderSettings();
                refreshInfo();
            }
        });

        btnAdd.addActionListener(e -> addFolder());
        btnRemove.addActionListener(e -> removeSelectedFolder());
        btnToggleEnabled.addActionListener(e -> toggleSelectedEnabled());
        btnView.addActionListener(e -> openSelectedFolder());
        btnToggleMonitor.addActionListener(e -> toggleMonitoring());

        numInterval.addChangeListener(e -> { if (!loadingUi) saveSelectedFolderSettings(); });
        cmbSubdirs.addActionListener(e -> { if (!loadingUi) saveSelectedFolderSettings(); });

        cmbLanguage.addActionListener(e -> {
            if (loadingUi) {
                return;
            }
            configManager.getConfig().setLanguage(cmbLanguage.getSelectedIndex() == 1 ? "en-US" : "zh-CN");
            Lang.init(configManager.getConfig().getLanguage());
            persistConfig(true);
            applyLocalizedText();
            refreshInfo();
        });

        rdoDeleteShared.addActionListener(e -> onDeleteModeChanged());
        rdoDeletePerFolder.addActionListener(e -> onDeleteModeChanged());
        numStorage.addChangeListener(e -> { if (!loadingUi) saveDeleteConditionsFromUi(); });
        cmbTimeUnit.addActionListener(e -> { if (!loadingUi) saveDeleteConditionsFromUi(); });
        chkImageCount.addActionListener(e -> { if (!loadingUi) saveDeleteConditionsFromUi(); });
        numImageCount.addChangeListener(e -> { if (!loadingUi) saveDeleteConditionsFromUi(); });
        chkDiskSpace.addActionListener(e -> { if (!loadingUi) saveDeleteConditionsFromUi(); });
        numDiskSpace.addChangeListener(e -> { if (!loadingUi) saveDeleteConditionsFromUi(); });

        chkBackground.addActionListener(e -> { if (!loadingUi) saveGlobalSettings(); });
        chkMinimizeToTray.addActionListener(e -> { if (!loadingUi) saveGlobalSettings(); });
        chkAutoStartMonitoring.addActionListener(e -> { if (!loadingUi) saveGlobalSettings(); });
        chkAutoStartWindows.addActionListener(e -> {
            if (loadingUi) {
                return;
            }
            autoStartService.setEnabled(chkAutoStartWindows.isSelected());
            saveGlobalSettings();
        });

        btnScriptApply.addActionListener(e -> onScriptApply());
        btnScriptResetDefault.addActionListener(e -> onScriptResetDefault());
        btnScriptTest.addActionListener(e -> onScriptTest());
    }

    private boolean isFolderCleanupMode() {
        return configManager.getConfig().getCleanupMode() == CleanupMode.Folder;
    }

    private void onDeleteModeChanged() {
        if (loadingUi) {
            return;
        }
        configManager.getConfig().setUseSharedDeleteConditions(rdoDeleteShared.isSelected());
        loadSelectedFolderSettings();
        persistConfig(true);
    }

    // ---------- 配置加载/保存 ----------

    private void loadConfigToUi() {
        loadingUi = true;
        try {
            AppConfig cfg = configManager.getConfig();
            Lang.init(cfg.getLanguage());
            rebindCleanupModeCombo();
            cmbCleanupMode.setSelectedIndex(cfg.getCleanupMode() == CleanupMode.Folder ? 1 : 0);
            txtFolderPrefix.setText(cfg.getEffectiveFolderPrefix());
            txtFolderPrefix.setEditable(false);
            chkEnablePrefixEdit.setSelected(false);
            rebindSubdirsCombo();
            rebindTimeUnitCombo();
            rebindLanguageCombo();
            cmbLanguage.setSelectedIndex(Lang.isEnglish() ? 1 : 0);
            chkBackground.setSelected(cfg.isRunInBackground());
            chkMinimizeToTray.setSelected(cfg.isMinimizeToTrayOnClose());
            chkAutoStartMonitoring.setSelected(cfg.isAutoStartMonitoring());
            chkAutoStartWindows.setSelected(autoStartService.isEnabled());
            rdoDeleteShared.setSelected(cfg.isUseSharedDeleteConditions());
            rdoDeletePerFolder.setSelected(!cfg.isUseSharedDeleteConditions());
            txtScript.setText(cfg.getDeleteScript().isEmpty()
                    ? ScriptService.DEFAULT_SCRIPT : cfg.getDeleteScript());
            txtScript.setCaretPosition(0);
            if (txtTestContext.getText().trim().isEmpty()) {
                txtTestContext.setText(defaultTestContextJson());
                txtTestContext.setCaretPosition(0);
            }
            applyModeDependentText();
        } finally {
            loadingUi = false;
        }
    }

    private static String defaultTestContextJson() {
        java.util.Map<String, Object> ctx = new java.util.HashMap<>();
        ctx.put("nowMs", System.currentTimeMillis());
        ctx.put("path", "D:\\capture");
        ctx.put("cleanupMode", "Image");
        ctx.put("storageTimeSeconds", 20.0);
        ctx.put("expiredCount", 5);
        ctx.put("imageCount", 100);
        ctx.put("imageCountEnabled", false);
        ctx.put("imageCountThreshold", 3000);
        ctx.put("freeSpaceGb", 3.5);
        ctx.put("diskSpaceEnabled", false);
        ctx.put("diskSpaceThresholdGb", 8.0);
        return new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(ctx);
    }

    private void rebindCleanupModeCombo() {
        boolean previous = loadingUi;
        loadingUi = true;
        try {
            cmbCleanupMode.removeAllItems();
            cmbCleanupMode.addItem(Lang.t("CleanupModeImage"));
            cmbCleanupMode.addItem(Lang.t("CleanupModeFolder"));
            cmbCleanupMode.setSelectedIndex(
                    configManager.getConfig().getCleanupMode() == CleanupMode.Folder ? 1 : 0);
        } finally {
            loadingUi = previous;
        }
    }

    private void rebindSubdirsCombo() {
        boolean previous = loadingUi;
        loadingUi = true;
        try {
            cmbSubdirs.removeAllItems();
            cmbSubdirs.addItem(Lang.t("Yes"));
            cmbSubdirs.addItem(Lang.t("No"));
        } finally {
            loadingUi = previous;
        }
    }

    private void rebindLanguageCombo() {
        boolean previous = loadingUi;
        loadingUi = true;
        try {
            cmbLanguage.removeAllItems();
            cmbLanguage.addItem("简体中文");
            cmbLanguage.addItem("English");
            cmbLanguage.setSelectedIndex(Lang.isEnglish() ? 1 : 0);
        } finally {
            loadingUi = previous;
        }
    }

    private void rebindTimeUnitCombo() {
        boolean previous = loadingUi;
        loadingUi = true;
        try {
            cmbTimeUnit.removeAllItems();
            cmbTimeUnit.addItem(Lang.t("Seconds"));
            cmbTimeUnit.addItem(TimeUnit.Minutes.name());
            cmbTimeUnit.addItem(TimeUnit.Hours.name());
            cmbTimeUnit.addItem(TimeUnit.Days.name());
        } finally {
            loadingUi = previous;
        }
    }

    private void refreshFolderList(boolean preserveSelection) {
        AppConfig cfg = configManager.getConfig();
        FolderItem selected = lstFolders.getSelectedValue();
        FolderItem[] items = new FolderItem[cfg.getFolders().size()];
        for (int i = 0; i < items.length; i++) {
            items[i] = new FolderItem(cfg.getFolders().get(i));
        }
        lstFolders.setListData(items);
        if (preserveSelection && selected != null) {
            for (int i = 0; i < items.length; i++) {
                if (items[i].profile.getId().equals(selected.profile.getId())) {
                    lstFolders.setSelectedIndex(i);
                    break;
                }
            }
        }
    }

    private MonitorFolderProfile getSelectedProfile() {
        FolderItem item = lstFolders.getSelectedValue();
        return item == null ? null : item.profile;
    }

    private void loadSelectedFolderSettings() {
        MonitorFolderProfile profile = getSelectedProfile();
        loadingUi = true;
        try {
            if (profile == null) {
                numInterval.setValue(2);
                cmbSubdirs.setSelectedIndex(0);
                loadDeleteConditionsToUi(configManager.getConfig().getSharedDeleteConditions());
                setDeleteConditionControlsEnabled(false);
                return;
            }
            numInterval.setValue(Math.max(1, profile.getMonitorIntervalSeconds()));
            cmbSubdirs.setSelectedIndex(profile.isIncludeSubdirectories() ? 0 : 1);
            boolean shared = configManager.getConfig().isUseSharedDeleteConditions();
            loadDeleteConditionsToUi(shared
                    ? configManager.getConfig().getSharedDeleteConditions()
                    : profile.getDeleteConditions());
            setDeleteConditionControlsEnabled(true);
        } finally {
            loadingUi = false;
        }
    }

    private void loadDeleteConditionsToUi(DeleteConditions c) {
        numStorage.setValue(Math.max(1, c.getStorageTimeValue()));
        int unitIndex = c.getStorageTimeUnit().ordinal();
        if (unitIndex >= 0 && unitIndex < cmbTimeUnit.getItemCount()) {
            cmbTimeUnit.setSelectedIndex(unitIndex);
        }
        chkImageCount.setSelected(c.isImageCountEnabled());
        numImageCount.setValue(Math.max(1, c.getImageCountThreshold()));
        chkDiskSpace.setSelected(c.isDiskSpaceEnabled());
        numDiskSpace.setValue(Math.max(0.1, c.getDiskSpaceThresholdGb()));
        setConditionInputsEnabled();
    }

    private void setConditionInputsEnabled() {
        numImageCount.setEnabled(chkImageCount.isSelected());
        numDiskSpace.setEnabled(chkDiskSpace.isSelected());
    }

    private void setDeleteConditionControlsEnabled(boolean enabled) {
        rdoDeleteShared.setEnabled(true);
        rdoDeletePerFolder.setEnabled(true);
        numStorage.setEnabled(enabled);
        cmbTimeUnit.setEnabled(enabled);
        chkImageCount.setEnabled(enabled);
        numImageCount.setEnabled(enabled && chkImageCount.isSelected());
        chkDiskSpace.setEnabled(enabled);
        numDiskSpace.setEnabled(enabled && chkDiskSpace.isSelected());
    }

    private void saveSelectedFolderSettings() {
        MonitorFolderProfile profile = getSelectedProfile();
        if (profile == null || loadingUi) {
            return;
        }
        profile.setMonitorIntervalSeconds(((Number) numInterval.getValue()).intValue());
        profile.setIncludeSubdirectories(cmbSubdirs.getSelectedIndex() == 0);
        saveDeleteConditionsFromUi();
        persistConfig(true);
        refreshFolderList(true);
    }

    private void saveDeleteConditionsFromUi() {
        AppConfig cfg = configManager.getConfig();
        DeleteConditions target = cfg.isUseSharedDeleteConditions()
                ? cfg.getSharedDeleteConditions()
                : (getSelectedProfile() != null ? getSelectedProfile().getDeleteConditions() : null);
        if (target == null) {
            return;
        }
        target.setStorageTimeValue(((Number) numStorage.getValue()).intValue());
        int unitIndex = cmbTimeUnit.getSelectedIndex();
        TimeUnit[] units = TimeUnit.values();
        target.setStorageTimeUnit(units[Math.max(0, Math.min(unitIndex, units.length - 1))]);
        target.setImageCountEnabled(chkImageCount.isSelected());
        target.setImageCountThreshold(((Number) numImageCount.getValue()).intValue());
        target.setDiskSpaceEnabled(chkDiskSpace.isSelected());
        target.setDiskSpaceThresholdGb(((Number) numDiskSpace.getValue()).doubleValue());
        setConditionInputsEnabled();
        persistConfig(true);
    }

    private void saveGlobalSettings() {
        if (loadingUi) {
            return;
        }
        AppConfig cfg = configManager.getConfig();
        cfg.setRunInBackground(chkBackground.isSelected());
        cfg.setMinimizeToTrayOnClose(chkMinimizeToTray.isSelected());
        cfg.setAutoStartMonitoring(chkAutoStartMonitoring.isSelected());
        cfg.setAutoStartWithWindows(chkAutoStartWindows.isSelected());
        persistConfig(true);
    }

    private void persistConfig(boolean updateEngine) {
        configManager.scheduleSave();
        if (updateEngine) {
            AppConfig cfg = configManager.getConfig();
            scriptService.reload(cfg.getDeleteScript());
            engine.updateConfig(cfg);
        }
    }

    // ---------- 路径操作 ----------

    private void addFolder() {
        javax.swing.JFileChooser chooser = new javax.swing.JFileChooser();
        chooser.setFileSelectionMode(javax.swing.JFileChooser.DIRECTORIES_ONLY);
        if (chooser.showOpenDialog(this) == javax.swing.JFileChooser.APPROVE_OPTION) {
            MonitorFolderProfile profile = new MonitorFolderProfile();
            profile.setPath(chooser.getSelectedFile().getAbsolutePath());
            configManager.getConfig().getFolders().add(profile);
            persistConfig(true);
            refreshFolderList(false);
            selectProfile(profile);
        }
    }

    private void selectProfile(MonitorFolderProfile profile) {
        for (int i = 0; i < lstFolders.getModel().getSize(); i++) {
            if (lstFolders.getModel().getElementAt(i).profile.getId().equals(profile.getId())) {
                lstFolders.setSelectedIndex(i);
                break;
            }
        }
    }

    private void removeSelectedFolder() {
        MonitorFolderProfile profile = getSelectedProfile();
        if (profile == null) {
            return;
        }
        int confirm = JOptionPane.showConfirmDialog(this, Lang.t("ConfirmRemoveFolder"),
                Lang.t("AppTitle"), JOptionPane.OK_CANCEL_OPTION);
        if (confirm != JOptionPane.OK_OPTION) {
            return;
        }
        configManager.getConfig().getFolders().remove(profile);
        persistConfig(true);
        refreshFolderList(false);
        loadSelectedFolderSettings();
        refreshInfo();
    }

    private void toggleSelectedEnabled() {
        MonitorFolderProfile profile = getSelectedProfile();
        if (profile == null) {
            return;
        }
        profile.setEnabled(!profile.isEnabled());
        persistConfig(true);
        refreshFolderList(true);
    }

    private void openSelectedFolder() {
        MonitorFolderProfile profile = getSelectedProfile();
        if (profile == null) {
            return;
        }
        try {
            Desktop.getDesktop().open(new File(profile.getPath()));
        } catch (Exception ex) {
            LogService.getInstance().warn(Lang.t("OpenFolderFailed") + " [" + profile.getPath() + "]: " + ex.getMessage());
        }
    }

    // ---------- 监控 ----------

    public void startMonitoring() {
        engine.start(configManager.getConfig());
        if (configManager.getConfig().isRunInBackground() && trayActions != null
                && trayActions.isTrayAvailable()) {
            trayActions.hideToTray();
        }
        updateMonitorButtonText();
        refreshInfo();
    }

    public void stopMonitoring() {
        engine.stop();
        updateMonitorButtonText();
        refreshInfo();
    }

    private void toggleMonitoring() {
        if (engine.isMonitoring()) {
            stopMonitoring();
        } else {
            startMonitoring();
        }
    }

    private void updateMonitorButtonText() {
        btnToggleMonitor.setText(engine.isMonitoring() ? Lang.t("StopMonitoring") : Lang.t("StartMonitoring"));
        valMonitorStatus.setText(engine.isMonitoring() ? Lang.t("StatusRunning") : Lang.t("StatusStopped"));
    }

    private void refreshInfo() {
        MonitorFolderProfile profile = getSelectedProfile();
        if (profile == null) {
            valCurrentPath.setText(Lang.t("SelectFolderHint"));
            valReadTime.setText("-");
            valActualCycle.setText("-");
            valImageCount.setText("-");
            valDisk.setText("-");
            valDiskFree.setText("-");
            valDiskTotal.setText("-");
            valDeleted.setText("-");
        } else {
            MonitorStats stats = engine.getStats(profile.getId());
            com.autoweld.imagecleaner.model.DiskInfo disk = diskInfoService.getDiskInfo(profile.getPath());
            boolean scanned = stats.getLastScanUtcMs() != 0;
            valCurrentPath.setText(profile.getPath());
            valReadTime.setText(scanned ? stats.getScanElapsedMs() + " " + Lang.t("MsUnit") : "-");
            valActualCycle.setText(stats.getActualIntervalMs() > 0 ? stats.getActualIntervalMs() + " " + Lang.t("MsUnit") : "-");
            valImageCount.setText(scanned ? String.valueOf(stats.getImageCount()) : "-");
            valDisk.setText(disk.getDriveLetter().isEmpty() ? "-"
                    : disk.getDriveLetter() + Lang.t("DriveSuffix"));
            valDiskFree.setText(disk.isValid()
                    ? String.format(java.util.Locale.ROOT, "%.2f %s", disk.getFreeSpaceGb(), Lang.t("GbUnit")) : "-");
            valDiskTotal.setText(disk.isValid()
                    ? String.format(java.util.Locale.ROOT, "%.2f %s", disk.getTotalSpaceGb(), Lang.t("GbUnit")) : "-");
            valDeleted.setText(scanned ? String.valueOf(stats.getDeletedCountLastRun()) : "-");
        }
        GlobalMonitorStats global = engine.getGlobalStats();
        valTotalFolders.setText(String.valueOf(global.getTotalFolders()));
        valEnabledFolders.setText(String.valueOf(global.getEnabledFolders()));
        valTotalImages.setText(String.valueOf(global.getTotalImageCount()));
        valMonitorStatus.setText(global.isMonitoring() ? Lang.t("StatusRunning") : Lang.t("StatusStopped"));
        valScriptStatus.setText(scriptService.isScriptActive() ? Lang.t("ScriptActive") : Lang.t("ScriptInactive"));
        updateScriptSourceLabel();
    }

    // ---------- 窗口行为 ----------

    private void onWindowClosing() {
        AppConfig cfg = configManager.getConfig();
        if (cfg.isMinimizeToTrayOnClose() && trayActions != null && trayActions.isTrayAvailable()) {
            trayActions.hideToTray();
            return;
        }
        int confirm = JOptionPane.showConfirmDialog(this, Lang.t("ConfirmExit"),
                Lang.t("AppTitle"), JOptionPane.OK_CANCEL_OPTION);
        if (confirm == JOptionPane.OK_OPTION) {
            configManager.saveNow();
            dispose();
            System.exit(0);
        }
    }

    /** 轻量文本框(避免引入额外 import 混乱) */
    private static final class JTextFieldFixed extends javax.swing.JTextField {
        JTextFieldFixed(String text, int columns) {
            super(text, columns);
        }
    }
}

ui/TrayController.java

package com.autoweld.imagecleaner.ui;

import java.awt.AWTException;
import java.awt.Color;
import java.awt.Font;
import java.awt.GraphicsEnvironment;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.event.ActionListener;

import javax.swing.Timer;
import javax.swing.UIManager;

import com.autoweld.imagecleaner.i18n.Lang;

/**
 * 系统托盘:监控运行时图标绿色闪烁(500ms),右键菜单 显示/启停/退出。
 * 对应 C# TrayApplicationContext 的托盘部分。
 *
 * 中文乱码修复:
 * - Windows 系统托盘原生 PopupMenu/MenuItem 的默认字体可能不包含中文字形(问号乱码),
 *   这里显式选择可用的中文字体(微软雅黑→宋体→Segoe UI→Dialog)为每个菜单项 setFont。
 * - 同时在 App 入口设置 file.encoding/sun.jnu.encoding 的默认值。
 */
public class TrayController {

    public interface Callbacks {
        void onShowWindow();

        void onToggleMonitoring();

        void onExit();

        boolean isMonitoring();
    }

    private final TrayIcon trayIcon;
    private final Image iconIdle;
    private final Image iconActiveA;
    private final Image iconActiveB;
    private final Timer blinkTimer;
    private boolean blinkState;
    private final Callbacks callbacks;
    private MenuItem miToggle;

    public TrayController(Callbacks callbacks) {
        this.callbacks = callbacks;
        this.iconIdle = TrayIconImages.create(Color.GRAY);
        this.iconActiveA = TrayIconImages.create(new Color(0, 180, 70));
        this.iconActiveB = TrayIconImages.create(new Color(160, 255, 170));

        Font menuFont = chooseMenuFont();

        PopupMenu menu = new PopupMenu();
        MenuItem miShow = new MenuItem(Lang.t("TrayShow"));
        miShow.setFont(menuFont);
        miShow.addActionListener(e -> callbacks.onShowWindow());
        miToggle = new MenuItem(Lang.t("TrayStart"));
        miToggle.setFont(menuFont);
        miToggle.addActionListener(e -> callbacks.onToggleMonitoring());
        MenuItem miExit = new MenuItem(Lang.t("TrayExit"));
        miExit.setFont(menuFont);
        miExit.addActionListener(e -> callbacks.onExit());
        menu.add(miShow);
        menu.add(miToggle);
        menu.addSeparator();
        menu.add(miExit);

        trayIcon = new TrayIcon(iconIdle, Lang.t("AppTitle"), menu);
        trayIcon.setImageAutoSize(true);
        trayIcon.addActionListener(e -> callbacks.onShowWindow()); // 双击

        blinkTimer = new Timer(500, e -> {
            blinkState = !blinkState;
            trayIcon.setImage(blinkState ? iconActiveA : iconActiveB);
        });
    }

    /**
     * 选择一个能显示中文的字体(用于 AWT 原生托盘菜单,防止中文显示为问号)。
     * 优先顺序:Microsoft YaHei UI → Microsoft YaHei → SimSun → Dialog
     */
    private static Font chooseMenuFont() {
        String[] candidates = {
                "Microsoft YaHei UI",
                "Microsoft YaHei",
                "微软雅黑", // 微软雅黑(中文名兜底)
                "SimSun",
                "宋体", // 宋体
                "PingFang SC",
                "Source Han Sans CN",
                "Noto Sans CJK SC",
                "Segoe UI",
        };
        try {
            GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
            String[] installed = ge.getAvailableFontFamilyNames();
            java.util.Set<String> set = new java.util.HashSet<>(java.util.Arrays.asList(installed));
            // Swing 默认菜单字体大小(单位:pt)
            int size = Math.max(12, UIManager.getFont("Label.font") == null
                    ? 12 : UIManager.getFont("Label.font").getSize());
            for (String name : candidates) {
                if (set.contains(name)) {
                    return new Font(name, Font.PLAIN, size);
                }
            }
        } catch (Throwable ignored) {
            // 极端情况下直接回退到 Dialog 逻辑字体
        }
        return new Font(Font.DIALOG, Font.PLAIN, 12);
    }

    /** 托盘是否可用并已成功添加 */
    public boolean install() {
        if (!SystemTray.isSupported()) {
            return false;
        }
        try {
            SystemTray.getSystemTray().add(trayIcon);
            return true;
        } catch (AWTException e) {
            return false;
        }
    }

    public void updateState() {
        boolean monitoring = callbacks.isMonitoring();
        miToggle.setLabel(monitoring ? Lang.t("TrayStop") : Lang.t("TrayStart"));
        trayIcon.setToolTip(Lang.t("AppTitle") + " - "
                + (monitoring ? Lang.t("StatusRunning") : Lang.t("StatusStopped")));
        if (monitoring) {
            trayIcon.setImage(iconActiveA);
            blinkTimer.start();
        } else {
            blinkTimer.stop();
            trayIcon.setImage(iconIdle);
        }
    }

    public void dispose() {
        blinkTimer.stop();
        if (SystemTray.isSupported()) {
            SystemTray.getSystemTray().remove(trayIcon);
        }
    }
}

ui/TrayIconImages.java

package com.autoweld.imagecleaner.ui;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;

/** 用 GDI 风格在内存中绘制托盘圆点图标(对应 C# TrayIcons) */
final class TrayIconImages {

    private TrayIconImages() {
    }

    static Image create(Color color) {
        int size = 16;
        BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = img.createGraphics();
        try {
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g.setColor(new Color(0, 0, 0, 0));
            g.fillRect(0, 0, size, size);
            g.setColor(color);
            g.fillOval(1, 1, 13, 13);
            g.setColor(new Color(50, 50, 50));
            g.drawOval(1, 1, 13, 13);
        } finally {
            g.dispose();
        }
        return img;
    }
}

i18n/Lang.java

package com.autoweld.imagecleaner.i18n;

import java.util.Locale;
import java.util.ResourceBundle;

/** 中英文界面文案切换 */
public final class Lang {

    private static final String BASE = "com.autoweld.imagecleaner.i18n.Strings";
    private static ResourceBundle bundle;

    private Lang() {
    }

    public static void init(String language) {
        Locale locale = "en-US".equals(language) ? Locale.US : Locale.SIMPLIFIED_CHINESE;
        Locale.setDefault(locale);
        bundle = ResourceBundle.getBundle(BASE, locale, Lang.class.getClassLoader());
    }

    public static String t(String key) {
        if (bundle == null) {
            init("zh-CN");
        }
        try {
            return bundle.getString(key);
        } catch (Exception e) {
            return key;
        }
    }

    public static boolean isEnglish() {
        return bundle != null && Locale.US.equals(bundle.getLocale());
    }
}

资源文件 Strings_zh_CN.properties

AppTitle=图片清理
AlreadyRunning=图片清理已在运行。
CleanupModeLabel=清理模式
CleanupModeImage=图片清理
CleanupModeFolder=文件夹清理
FolderPrefix=文件夹前缀
EnablePrefixEdit=启用更改
MonitorPaths=监控路径
AddFolder=添加目录
RemoveFolder=删除选中
ToggleEnabled=启用/禁用
ViewImages=查看图片
ViewFolders=查看目录
SelectFolderHint=(未选择目录)
MonitorBasicSettings=监控基本设置
MonitorInterval=监控周期
Seconds=秒
IncludeSubdirs=是否包含子目录
Yes=是
No=否
LanguageSettings=多语言设置
RunSettings=运行设置
RunInBackground=后台运行
MinimizeToTray=关闭窗口时最小化至托盘
AutoStartMonitoring=程序启动自动运行监控
AutoStartWindows=开机自启
DeleteConditions=删图条件设置
DeleteConditionsHint=多条件之间为 AND 关系
FolderDeleteConditions=删文件夹条件设置
FolderDeleteConditionsHint=多条件 AND;保存时间以文件夹创建时间为准;仅删除指定前缀文件夹
DeleteModeShared=统一设置
DeleteModePerFolder=单独设置
StorageTime=保存时间 >
ImageCount=图片数目 >
FolderCount=文件夹数目 >
ImagesUnit=张
FoldersUnit=个
GbUnit=GB
InfoDisplay=信息显示
CurrentFolder=当前路径
ReadTime=读图耗时
FolderScanTime=扫描耗时
ActualCycle=实际周期
ImageCountLabel=图片数目
FolderCountLabel=文件夹数目
MonitoredDisk=被监控盘
DiskFree=磁盘余量
DiskTotal=磁盘总量
DeletedLastRun=上次删除
GlobalSummary=全局汇总
TotalFolders=监控路径数
EnabledFolders=启用路径数
TotalImages=合计图片数
TotalFolderItems=合计文件夹数
MonitorStatus=监控状态
StatusRunning=运行中
StatusStopped=已停止
StartMonitoring=开始监控
StopMonitoring=停止监控
TrayShow=显示窗口
TrayExit=退出
TrayStart=启动监控
TrayStop=停止监控
MsUnit=ms
DriveSuffix=盘
ScriptStatus=判定脚本
ScriptActive=已启用
ScriptInactive=未启用
OpenFolderFailed=无法打开目录
ConfirmRemoveFolder=确定删除选中的监控目录?
ConfirmExit=确定退出图片清理?
TabMonitor=监控设置
TabScript=判定脚本
ScriptSourceLabel=当前生效来源
ScriptSourceFile=外部文件 delete_conditions.js
ScriptSourceConfig=配置(本页脚本)
ScriptSourceNone=内置默认 AND 逻辑
ScriptExternalWarning=检测到外部文件 delete_conditions.js,其优先级高于本页脚本,本页修改不会生效
ScriptApply=保存并应用
ScriptResetDefault=恢复默认脚本
ScriptTestRun=运行测试
ScriptTestContext=测试上下文(JSON)
ScriptTestResult=测试结果
ScriptTestRunning=测试运行中…
ScriptResultTrue=true(允许删除)
ScriptResultFalse=false(停止删除)
ScriptResultNull=未返回布尔值(将回退内置 AND 逻辑)
ScriptConsole=控制台输出

构建脚本 build.gradle

plugins {
    id 'java'
}

group = 'com.autoweld'
version = '1.0.0'

java {
    sourceCompatibility = JavaVersion.VERSION_25
    targetCompatibility = JavaVersion.VERSION_25
}

// 完全离线模式 - 只使用本地libs目录
repositories {
    flatDir {
        dirs 'libs'
    }
}

dependencies {
    // FlatLaf Look and Feel
    implementation name: 'flatlaf-3.4.1'

    // QuickJS for JavaScript scripting
    implementation name: 'cn.net.zhijian.quickjs-0.2.2'

    // RSyntaxTextArea for script editor
    implementation name: 'rsyntaxtextarea-3.3.4'

    // JSON processing
    implementation name: 'gson-2.10.1'
}

def mainClassName = 'com.autoweld.imagecleaner.App'

jar {
    manifest {
        attributes(
            'Main-Class': mainClassName,
            'Implementation-Title': 'ImageCleaner',
            'Implementation-Version': version
        )
    }
    from {
        configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
    }
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

def getTimestamp() {
    def sdf = new java.text.SimpleDateFormat("yyMMddHHmm")
    return sdf.format(new Date())
}

task fatJar(type: Jar) {
    manifest {
        attributes(
            'Main-Class': mainClassName,
            'Implementation-Title': 'ImageCleaner',
            'Implementation-Version': version
        )
    }
    archiveClassifier = 'fat'
    archiveFileName = "imagecleaner-${version}-fat_${getTimestamp()}.jar"
    destinationDirectory = file('.')
    from sourceSets.main.output
    from {
        configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
    }
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

build.dependsOn fatJar

启动器 启动.bat

@echo off
rem ImageCleaner launcher for industrial PCs (Java 11+ required).
cd /d "%~dp0"

where java >nul 2>nul
if errorlevel 1 goto NO_JAVA

set "JAR="
for %%f in (imagecleaner-*-fat_*.jar) do set "JAR=%%f"
if "%JAR%"=="" (
  msg * "未找到 imagecleaner fat jar,请将 jar 与本脚本放在同一目录。"
  exit /b 1
)

java -version 2>&1 | findstr /R /C:"version \"1\.8" >nul
if not errorlevel 1 goto OLD_JAVA

for /f "tokens=3" %%v in ('java -version 2^>^&1 ^| findstr /i "version"') do set JV=%%v
set JV=%JV:"=%
for /f "delims=. tokens=1" %%m in ("%JV%") do set MAJOR=%%m
if "%MAJOR%"=="1" goto OLD_JAVA

start "" javaw -jar "%JAR%"
exit /b 0

:NO_JAVA
msg * "未检测到 Java 运行环境,请先安装 Java 11 或更高版本(32/64 位均可)。"
exit /b 1

:OLD_JAVA
msg * "Java 版本过低,需要 Java 11 或更高版本。"
exit /b 1

效果

部署时把 fat jar、启动.bat、(可选)delete_conditions.js 拷到同一目录, 工控机上双击 bat 即可. 日志里能看到 QuickJS 脚本的加载与每次删除记录:

2026-09-13 08:57:02.968 [INFO] 启动环境: OS=Windows 10 10.0; Arch=amd64; Java=25.0.2; ProgramDir=D:\...\ImageCleanerAutoWeld; DataDir=D:\...\ImageCleanerAutoWeld; Fallback=false; User=Lenovo; Machine=THINKBOOK14
2026-09-13 08:57:03.198 [INFO] 删除判定脚本已加载: delete_conditions.js
2026-09-13 08:57:04.940 [INFO] 监控已启动(图片清理)

主窗口分两个页签: "监控设置"页配置模式/路径/删除条件/实时信息; "判定脚本"页内置 JS 编辑器(RSyntaxTextArea 深色主题), 支持编辑脚本、自定义测试上下文 JSON、一键运行测试并捕获 console 输出, 保存即热生效. 监控运行时托盘图标绿色闪烁, 右键可显示窗口/启停/退出.

posted @ 2026-09-13 10:53  qsBye  阅读(8)  评论(0)    收藏  举报