摸鱼笔记[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先写.tmp再ATOMIC_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 输出, 保存即热生效. 监控运行时托盘图标绿色闪烁, 右键可显示窗口/启停/退出.

浙公网安备 33010602011771号