统一导出历史文件操作

This commit is contained in:
张家训
2026-07-31 19:39:00 +08:00
parent 9c82c8f638
commit c029b367f6
8 changed files with 336 additions and 170 deletions
@@ -10,7 +10,6 @@ import com.example.workutils.biz.apidoc.parser.JavaSourceParser;
import com.example.workutils.system.config.WorkUtilsProperties;
import com.example.workutils.system.support.FileOperationHelper;
import com.example.workutils.system.support.HistoryPersistence;
import com.example.workutils.system.support.SystemOpenHelper;
import com.example.workutils.system.ui.StyledButton;
import com.example.workutils.system.ui.Theme;
import com.example.workutils.system.support.AppExecutors;
@@ -21,6 +20,9 @@ import com.example.workutils.system.ui.async.OperationStatusPane;
import com.example.workutils.system.ui.history.ExportHistoryPane;
import com.example.workutils.system.ui.history.HistoryFileCommands;
import com.example.workutils.system.ui.history.HistoryItem;
import com.example.workutils.system.ui.history.ResultFileActions;
import com.example.workutils.system.ui.history.SystemFileActionGateway;
import com.example.workutils.system.ui.notification.FxNotificationCenter;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -58,6 +60,7 @@ public class ApiDocWindow {
private final ApiBaseClassManager apiBaseClassManager;
private final RunApiDocExportUseCase exportUseCase;
private final AsyncOperationController<RunApiDocExportUseCase.Result> exportOperation;
private final HistoryFileCommands historyFileCommands;
// 模式选择
private ToggleGroup modeGroup;
@@ -107,6 +110,11 @@ public class ApiDocWindow {
historyItems.setAll(historyRows.stream()
.map(ApiDocWindow::toHistoryItem).toList()));
this.stage = new Stage();
this.historyFileCommands = new ResultFileActions(
new SystemFileActionGateway(),
new FxNotificationCenter(stage),
message -> log("[INFO] " + message))
.commands();
buildUI();
loadProjects();
loadHistory();
@@ -285,7 +293,7 @@ public class ApiDocWindow {
ExportHistoryPane historyPane = new ExportHistoryPane(
"导出格式", "文件数", historyItems,
historyFileCommands(), fsBody());
historyFileCommands, fsBody());
historyPane.setPrefHeight(210);
historyWrapper.getChildren().add(historyPane);
tabHistory.setContent(historyWrapper);
@@ -760,13 +768,13 @@ public class ApiDocWindow {
+ "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 2 6;"));
openBtn.setOnAction(e -> {
openFile(currentFileName);
historyFileCommands.openFile(currentFileName);
});
dirBtn.setOnAction(e -> {
openFolder(currentFileName);
historyFileCommands.openFolder(currentFileName);
});
copyBtn.setOnAction(e -> {
copyFilePath(currentFileName);
historyFileCommands.copyPath(currentFileName);
});
delBtn.setOnAction(e -> {
if (currentRow != null) {
@@ -793,63 +801,10 @@ public class ApiDocWindow {
}
}
private void copyFilePath(String path) {
if (path == null) return;
javafx.scene.input.Clipboard.getSystemClipboard()
.setContent(new javafx.scene.input.ClipboardContent() {{
putString(path);
}});
log("[INFO] 已复制路径: " + path);
}
};
return cell;
}
private HistoryFileCommands historyFileCommands() {
return new HistoryFileCommands(
this::openFile,
this::openWithNotepad,
this::openFolder,
path -> {
javafx.scene.input.ClipboardContent content =
new javafx.scene.input.ClipboardContent();
content.putString(path);
javafx.scene.input.Clipboard.getSystemClipboard().setContent(content);
log("[INFO] 已复制路径: " + path);
});
}
// ==================== 文件打开工具方法 ====================
private void openFile(String path) {
if (path == null) return;
try {
File f = new File(path);
if (!f.exists()) return;
SystemOpenHelper.openFile(f.toPath());
} catch (Exception ignored) {
}
}
private void openWithNotepad(String path) {
if (path == null) return;
try {
SystemOpenHelper.editFile(path);
} catch (Exception e) {
openFile(path);
}
}
private void openFolder(String path) {
if (path == null) return;
try {
File f = new File(path).getParentFile();
if (f == null || !f.exists()) return;
SystemOpenHelper.revealInFileManager(path);
} catch (Exception ignored) {
}
}
// ==================== 数据行 ====================
static HistoryItem toHistoryItem(HistoryRow row) {
@@ -6,7 +6,6 @@ import com.example.workutils.biz.mybatisexport.service.MyBatisExportService;
import com.example.workutils.system.config.WorkUtilsProperties;
import com.example.workutils.system.support.AppExecutors;
import com.example.workutils.system.support.HistoryPersistence;
import com.example.workutils.system.support.SystemOpenHelper;
import com.example.workutils.system.ui.StyledButton;
import com.example.workutils.system.ui.Theme;
import com.example.workutils.system.ui.async.AsyncOperationController;
@@ -15,6 +14,9 @@ import com.example.workutils.system.ui.async.OperationStatusPane;
import com.example.workutils.system.ui.history.ExportHistoryPane;
import com.example.workutils.system.ui.history.HistoryFileCommands;
import com.example.workutils.system.ui.history.HistoryItem;
import com.example.workutils.system.ui.history.ResultFileActions;
import com.example.workutils.system.ui.history.SystemFileActionGateway;
import com.example.workutils.system.ui.notification.FxNotificationCenter;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -26,7 +28,6 @@ import javafx.scene.layout.*;
import javafx.scene.Parent;
import javafx.stage.Stage;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
@@ -46,6 +47,7 @@ public class MyBatisExportWindow {
private final WorkUtilsProperties properties;
private final RunSqlExportUseCase runExportUseCase;
private final AsyncOperationController<MyBatisExportService.ExportResult> exportOperation;
private final HistoryFileCommands historyFileCommands;
// 过滤
private ComboBox<String> filterModeCmb;
@@ -81,6 +83,11 @@ public class MyBatisExportWindow {
historyItems.setAll(historyRows.stream()
.map(MyBatisExportWindow::toHistoryItem).toList()));
this.stage = new Stage();
this.historyFileCommands = new ResultFileActions(
new SystemFileActionGateway(),
new FxNotificationCenter(stage),
message -> logArea.appendText("[INFO] " + message + "\n"))
.commands();
buildUI();
loadHistory();
}
@@ -174,25 +181,12 @@ public class MyBatisExportWindow {
tableTitle.setTextFill(Theme.TEXT_PRIMARY);
ExportHistoryPane historyPane = new ExportHistoryPane(
"导出数", historyItems, historyFileCommands(), fsBody());
"导出数", historyItems, historyFileCommands, fsBody());
VBox.setVgrow(historyPane, Priority.ALWAYS);
root.getChildren().addAll(filterRow, progressRow, logTitle, logArea, tableTitle, historyPane);
stage.setScene(new Scene(root));
}
private HistoryFileCommands historyFileCommands() {
return new HistoryFileCommands(
this::openFile,
this::openWithNotepad,
this::openFolder,
path -> {
javafx.scene.input.ClipboardContent content =
new javafx.scene.input.ClipboardContent();
content.putString(path);
javafx.scene.input.Clipboard.getSystemClipboard().setContent(content);
});
}
// ==================== 历史持久化 ====================
/**
@@ -275,37 +269,6 @@ public class MyBatisExportWindow {
saveHistory();
}
// ==================== 文件打开工具方法 ====================
private void openFile(String path) {
if (path == null) return;
try {
File f = new File(path);
if (!f.exists()) return;
SystemOpenHelper.openFile(f.toPath());
} catch (Exception ignored) {
}
}
private void openWithNotepad(String path) {
if (path == null) return;
try {
SystemOpenHelper.editFile(path);
} catch (Exception e) {
openFile(path);
}
}
private void openFolder(String path) {
if (path == null) return;
try {
File f = new File(path).getParentFile();
if (f == null || !f.exists()) return;
SystemOpenHelper.revealInFileManager(path);
} catch (Exception ignored) {
}
}
// ==================== 数据行 ====================
static HistoryItem toHistoryItem(HistoryRow row) {
@@ -8,7 +8,6 @@ import com.example.workutils.system.support.AppExecutors;
import com.example.workutils.system.support.FileOperationHelper;
import com.example.workutils.system.support.HistoryPersistence;
import com.example.workutils.system.support.OutputHelper;
import com.example.workutils.system.support.SystemOpenHelper;
import com.example.workutils.system.ui.StyledButton;
import com.example.workutils.system.ui.Theme;
import com.example.workutils.system.ui.async.AsyncOperationController;
@@ -17,6 +16,9 @@ import com.example.workutils.system.ui.async.OperationStatusPane;
import com.example.workutils.system.ui.history.ExportHistoryPane;
import com.example.workutils.system.ui.history.HistoryFileCommands;
import com.example.workutils.system.ui.history.HistoryItem;
import com.example.workutils.system.ui.history.ResultFileActions;
import com.example.workutils.system.ui.history.SystemFileActionGateway;
import com.example.workutils.system.ui.notification.FxNotificationCenter;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -28,7 +30,6 @@ import javafx.scene.layout.*;
import javafx.scene.Parent;
import javafx.stage.Stage;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
@@ -49,6 +50,7 @@ public class ScannerWindow {
private final OutputHelper outputHelper;
private final RunScanUseCase runScanUseCase;
private final AsyncOperationController<RunScanUseCase.Result> scanOperation;
private final HistoryFileCommands historyFileCommands;
// UI
private CheckBox regexCb;
@@ -79,6 +81,11 @@ public class ScannerWindow {
historyItems.setAll(historyRows.stream()
.map(ScannerWindow::toHistoryItem).toList()));
this.stage = new Stage();
this.historyFileCommands = new ResultFileActions(
new SystemFileActionGateway(),
new FxNotificationCenter(stage),
message -> logArea.appendText("[INFO] " + message + "\n"))
.commands();
buildUI();
loadHistory();
}
@@ -158,7 +165,7 @@ public class ScannerWindow {
tableTitle.setTextFill(Theme.TEXT_PRIMARY);
ExportHistoryPane historyPane = new ExportHistoryPane(
"匹配数", historyItems, historyFileCommands(), fsBody());
"匹配数", historyItems, historyFileCommands, fsBody());
VBox.setVgrow(historyPane, Priority.ALWAYS);
// === 日志区 ===
@@ -172,20 +179,6 @@ public class ScannerWindow {
stage.setScene(new Scene(root));
}
private HistoryFileCommands historyFileCommands() {
return new HistoryFileCommands(
this::openFile,
this::openWithNotepad,
this::openFolder,
path -> {
javafx.scene.input.ClipboardContent content =
new javafx.scene.input.ClipboardContent();
content.putString(path);
javafx.scene.input.Clipboard.getSystemClipboard().setContent(content);
logArea.appendText("[INFO] 已复制路径: " + path + "\n");
});
}
// ==================== 历史持久化 ====================
/**
@@ -253,54 +246,6 @@ public class ScannerWindow {
logArea.appendText("已导出: " + result.fileName() + "\n");
}
// ==================== 文件打开工具方法 ====================
private void openFile(String path) {
if (path == null) {
logArea.appendText("[WARN] 路径为空\n");
return;
}
try {
File f = new File(path);
if (!f.exists()) {
logArea.appendText("[WARN] 文件不存在: " + path + "\n");
return;
}
// Delegate platform-specific open behavior to SystemOpenHelper.
SystemOpenHelper.openFile(f.toPath());
logArea.appendText("[OK] 已打开: " + path + "\n");
} catch (Exception e) {
logArea.appendText("[ERROR] 打开失败: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()) + "\n");
}
}
private void openWithNotepad(String path) {
try {
SystemOpenHelper.editFile(path);
logArea.appendText("[OK] 用系统编辑器打开: " + path + "\n");
} catch (Exception e) {
openFile(path); // fallback
}
}
private void openFolder(String path) {
if (path == null) {
logArea.appendText("[WARN] 路径为空\n");
return;
}
try {
File f = new File(path).getParentFile();
if (f == null || !f.exists()) {
logArea.appendText("[WARN] 目录不存在: " + path + "\n");
return;
}
SystemOpenHelper.revealInFileManager(path);
logArea.appendText("[OK] 已打开目录: " + f.getAbsolutePath() + "\n");
} catch (Exception e) {
logArea.appendText("[ERROR] 打开目录失败: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()) + "\n");
}
}
// ==================== 数据行 ====================
static HistoryItem toHistoryItem(HistoryRow row) {
@@ -0,0 +1,15 @@
package com.example.workutils.system.ui.history;
import java.io.IOException;
import java.nio.file.Path;
public interface FileActionGateway {
void open(Path path) throws IOException;
void edit(Path path) throws IOException;
void reveal(Path path) throws IOException;
void copyPath(String path);
}
@@ -0,0 +1,90 @@
package com.example.workutils.system.ui.history;
import com.example.workutils.system.ui.notification.NotificationCenter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.function.Consumer;
public final class ResultFileActions {
private final FileActionGateway gateway;
private final NotificationCenter notifications;
private final Consumer<String> successFeedback;
public ResultFileActions(
FileActionGateway gateway,
NotificationCenter notifications,
Consumer<String> successFeedback) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.notifications = Objects.requireNonNull(notifications, "notifications");
this.successFeedback = Objects.requireNonNull(successFeedback, "successFeedback");
}
public HistoryFileCommands commands() {
return new HistoryFileCommands(
path -> performExisting(path, "打开文件", gateway::open),
path -> performExisting(path, "编辑文件", gateway::edit),
path -> performExisting(path, "打开目录", gateway::reveal),
this::copyPath);
}
private void performExisting(String value, String action, PathAction operation) {
Path path = parsePath(value);
if (path == null) {
return;
}
if (!Files.isRegularFile(path)) {
notifications.warning("文件操作失败", "文件不存在");
return;
}
try {
operation.accept(path);
} catch (IOException | RuntimeException error) {
notifyFailure(action, error);
}
}
private void copyPath(String value) {
Path path = parsePath(value);
if (path == null) {
return;
}
String normalized = path.toString();
try {
gateway.copyPath(normalized);
successFeedback.accept(Files.exists(path)
? "已复制路径: " + normalized
: "已复制路径(文件已不存在): " + normalized);
} catch (RuntimeException error) {
notifyFailure("复制路径", error);
}
}
private Path parsePath(String value) {
if (value == null || value.isBlank()) {
notifications.warning("文件操作失败", "文件路径不能为空");
return null;
}
try {
return Path.of(value.trim()).toAbsolutePath().normalize();
} catch (InvalidPathException error) {
notifyFailure("解析路径", error);
return null;
}
}
private void notifyFailure(String action, Exception error) {
String detail = error.getMessage();
notifications.error("文件操作失败", action + "失败"
+ (detail == null || detail.isBlank() ? "" : ": " + detail));
}
@FunctionalInterface
private interface PathAction {
void accept(Path path) throws IOException;
}
}
@@ -0,0 +1,33 @@
package com.example.workutils.system.ui.history;
import com.example.workutils.system.support.SystemOpenHelper;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.io.IOException;
import java.nio.file.Path;
public final class SystemFileActionGateway implements FileActionGateway {
@Override
public void open(Path path) throws IOException {
SystemOpenHelper.openFile(path);
}
@Override
public void edit(Path path) throws IOException {
SystemOpenHelper.editFile(path);
}
@Override
public void reveal(Path path) throws IOException {
SystemOpenHelper.revealInFileManager(path);
}
@Override
public void copyPath(String path) {
ClipboardContent content = new ClipboardContent();
content.putString(path);
Clipboard.getSystemClipboard().setContent(content);
}
}
@@ -0,0 +1,135 @@
package com.example.workutils.system.ui.history;
import com.example.workutils.system.ui.notification.NotificationCenter;
import com.example.workutils.system.ui.notification.NotificationMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ResultFileActionsTest {
@TempDir
Path tempDir;
@Test
void routesExistingFileActionsThroughGateway() throws Exception {
Path file = Files.createFile(tempDir.resolve("result.csv"));
RecordingGateway gateway = new RecordingGateway();
RecordingNotifications notifications = new RecordingNotifications();
ResultFileActions actions = new ResultFileActions(
gateway, notifications, message -> { });
HistoryFileCommands commands = actions.commands();
commands.openFile(file.toString());
commands.openInEditor(file.toString());
commands.openFolder(file.toString());
assertEquals(List.of(
"open:" + file.toAbsolutePath().normalize(),
"edit:" + file.toAbsolutePath().normalize(),
"reveal:" + file.toAbsolutePath().normalize()), gateway.calls);
assertTrue(notifications.messages.isEmpty());
}
@Test
void copiesNormalizedMissingPathAndReportsThatItNoLongerExists() {
Path missing = tempDir.resolve("missing.csv");
RecordingGateway gateway = new RecordingGateway();
RecordingNotifications notifications = new RecordingNotifications();
List<String> feedback = new ArrayList<>();
ResultFileActions actions = new ResultFileActions(
gateway, notifications, feedback::add);
actions.commands().copyPath(missing.toString());
String normalized = missing.toAbsolutePath().normalize().toString();
assertEquals(List.of("copy:" + normalized), gateway.calls);
assertEquals(List.of("已复制路径(文件已不存在): " + normalized), feedback);
}
@Test
void rejectsBlankAndMissingOpenTargetsWithoutCallingGateway() {
RecordingGateway gateway = new RecordingGateway();
RecordingNotifications notifications = new RecordingNotifications();
ResultFileActions actions = new ResultFileActions(
gateway, notifications, message -> { });
actions.commands().openFile(" ");
actions.commands().openFolder(tempDir.resolve("gone.csv").toString());
assertTrue(gateway.calls.isEmpty());
assertEquals(2, notifications.messages.size());
assertEquals("文件路径不能为空", notifications.messages.get(0).message());
assertEquals("文件不存在", notifications.messages.get(1).message());
}
@Test
void translatesPlatformFailureIntoUserFacingError() throws Exception {
Path file = Files.createFile(tempDir.resolve("result.sql"));
RecordingGateway gateway = new RecordingGateway();
gateway.failure = new IllegalStateException("desktop unavailable");
RecordingNotifications notifications = new RecordingNotifications();
ResultFileActions actions = new ResultFileActions(
gateway, notifications, message -> { });
actions.commands().openFile(file.toString());
assertEquals(1, notifications.messages.size());
NotificationMessage message = notifications.messages.get(0);
assertEquals("文件操作失败", message.title());
assertTrue(message.message().contains("desktop unavailable"));
}
private static final class RecordingGateway implements FileActionGateway {
private final List<String> calls = new ArrayList<>();
private RuntimeException failure;
@Override
public void open(Path path) {
record("open:" + path);
}
@Override
public void edit(Path path) {
record("edit:" + path);
}
@Override
public void reveal(Path path) {
record("reveal:" + path);
}
@Override
public void copyPath(String path) {
record("copy:" + path);
}
private void record(String call) {
if (failure != null) {
throw failure;
}
calls.add(call);
}
}
private static final class RecordingNotifications implements NotificationCenter {
private final List<NotificationMessage> messages = new ArrayList<>();
@Override
public void show(NotificationMessage message) {
messages.add(message);
}
@Override
public boolean confirm(String title, String header, String message) {
throw new UnsupportedOperationException();
}
}
}
@@ -0,0 +1,30 @@
package com.example.workutils.system.ui.history;
import com.example.workutils.biz.apidoc.ApiDocWindow;
import com.example.workutils.biz.mybatisexport.MyBatisExportWindow;
import com.example.workutils.biz.scanner.ScannerWindow;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
class WindowHistoryCommandsDependencyTest {
@ParameterizedTest
@ValueSource(classes = {
ApiDocWindow.class,
ScannerWindow.class,
MyBatisExportWindow.class
})
void targetWindowOwnsOneSharedHistoryCommandBundle(Class<?> windowType) {
long commandFields = Arrays.stream(windowType.getDeclaredFields())
.filter(field -> field.getType() == HistoryFileCommands.class)
.count();
assertEquals(1, commandFields,
() -> windowType.getSimpleName()
+ " must own one shared HistoryFileCommands instance");
}
}