feat(browser): 添加 BrowserWindow 和 BrowserWindowJDialog 类以支持嵌入式浏览器功能

- 新增 BrowserWindow 类,支持通过 Builder 模式创建可定制的浏览器窗口
- 新增 BrowserWindowJDialog 类,继承自 JDialog,用于创建模态或非模态浏览器对话框
- 实现基于 CEF 的浏览器组件加载与生命周期管理
- 支持自定义上下文菜单、键盘事件(如 F12 开发者工具)、JS 对话框拦截
- 提供链接打开方式配置(在当前窗口或外部浏览器中打开)
- 集成消息路由机制,支持前端与后端通信
- 支持主题与字体信息注入至网页端
- 添加资源自动释放逻辑,防止内存泄漏
- 增加对粘贴板操作的支持(复制/粘贴文本)
This commit is contained in:
2025-11-22 08:45:41 +08:00
parent fe4142902c
commit 75bdca05f2
24 changed files with 5462 additions and 203 deletions

View File

@@ -0,0 +1,821 @@
package com.chuangzhou.vivid2D.browser;
import com.axis.innovators.box.AxisInnovatorsBox;
import com.axis.innovators.box.events.BrowserCreationCallback;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import org.cef.CefApp;
import org.cef.CefClient;
import org.cef.CefSettings;
import org.cef.browser.CefBrowser;
import org.cef.browser.CefFrame;
import org.cef.browser.CefMessageRouter;
import org.cef.callback.CefContextMenuParams;
import org.cef.callback.CefJSDialogCallback;
import org.cef.callback.CefMenuModel;
import org.cef.callback.CefQueryCallback;
import org.cef.handler.*;
import org.cef.misc.BoolRef;
import org.cef.network.CefRequest;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.awt.event.*;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.util.function.Consumer;
import static org.cef.callback.CefMenuModel.MenuId.MENU_ID_USER_FIRST;
/**
* @author tzdwindows 7
*/
public class BrowserWindow extends JFrame {
private final String windowId;
private final String htmlUrl;
private CefApp cefApp;
private CefClient client;
private CefBrowser browser;
private final Component browserComponent;
private final String htmlPath;
private static boolean isInitialized = false;
private WindowOperationHandler operationHandler;
private static Thread cefThread;
private CefMessageRouter msgRouter;
public static class Builder {
private BrowserCreationCallback browserCreationCallback;
private String windowId;
private String title = "JCEF Window";
private Dimension size = new Dimension(800, 600);
private WindowOperationHandler operationHandler;
private String htmlPath;
private Image icon;
private boolean resizable = true; // 默认允许调整大小
private boolean maximizable = true; // 默认允许最大化
private boolean minimizable = true; // 默认允许最小化
private String htmlUrl = "";
private boolean openLinksInExternalBrowser = true; // 默认使用外部浏览器
public Builder resizable(boolean resizable) {
this.resizable = resizable;
return this;
}
public Builder maximizable(boolean maximizable) {
this.maximizable = maximizable;
return this;
}
/**
* 设置链接打开方式
*
* @param openInBrowser 是否在当前浏览器窗口中打开链接
* true - 在当前浏览器窗口中打开链接(本地跳转)
* false - 使用系统默认浏览器打开链接(外部跳转)
* @return Builder实例支持链式调用
*
* @apiNote 此方法控制两种不同的链接打开行为:
* 1. 当设置为true时
* - 所有链接将在当前CEF浏览器窗口内打开
*
* 2. 当设置为false时默认值
* - 所有链接将在系统默认浏览器中打开
* - 更安全,避免潜在的安全风险
* - 适用于简单的信息展示场景
*
* @implNote 内部实现说明:
* - 实际存储的是反向值(openLinksInExternalBrowser)
* - 这样设置是为了保持与历史版本的兼容性
* - 方法名使用"openInBrowser"更符合用户直觉
*
* @example 使用示例:
* // 在当前窗口打开链接
* new Builder().openLinksInBrowser(true).build();
*
* // 使用系统浏览器打开链接(默认)
* new Builder().openLinksInBrowser(false).build();
*
* @see #openLinksInExternalBrowser 内部存储字段
* @see CefLifeSpanHandler#onBeforePopup 弹窗处理实现
* @see CefRequestHandler#onBeforeBrowse 导航处理实现
*/
public Builder openLinksInBrowser(boolean openInBrowser) {
this.openLinksInExternalBrowser = !openInBrowser;
return this;
}
public Builder minimizable(boolean minimizable) {
this.minimizable = minimizable;
return this;
}
public Builder(String windowId) {
this.windowId = windowId;
}
/**
* 设置浏览器创建回调
* @param callback 回调
*/
public Builder setBrowserCreationCallback(BrowserCreationCallback callback){
this.browserCreationCallback = callback;
return this;
}
/**
* 设置浏览器窗口标题
* @param title 标题
*/
public Builder title(String title) {
this.title = title;
return this;
}
/**
* 设置浏览器窗口大小
* @param width 宽度
* @param height 高度
*/
public Builder size(int width, int height) {
this.size = new Dimension(width, height);
return this;
}
/**
* 设置浏览器触发事件
* @param handler 事件处理器
*/
public Builder operationHandler(WindowOperationHandler handler) {
this.operationHandler = handler;
return this;
}
/**
* 设置浏览器图标
* @param icon 图标
*/
public Builder icon(Image icon) {
this.icon = icon;
return this;
}
/**
* 设置HTML路径
*/
public BrowserWindow build() {
if (htmlUrl.isEmpty()) {
if (this.htmlPath == null || this.htmlPath.isEmpty()) {
throw new IllegalArgumentException("HTML paths cannot be empty");
}
File htmlFile = new File(this.htmlPath);
if (!htmlFile.exists()) {
throw new RuntimeException("The HTML file does not exist: " + htmlFile.getAbsolutePath());
}
}
return new BrowserWindow(this);
}
/**
* 设置HTML路径
* @param path HTML路径
*/
public Builder htmlPath(String path) {
this.htmlPath = path;
return this;
}
/**
* 使用Url
* @param htmlUrl Url路径
*/
public Builder htmlUrl(String htmlUrl) {
this.htmlUrl = htmlUrl;
return this;
}
}
private BrowserWindow(Builder builder) {
this.windowId = builder.windowId;
this.htmlPath = builder.htmlPath;
this.operationHandler = builder.operationHandler;
this.htmlUrl = builder.htmlUrl;
// 设置图标(如果存在)
if (builder.icon != null) {
setIconImage(builder.icon);
}
// 初始化浏览器组件
try {
this.browserComponent = initializeCef(builder);
if (operationHandler != null) {
setupMessageHandlers(operationHandler);
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "初始化失败: " + e.getMessage());
throw new RuntimeException(e);
}
}
private Component initializeCef(Builder builder) throws MalformedURLException {
if (!isInitialized) {
isInitialized = true;
try {
this.cefApp = CefAppManager.getInstance();
//CefAppManager.incrementBrowserCount();
client = cefApp.createClient();
client.addDisplayHandler(new CefDisplayHandler (){
@Override
public void onAddressChange(CefBrowser browser, CefFrame frame, String url) {
}
@Override
public void onTitleChange(CefBrowser browser, String title) {
}
@Override
public void OnFullscreenModeChange(CefBrowser browser, boolean fullscreen) {
}
@Override
public boolean onTooltip(CefBrowser browser, String text) {
return false;
}
@Override
public void onStatusMessage(CefBrowser browser, String value) {
}
@Override
public boolean onConsoleMessage(
CefBrowser browser,
CefSettings.LogSeverity level,
String message,
String source,
int line
) {
// 格式化输出到 Java 控制台
//if (level != CefSettings.LogSeverity.LOGSEVERITY_WARNING) {
String log = String.format(
"[Browser Console] %s %s (Line %d) -> %s",
getLogLevelSymbol(level),
source,
line,
message
);
System.out.println(log);
//}
return false;
}
@Override
public boolean onCursorChange(CefBrowser browser, int cursorType) {
return false;
}
private String getLogLevelSymbol(CefSettings.LogSeverity level) {
switch (level) {
case LOGSEVERITY_ERROR: return "";
case LOGSEVERITY_WARNING: return "⚠️";
case LOGSEVERITY_DEFAULT: return "🐞";
default: return "";
}
}
});
if (AxisInnovatorsBox.getMain() != null && AxisInnovatorsBox.getMain().isDebugEnvironment()) {
client.addKeyboardHandler(new CefKeyboardHandlerAdapter() {
@Override
public boolean onKeyEvent(CefBrowser browser, CefKeyEvent event) {
// 检测 F12
if (event.windows_key_code == 123) {
browser.getDevTools().createImmediately();
return true;
}
return false;
}
});
}
client.addLifeSpanHandler(new CefLifeSpanHandlerAdapter() {
@Override
public boolean onBeforePopup(CefBrowser browser, CefFrame frame,
String targetUrl, String targetFrameName) {
// 处理弹出窗口:根据配置决定打开方式
if (builder.openLinksInExternalBrowser) {
// 使用默认浏览器打开
try {
Desktop.getDesktop().browse(new URI(targetUrl));
} catch (Exception e) {
System.out.println("Failed to open external browser: " + e.getMessage());
}
return true; // 拦截弹窗
} else {
// 在当前浏览器中打开
browser.loadURL(targetUrl);
return true; // 拦截弹窗并在当前窗口打开
}
}
});
client.addRequestHandler(new CefRequestHandlerAdapter() {
@Override
public boolean onBeforeBrowse(CefBrowser browser, CefFrame frame,
CefRequest request, boolean userGesture, boolean isRedirect) {
// 处理主窗口导航
if (userGesture) {
if (builder.openLinksInExternalBrowser) {
// 使用默认浏览器打开
try {
Desktop.getDesktop().browse(new URI(request.getURL()));
return true; // 取消内置浏览器导航
} catch (Exception e) {
System.out.println("Failed to open external browser: " + e.getMessage());
}
} else {
// 允许在当前浏览器中打开
return false;
}
}
return false;
}
});
client.addContextMenuHandler(new CefContextMenuHandlerAdapter() {
@Override
public void onBeforeContextMenu(CefBrowser browser, CefFrame frame,
CefContextMenuParams params, CefMenuModel model) {
model.clear();
if (!params.getSelectionText().isEmpty() || params.isEditable()) {
model.addItem(MENU_ID_USER_FIRST, "复制");
}
if (params.isEditable()) {
model.addItem(MENU_ID_USER_FIRST + 1, "粘贴");
model.addItem(MENU_ID_USER_FIRST + 2, "粘贴纯文本");
}
}
@Override
public boolean onContextMenuCommand(CefBrowser browser, CefFrame frame,
CefContextMenuParams params, int commandId, int eventFlags) {
if (commandId == MENU_ID_USER_FIRST) {
if (params.isEditable()) {
browser.executeJavaScript("document.execCommand('copy');", browser.getURL(), 0);
} else {
browser.executeJavaScript(
"window.getSelection().toString();",
browser.getURL(),
0
);
}
return true;
} else if (commandId == MENU_ID_USER_FIRST + 1) {
pasteContent(browser, false);
return true;
} else if (commandId == MENU_ID_USER_FIRST + 2) {
pasteContent(browser, true);
return true;
}
return false;
}
/**
* 处理粘贴操作
* @param plainText 是否去除格式(纯文本模式)
*/
private void pasteContent(CefBrowser browser, boolean plainText) {
try {
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
String text = (String) clipboard.getData(DataFlavor.stringFlavor);
if (plainText) {
text = text.replaceAll("<[^>]+>", "");
}
String escapedText = text
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r");
String script = String.format(
"if (document.activeElement) {\n" +
" document.activeElement.value += '%s';\n" + // 简单追加文本
" document.dispatchEvent(new Event('input', { bubbles: true }));\n" + // 触发输入事件
"}",
escapedText
);
browser.executeJavaScript(script, browser.getURL(), 0);
}
} catch (UnsupportedFlavorException | IOException e) {
e.printStackTrace();
}
}
});
client.addJSDialogHandler(new CefJSDialogHandlerAdapter() {
@Override
public boolean onJSDialog(CefBrowser browser, String origin_url, JSDialogType dialog_type, String message_text, String default_prompt_text, CefJSDialogCallback callback, BoolRef suppress_message) {
if (dialog_type == JSDialogType.JSDIALOGTYPE_ALERT) {
SwingUtilities.invokeLater(() -> {
JOptionPane.showMessageDialog(
BrowserWindow.this,
message_text,
"警告",
JOptionPane.INFORMATION_MESSAGE
);
});
callback.Continue(true, "");
return true;
}
return false;
}
});
// 3. 拦截所有新窗口(关键修复点!)
//client.addLifeSpanHandler(new CefLifeSpanHandlerAdapter() {
// @Override
// public boolean onBeforePopup(CefBrowser browser,
// CefFrame frame, String target_url, String target_frame_name) {
// return true; // 返回true表示拦截弹窗
// }
//});
Thread.currentThread().setName("BrowserRenderThread");
// 4. 加载HTML
if (htmlUrl.isEmpty()){
String fileUrl = new File(htmlPath).toURI().toURL().toString();
System.out.println("Loading HTML from: " + fileUrl);
// 5. 创建浏览器组件(直接添加到内容面板)
browser = client.createBrowser(fileUrl, false, false);
} else {
System.out.println("Loading Url from: " + htmlUrl);
browser = client.createBrowser(htmlUrl, false, false);
}
Component browserComponent = browser.getUIComponent();
if (builder.browserCreationCallback != null) {
boolean handled = builder.browserCreationCallback.onLayoutCustomization(
this, // 当前窗口
getContentPane(), // 内容面板
browserComponent, // 浏览器组件
builder // 构建器对象
);
// 如果回调返回true跳过默认布局
if (handled) {
// 设置窗口基本属性
setTitle(builder.title);
setSize(builder.size);
setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
// 添加资源释放监听器
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
browser.close(true);
client.dispose();
}
});
setVisible(true);
return browserComponent; // 直接返回,跳过默认布局
}
}
CefMessageRouter.CefMessageRouterConfig config = new CefMessageRouter.CefMessageRouterConfig();
config.jsQueryFunction = "javaQuery";// 定义方法
config.jsCancelFunction = "javaQueryCancel";// 定义取消方法
updateTheme();
// 6. 配置窗口布局(确保只添加一次)
SwingUtilities.invokeLater(() -> {
getContentPane().removeAll();
getContentPane().setLayout(new BorderLayout());
// 透明拖拽层(仅顶部可拖拽)
JPanel dragPanel = new JPanel(new BorderLayout());
dragPanel.setOpaque(false);
JPanel titleBar = new JPanel();
titleBar.setOpaque(false);
titleBar.setPreferredSize(new Dimension(builder.size.width, 20));
final Point[] dragStart = new Point[1];
titleBar.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
dragStart[0] = e.getPoint();
}
@Override
public void mouseReleased(MouseEvent e) {
dragStart[0] = null;
}
});
titleBar.addMouseMotionListener(new MouseMotionAdapter() {
@Override
public void mouseDragged(MouseEvent e) {
if (dragStart[0] != null) {
Point curr = e.getLocationOnScreen();
setLocation(curr.x - dragStart[0].x, curr.y - dragStart[0].y);
}
}
});
dragPanel.add(titleBar, BorderLayout.NORTH);
getContentPane().add(dragPanel, BorderLayout.CENTER);
getContentPane().add(browserComponent, BorderLayout.CENTER);
// 7. 窗口属性设置
setTitle(builder.title);
setSize(builder.size);
setLocationRelativeTo(null);
setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
// 8. 资源释放
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
browser.close(true);
client.dispose();
}
});
setVisible(true);
});
return browserComponent;
} catch (Exception e) {
e.printStackTrace();
JOptionPane.showMessageDialog(null, "初始化失败: " + e.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
}
} else {
isInitialized = false;
SwingUtilities.invokeLater(() -> {
dispose();
});
}
return null;
}
/**
* 更新主题
*/
public void updateTheme() {
// 1. 获取Java字体信息
String fontInfo = getSystemFontsInfo();
boolean isDarkTheme = AxisInnovatorsBox.getMain().getRegistrationTopic().isDarkMode();
injectFontInfoToPage(browser, fontInfo, isDarkTheme);
// 2. 注入主题信息
//injectThemeInfoToPage(browser, isDarkTheme);
//// 3. 刷新浏览器
//SwingUtilities.invokeLater(() -> {
// browser.reload();
//});
}
/**
* 获取Java字体信息从UIManager获取
*/
private String getSystemFontsInfo() {
try {
Gson gson = new Gson();
JsonObject fontInfo = new JsonObject();
JsonObject uiFonts = new JsonObject();
String[] fontKeys = {
"Label.font", "Button.font", "ToggleButton.font", "RadioButton.font",
"CheckBox.font", "ColorChooser.font", "ComboBox.font", "EditorPane.font",
"TextArea.font", "TextField.font", "PasswordField.font", "TextPane.font",
"FormattedTextField.font", "Table.font", "TableHeader.font", "List.font",
"Tree.font", "TabbedPane.font", "MenuBar.font", "Menu.font", "MenuItem.font",
"PopupMenu.font", "CheckBoxMenuItem.font", "RadioButtonMenuItem.font",
"Spinner.font", "ToolBar.font", "TitledBorder.font", "OptionPane.messageFont",
"OptionPane.buttonFont", "Panel.font", "Viewport.font", "ToolTip.font"
};
for (String key : fontKeys) {
Font font = UIManager.getFont(key);
if (font != null) {
JsonObject fontObj = new JsonObject();
fontObj.addProperty("name", font.getFontName());
fontObj.addProperty("family", font.getFamily());
fontObj.addProperty("size", font.getSize());
fontObj.addProperty("style", font.getStyle());
fontObj.addProperty("bold", font.isBold());
fontObj.addProperty("italic", font.isItalic());
fontObj.addProperty("plain", font.isPlain());
uiFonts.add(key, fontObj);
}
}
fontInfo.add("uiFonts", uiFonts);
fontInfo.addProperty("timestamp", System.currentTimeMillis());
fontInfo.addProperty("lookAndFeel", UIManager.getLookAndFeel().getName());
return gson.toJson(fontInfo);
} catch (Exception e) {
return "{\"error\": \"无法获取UIManager字体信息: " + e.getMessage() + "\"}";
}
}
/**
* 注入主题信息到页面
*/
private void injectThemeInfoToPage(CefBrowser browser, boolean isDarkTheme) {
if (client == null) {
return;
}
String themeInfo = String.format(
"{\"isDarkTheme\": %s, \"timestamp\": %d}",
isDarkTheme,
System.currentTimeMillis()
);
// 最简单的脚本 - 直接设置和分发事件
String script = String.format(
"window.javaThemeInfo = %s;" +
"console.log('主题信息已设置:', window.javaThemeInfo);" +
"" +
"var event = new CustomEvent('javaThemeChanged', {" +
" detail: window.javaThemeInfo" +
"});" +
"document.dispatchEvent(event);" +
"console.log('javaThemeChanged事件已分发');",
themeInfo);
browser.executeJavaScript(script, browser.getURL(), 0);
}
/**
* 注入字体信息到页面并设置字体
*/
private void injectFontInfoToPage(CefBrowser browser, String fontInfo,boolean isDarkTheme) {
if (client == null) {
return;
}
client.addLoadHandler(new CefLoadHandlerAdapter() {
@Override
public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) {
// 使用更简单的脚本来注入字体信息
String script =
"if (typeof window.javaFontInfo === 'undefined') {" +
" window.javaFontInfo = " + fontInfo + ";" +
" console.log('Java font information has been loaded:', window.javaFontInfo);" +
" " +
" var event = new CustomEvent('javaFontsLoaded', {" +
" detail: window.javaFontInfo" +
" });" +
" document.dispatchEvent(event);" +
" console.log('The javaFontsLoaded event is dispatched');" +
"}";
browser.executeJavaScript(script, browser.getURL(), 0);
// 添加调试信息
browser.executeJavaScript(
"console.log('Font information injection is completewindow.javaFontInfo:', typeof window.javaFontInfo);" +
"console.log('Number of event listeners:', document.eventListeners ? document.eventListeners('javaFontsLoaded') : '无法获取');",
browser.getURL(), 0
);
String themeInfo = String.format(
"{\"isDarkTheme\": %s, \"timestamp\": %d}",
isDarkTheme,
System.currentTimeMillis()
);
script = String.format(
"window.javaThemeInfo = %s;" +
"console.log('主题信息已设置:', window.javaThemeInfo);" +
"" +
"var event = new CustomEvent('javaThemeChanged', {" +
" detail: window.javaThemeInfo" +
"});" +
"document.dispatchEvent(event);" +
"console.log('javaThemeChanged事件已分发');",
themeInfo);
browser.executeJavaScript(script, browser.getURL(), 0);
}
});
}
public static void printStackTrace() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
for (int i = 2; i < stackTrace.length; i++) {
StackTraceElement element = stackTrace[i];
System.out.println(element.getClassName() + "." + element.getMethodName() +
"(" + (element.getFileName() != null ? element.getFileName() : "Unknown Source") +
":" + element.getLineNumber() + ")");
}
}
@Override
public void setVisible(boolean b) {
if (b) {
if (browser != null) {
updateTheme();
}
}
super.setVisible(b);
}
public Component getBrowserComponent() {
return browserComponent;
}
private void setupMessageHandlers(WindowOperationHandler handler) {
if (client != null) {
msgRouter = CefMessageRouter.create();
msgRouter.addHandler(new CefMessageRouterHandlerAdapter() {
@Override
public boolean onQuery(CefBrowser browser,
CefFrame frame,
long queryId,
String request,
boolean persistent,
CefQueryCallback callback) {
if (request.startsWith("system:")) {
String[] parts = request.split(":");
String operation = parts.length >= 2 ? parts[1] : null;
String targetWindow = parts.length > 2 ? parts[2] : null;
handler.handleOperation(
new WindowOperation(operation, targetWindow, callback) // [!code ++]
);
return true;
}
if (request.startsWith("java-response:")) {
String[] parts = request.split(":");
String requestId = parts[1];
String responseData = parts.length > 2 ? parts[2] : "";
Consumer<String> handler = WindowRegistry.getInstance().getCallback(requestId);
if (handler != null) {
handler.accept(responseData);
callback.success("");
} else {
callback.failure(-1, "无效的请求ID");
}
return true;
}
return false;
}
}, true);
client.addMessageRouter(msgRouter);
}
}
public String getWindowId() {
return windowId;
}
/**
* 获取消息路由器
* @return 消息路由器
*/
public CefMessageRouter getMsgRouter() {
return msgRouter;
}
/**
* 获取浏览器对象
* @return 浏览器对象
*/
public CefBrowser getBrowser() {
return browser;
}
public void closeWindow() {
SwingUtilities.invokeLater(() -> {
if (browser != null) {
browser.close(true);
}
dispose();
cefApp.dispose();
WindowRegistry.getInstance().unregisterWindow(windowId);
});
}
}

View File

@@ -0,0 +1,833 @@
package com.chuangzhou.vivid2D.browser;
import com.axis.innovators.box.AxisInnovatorsBox;
import com.axis.innovators.box.events.BrowserCreationCallback;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import org.cef.CefApp;
import org.cef.CefClient;
import org.cef.CefSettings;
import org.cef.browser.CefBrowser;
import org.cef.browser.CefFrame;
import org.cef.browser.CefMessageRouter;
import org.cef.callback.CefContextMenuParams;
import org.cef.callback.CefJSDialogCallback;
import org.cef.callback.CefMenuModel;
import org.cef.callback.CefQueryCallback;
import org.cef.handler.*;
import org.cef.misc.BoolRef;
import org.cef.network.CefRequest;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.awt.event.*;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.util.function.Consumer;
import static org.cef.callback.CefMenuModel.MenuId.MENU_ID_USER_FIRST;
/**
* @author tzdwindows 7
*/
public class BrowserWindowJDialog extends JDialog {
private final String windowId;
private final String htmlUrl;
private CefApp cefApp;
private CefClient client;
private CefBrowser browser;
private final Component browserComponent;
private final String htmlPath;
//private boolean isInitialized = false;
private WindowOperationHandler operationHandler;
private static Thread cefThread;
private CefMessageRouter msgRouter;
public static class Builder {
private String windowId;
private String title = "JCEF Window";
private Dimension size = new Dimension(800, 600);
private WindowOperationHandler operationHandler;
private String htmlPath;
private Image icon;
private JFrame parentFrame;
private boolean resizable = true; // 默认允许调整大小
private boolean maximizable = true; // 默认允许最大化
private boolean minimizable = true; // 默认允许最小化
private String htmlUrl = "";
private BrowserCreationCallback browserCreationCallback;
private boolean openLinksInExternalBrowser = true; // 默认使用外部浏览器
public Builder resizable(boolean resizable) {
this.resizable = resizable;
return this;
}
public Builder maximizable(boolean maximizable) {
this.maximizable = maximizable;
return this;
}
public Builder minimizable(boolean minimizable) {
this.minimizable = minimizable;
return this;
}
public Builder(String windowId) {
this.windowId = windowId;
}
/**
* 设置浏览器窗口标题
* @param title 标题
*/
public Builder title(String title) {
this.title = title;
return this;
}
/**
* 设置链接打开方式
*
* @param openInBrowser 是否在当前浏览器窗口中打开链接
* true - 在当前浏览器窗口中打开链接(本地跳转)
* false - 使用系统默认浏览器打开链接(外部跳转)
* @return Builder实例支持链式调用
*
* @apiNote 此方法控制两种不同的链接打开行为:
* 1. 当设置为true时
* - 所有链接将在当前CEF浏览器窗口内打开
*
* 2. 当设置为false时默认值
* - 所有链接将在系统默认浏览器中打开
* - 更安全,避免潜在的安全风险
* - 适用于简单的信息展示场景
*
* @implNote 内部实现说明:
* - 实际存储的是反向值(openLinksInExternalBrowser)
* - 这样设置是为了保持与历史版本的兼容性
* - 方法名使用"openInBrowser"更符合用户直觉
*
* @example 使用示例:
* // 在当前窗口打开链接
* new Builder().openLinksInBrowser(true).build();
*
* // 使用系统浏览器打开链接(默认)
* new Builder().openLinksInBrowser(false).build();
*
* @see #openLinksInExternalBrowser 内部存储字段
* @see CefLifeSpanHandler#onBeforePopup 弹窗处理实现
* @see CefRequestHandler#onBeforeBrowse 导航处理实现
*/
public Builder openLinksInBrowser(boolean openInBrowser) {
this.openLinksInExternalBrowser = !openInBrowser;
return this;
}
/**
* 设置浏览器创建回调
* @param callback 回调
*/
public Builder setBrowserCreationCallback(BrowserCreationCallback callback){
this.browserCreationCallback = callback;
return this;
}
/**
* 设置浏览器窗口大小
* @param width 宽度
* @param height 高度
*/
public Builder size(int width, int height) {
this.size = new Dimension(width, height);
return this;
}
/**
* 设置浏览器窗口父窗口
* @param parent 父窗口
*/
public Builder parentFrame(JFrame parent) {
this.parentFrame = parent;
return this;
}
/**
* 设置浏览器触发事件
* @param handler 事件处理器
*/
public Builder operationHandler(WindowOperationHandler handler) {
this.operationHandler = handler;
return this;
}
/**
* 设置浏览器图标
* @param icon 图标
*/
public Builder icon(Image icon) {
this.icon = icon;
return this;
}
/**
* 设置HTML路径
*/
public BrowserWindowJDialog build() {
if (htmlUrl.isEmpty()) {
if (this.htmlPath == null || this.htmlPath.isEmpty()) {
throw new IllegalArgumentException("HTML paths cannot be empty");
}
File htmlFile = new File(this.htmlPath);
if (!htmlFile.exists()) {
throw new RuntimeException("The HTML file does not exist: " + htmlFile.getAbsolutePath());
}
}
return new BrowserWindowJDialog(this);
}
/**
* 设置HTML路径
* @param path HTML路径
*/
public Builder htmlPath(String path) {
this.htmlPath = path;
return this;
}
/**
* 使用Url
* @param htmlUrl Url路径
*/
public Builder htmlUrl(String htmlUrl) {
this.htmlUrl = htmlUrl;
return this;
}
}
private BrowserWindowJDialog(Builder builder) {
// 根据父窗口是否存在,设置是否为模态对话框
super(builder.parentFrame, builder.title, builder.parentFrame != null);
this.windowId = builder.windowId;
this.htmlPath = builder.htmlPath;
this.htmlUrl = builder.htmlUrl;
this.operationHandler = builder.operationHandler;
// 设置图标(如果存在)
if (builder.icon != null) {
setIconImage(builder.icon);
}
// 初始化浏览器组件
try {
this.browserComponent = initializeCef(builder);
if (operationHandler != null) {
setupMessageHandlers(operationHandler);
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "初始化失败: " + e.getMessage());
throw new RuntimeException(e);
}
}
private static boolean isInitialized = false;
private Component initializeCef(Builder builder) throws MalformedURLException {
if (!isInitialized) {
isInitialized = true;
try {
this.cefApp = CefAppManager.getInstance();
//CefAppManager.incrementBrowserCount();
client = cefApp.createClient();
client.addDisplayHandler(new CefDisplayHandler (){
@Override
public void onAddressChange(CefBrowser browser, CefFrame frame, String url) {}
@Override
public void onTitleChange(CefBrowser browser, String title) {}
@Override
public void OnFullscreenModeChange(CefBrowser browser, boolean fullscreen) {}
@Override
public boolean onTooltip(CefBrowser browser, String text) {
return false;
}
@Override
public void onStatusMessage(CefBrowser browser, String value) {}
@Override
public boolean onConsoleMessage(
CefBrowser browser,
CefSettings.LogSeverity level,
String message,
String source,
int line
) {
// 格式化输出到 Java 控制台
//if (level != CefSettings.LogSeverity.LOGSEVERITY_WARNING) {
String log = String.format(
"[Browser Console] %s %s (Line %d) -> %s",
getLogLevelSymbol(level),
source,
line,
message
);
System.out.println(log);
//}
return false;
}
@Override
public boolean onCursorChange(CefBrowser browser, int cursorType) {
return false;
}
private String getLogLevelSymbol(CefSettings.LogSeverity level) {
switch (level) {
case LOGSEVERITY_ERROR: return "";
case LOGSEVERITY_WARNING: return "⚠️";
case LOGSEVERITY_DEFAULT: return "🐞";
default: return "";
}
}
});
if (AxisInnovatorsBox.getMain() != null && AxisInnovatorsBox.getMain().isDebugEnvironment()) {
client.addKeyboardHandler(new CefKeyboardHandlerAdapter() {
@Override
public boolean onKeyEvent(CefBrowser browser, CefKeyEvent event) {
// 检测 F12
if (event.windows_key_code == 123) {
browser.getDevTools().createImmediately();
return true;
}
return false;
}
});
}
client.addLifeSpanHandler(new CefLifeSpanHandlerAdapter() {
@Override
public boolean onBeforePopup(CefBrowser browser, CefFrame frame,
String targetUrl, String targetFrameName) {
// 处理弹出窗口:根据配置决定打开方式
if (builder.openLinksInExternalBrowser) {
// 使用默认浏览器打开
try {
Desktop.getDesktop().browse(new URI(targetUrl));
} catch (Exception e) {
System.out.println("Failed to open external browser: " + e.getMessage());
}
return true; // 拦截弹窗
} else {
// 在当前浏览器中打开
browser.loadURL(targetUrl);
return true; // 拦截弹窗并在当前窗口打开
}
}
});
client.addRequestHandler(new CefRequestHandlerAdapter() {
@Override
public boolean onBeforeBrowse(CefBrowser browser, CefFrame frame,
CefRequest request, boolean userGesture, boolean isRedirect) {
// 处理主窗口导航
if (userGesture) {
if (builder.openLinksInExternalBrowser) {
// 使用默认浏览器打开
try {
Desktop.getDesktop().browse(new URI(request.getURL()));
return true; // 取消内置浏览器导航
} catch (Exception e) {
System.out.println("Failed to open external browser: " + e.getMessage());
}
} else {
// 允许在当前浏览器中打开
return false;
}
}
return false;
}
});
client.addContextMenuHandler(new CefContextMenuHandlerAdapter() {
@Override
public void onBeforeContextMenu(CefBrowser browser, CefFrame frame,
CefContextMenuParams params, CefMenuModel model) {
model.clear();
if (!params.getSelectionText().isEmpty() || params.isEditable()) {
model.addItem(MENU_ID_USER_FIRST, "复制");
}
if (params.isEditable()) {
model.addItem(MENU_ID_USER_FIRST + 1, "粘贴");
model.addItem(MENU_ID_USER_FIRST + 2, "粘贴纯文本");
}
}
@Override
public boolean onContextMenuCommand(CefBrowser browser, CefFrame frame,
CefContextMenuParams params, int commandId, int eventFlags) {
if (commandId == MENU_ID_USER_FIRST) {
if (params.isEditable()) {
browser.executeJavaScript("document.execCommand('copy');", browser.getURL(), 0);
} else {
browser.executeJavaScript(
"window.getSelection().toString();",
browser.getURL(),
0
);
}
return true;
} else if (commandId == MENU_ID_USER_FIRST + 1) {
pasteContent(browser, false);
return true;
} else if (commandId == MENU_ID_USER_FIRST + 2) {
pasteContent(browser, true);
return true;
}
return false;
}
/**
* 处理粘贴操作
* @param plainText 是否去除格式(纯文本模式)
*/
private void pasteContent(CefBrowser browser, boolean plainText) {
try {
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
String text = (String) clipboard.getData(DataFlavor.stringFlavor);
if (plainText) {
text = text.replaceAll("<[^>]+>", "");
}
String escapedText = text
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r");
String script = String.format(
"if (document.activeElement) {\n" +
" document.activeElement.value += '%s';\n" + // 简单追加文本
" document.dispatchEvent(new Event('input', { bubbles: true }));\n" + // 触发输入事件
"}",
escapedText
);
browser.executeJavaScript(script, browser.getURL(), 0);
}
} catch (UnsupportedFlavorException | IOException e) {
e.printStackTrace();
}
}
});
// 添加 alert 弹窗监控处理
client.addJSDialogHandler(new CefJSDialogHandlerAdapter() {
@Override
public boolean onJSDialog(CefBrowser browser, String origin_url, JSDialogType dialog_type, String message_text, String default_prompt_text, CefJSDialogCallback callback, BoolRef suppress_message) {
if (dialog_type == JSDialogType.JSDIALOGTYPE_ALERT) {
SwingUtilities.invokeLater(() -> {
JOptionPane.showMessageDialog(
BrowserWindowJDialog.this,
message_text,
"警告",
JOptionPane.INFORMATION_MESSAGE
);
});
callback.Continue(true, "");
return true;
}
return false;
}
});
// 3. 拦截所有新窗口(关键修复点!)
//client.addLifeSpanHandler(new CefLifeSpanHandlerAdapter() {
// @Override
// public boolean onBeforePopup(CefBrowser browser,
// CefFrame frame, String target_url, String target_frame_name) {
// return true; // 返回true表示拦截弹窗
// }
//});
Thread.currentThread().setName("BrowserRenderThread");
// 4. 加载HTML
if (htmlUrl.isEmpty()) {
String fileUrl = new File(htmlPath).toURI().toURL().toString();
System.out.println("Loading HTML from: " + fileUrl);
// 5. 创建浏览器组件(直接添加到内容面板)
browser = client.createBrowser(fileUrl, false, false);
} else {
System.out.println("Loading HTML from: " + htmlUrl);
browser = client.createBrowser(htmlUrl, false, false);
}
Component browserComponent = browser.getUIComponent();
if (builder.browserCreationCallback != null) {
boolean handled = builder.browserCreationCallback.onLayoutCustomization(
this, // 当前窗口
getContentPane(), // 内容面板
browserComponent, // 浏览器组件
builder // 构建器对象
);
// 如果回调返回true跳过默认布局
if (handled) {
// 设置窗口基本属性
setTitle(builder.title);
setSize(builder.size);
setLocationRelativeTo(builder.parentFrame);
setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
// 添加资源释放监听器
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
browser.close(true);
client.dispose();
}
});
setVisible(true);
return browserComponent; // 直接返回,跳过默认布局
}
}
updateTheme();
CefMessageRouter.CefMessageRouterConfig config = new CefMessageRouter.CefMessageRouterConfig();
config.jsQueryFunction = "javaQuery";// 定义方法
config.jsCancelFunction = "javaQueryCancel";// 定义取消方法
// 6. 配置窗口布局(确保只添加一次)
SwingUtilities.invokeLater(() -> {
getContentPane().removeAll();
getContentPane().setLayout(new BorderLayout());
// 透明拖拽层(仅顶部可拖拽)
JPanel dragPanel = new JPanel(new BorderLayout());
dragPanel.setOpaque(false);
JPanel titleBar = new JPanel();
titleBar.setOpaque(false);
titleBar.setPreferredSize(new Dimension(builder.size.width, 20));
final Point[] dragStart = new Point[1];
titleBar.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
dragStart[0] = e.getPoint();
}
@Override
public void mouseReleased(MouseEvent e) {
dragStart[0] = null;
}
});
titleBar.addMouseMotionListener(new MouseMotionAdapter() {
@Override
public void mouseDragged(MouseEvent e) {
if (dragStart[0] != null) {
Point curr = e.getLocationOnScreen();
setLocation(curr.x - dragStart[0].x, curr.y - dragStart[0].y);
}
}
});
dragPanel.add(titleBar, BorderLayout.NORTH);
getContentPane().add(dragPanel, BorderLayout.CENTER);
getContentPane().add(browserComponent, BorderLayout.CENTER);
// 7. 窗口属性设置
setTitle(builder.title);
setSize(builder.size);
setLocationRelativeTo(builder.parentFrame);
setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
// 8. 资源释放
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
browser.close(true);
client.dispose();
}
});
setVisible(true);
});
return browserComponent;
} catch (Exception e) {
e.printStackTrace();
JOptionPane.showMessageDialog(null, "初始化失败: " + e.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
}
} else {
isInitialized = false;
SwingUtilities.invokeLater(() -> {
dispose();
});
}
return null;
}
/**
* 更新主题
*/
public void updateTheme() {
// 1. 获取Java字体信息
String fontInfo = getSystemFontsInfo();
injectFontInfoToPage(browser, fontInfo);
// 2. 注入主题信息
if (AxisInnovatorsBox.getMain() != null) {
boolean isDarkTheme = AxisInnovatorsBox.getMain().getRegistrationTopic().isDarkMode();
injectThemeInfoToPage(browser, isDarkTheme);
}
//// 3. 刷新浏览器
//SwingUtilities.invokeLater(() -> {
// browser.reload();
//});
}
private void injectThemeInfoToPage(CefBrowser browser, boolean isDarkTheme) {
if (client == null) {
return;
}
client.addLoadHandler(new CefLoadHandlerAdapter() {
@Override
public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) {
String themeInfo = String.format(
"{\"isDarkTheme\": %s, \"timestamp\": %d}",
isDarkTheme,
System.currentTimeMillis()
);
String script =
"window.javaThemeInfo = " + themeInfo + ";\n" +
"console.log('Java theme information has been loaded:', window.javaThemeInfo);\n" +
"\n" +
"if (typeof applyJavaTheme === 'function') {\n" +
" applyJavaTheme(window.javaThemeInfo);\n" +
"}\n" +
"\n" +
"var event = new CustomEvent('javaThemeChanged', {\n" +
" detail: window.javaThemeInfo\n" +
"});\n" +
"document.dispatchEvent(event);\n" +
"console.log('The javaThemeChanged event is dispatched');";
browser.executeJavaScript(script, browser.getURL(), 0);
browser.executeJavaScript(
"console.log('Theme information injection is completewindow.javaThemeInfo:', typeof window.javaThemeInfo);" +
"console.log('Number of theme event listeners:', document.eventListeners ? document.eventListeners('javaThemeChanged') : '无法获取');",
browser.getURL(), 0
);
}
});
}
/**
* 获取Java字体信息从UIManager获取
*/
private String getSystemFontsInfo() {
try {
Gson gson = new Gson();
JsonObject fontInfo = new JsonObject();
JsonObject uiFonts = new JsonObject();
String[] fontKeys = {
"Label.font", "Button.font", "ToggleButton.font", "RadioButton.font",
"CheckBox.font", "ColorChooser.font", "ComboBox.font", "EditorPane.font",
"TextArea.font", "TextField.font", "PasswordField.font", "TextPane.font",
"FormattedTextField.font", "Table.font", "TableHeader.font", "List.font",
"Tree.font", "TabbedPane.font", "MenuBar.font", "Menu.font", "MenuItem.font",
"PopupMenu.font", "CheckBoxMenuItem.font", "RadioButtonMenuItem.font",
"Spinner.font", "ToolBar.font", "TitledBorder.font", "OptionPane.messageFont",
"OptionPane.buttonFont", "Panel.font", "Viewport.font", "ToolTip.font"
};
for (String key : fontKeys) {
Font font = UIManager.getFont(key);
if (font != null) {
JsonObject fontObj = new JsonObject();
fontObj.addProperty("name", font.getFontName());
fontObj.addProperty("family", font.getFamily());
fontObj.addProperty("size", font.getSize());
fontObj.addProperty("style", font.getStyle());
fontObj.addProperty("bold", font.isBold());
fontObj.addProperty("italic", font.isItalic());
fontObj.addProperty("plain", font.isPlain());
uiFonts.add(key, fontObj);
}
}
fontInfo.add("uiFonts", uiFonts);
fontInfo.addProperty("timestamp", System.currentTimeMillis());
fontInfo.addProperty("lookAndFeel", UIManager.getLookAndFeel().getName());
return gson.toJson(fontInfo);
} catch (Exception e) {
return "{\"error\": \"无法获取UIManager字体信息: " + e.getMessage() + "\"}";
}
}
/**
* 注入字体信息到页面并设置字体
*/
private void injectFontInfoToPage(CefBrowser browser, String fontInfo) {
if (client == null) {
return;
}
client.addLoadHandler(new CefLoadHandlerAdapter() {
@Override
public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) {
// 使用更简单的脚本来注入字体信息
String script =
"if (typeof window.javaFontInfo === 'undefined') {" +
" window.javaFontInfo = " + fontInfo + ";" +
" console.log('Java font information has been loaded:', window.javaFontInfo);" +
" " +
" var event = new CustomEvent('javaFontsLoaded', {" +
" detail: window.javaFontInfo" +
" });" +
" document.dispatchEvent(event);" +
" console.log('The javaFontsLoaded event is dispatched');" +
"}";
System.out.println("正在注入字体信息到页面...");
browser.executeJavaScript(script, browser.getURL(), 0);
// 添加调试信息
browser.executeJavaScript(
"console.log('Font information injection is completewindow.javaFontInfo:', typeof window.javaFontInfo);" +
"console.log('Number of event listeners:', document.eventListeners ? document.eventListeners('javaFontsLoaded') : '无法获取');",
browser.getURL(), 0
);
}
});
}
public static void printStackTrace() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
for (int i = 2; i < stackTrace.length; i++) {
StackTraceElement element = stackTrace[i];
System.out.println(element.getClassName() + "." + element.getMethodName() +
"(" + (element.getFileName() != null ? element.getFileName() : "Unknown Source") +
":" + element.getLineNumber() + ")");
}
}
@Override
public void setVisible(boolean b) {
if (b) {
if (browser != null) {
updateTheme();
}
}
super.setVisible(b);
}
public Component getBrowserComponent() {
return browserComponent;
}
private void setupMessageHandlers(WindowOperationHandler handler) {
if (client != null) {
msgRouter = CefMessageRouter.create();
msgRouter.addHandler(new CefMessageRouterHandlerAdapter() {
@Override
public boolean onQuery(CefBrowser browser,
CefFrame frame,
long queryId,
String request,
boolean persistent,
CefQueryCallback callback) {
if (request.startsWith("system:")) {
String[] parts = request.split(":");
String operation = parts.length >= 2 ? parts[1] : null;
String targetWindow = parts.length > 2 ? parts[2] : null;
handler.handleOperation(
new WindowOperation(operation, targetWindow, callback) // [!code ++]
);
return true;
}
if (request.startsWith("java-response:")) {
String[] parts = request.split(":");
String requestId = parts[1];
String responseData = parts.length > 2 ? parts[2] : "";
Consumer<String> handler = WindowRegistry.getInstance().getCallback(requestId);
if (handler != null) {
handler.accept(responseData);
callback.success("");
} else {
callback.failure(-1, "无效的请求ID");
}
return true;
}
return false;
}
}, true);
client.addMessageRouter(msgRouter);
}
}
public String getWindowId() {
return windowId;
}
/**
* 获取消息路由器
* @return 消息路由器
*/
public CefMessageRouter getMsgRouter() {
return msgRouter;
}
/**
* 获取浏览器对象
* @return 浏览器对象
*/
public CefBrowser getBrowser() {
return browser;
}
public void closeWindow() {
SwingUtilities.invokeLater(() -> {
if (browser != null) {
browser.close(true);
}
dispose();
cefApp.dispose();
WindowRegistry.getInstance().unregisterWindow(windowId);
});
}
}

View File

@@ -0,0 +1,280 @@
package com.chuangzhou.vivid2D.browser;
import com.axis.innovators.box.AxisInnovatorsBox;
import com.axis.innovators.box.register.LanguageManager;
import com.axis.innovators.box.tools.FolderCreator;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.cef.CefApp;
import org.cef.CefSettings;
import org.cef.callback.CefCommandLine;
import org.cef.handler.CefAppHandlerAdapter;
import java.io.File;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 增强版CEF应用管理器
* 特性:
* 1. 多级锁并发控制
* 2. 设置冲突自动恢复
* 3. 状态跟踪和验证
* 4. 增强的异常处理
*
* @author tzdwindows 7
*/
public class CefAppManager {
private static final Logger logger = LogManager.getLogger(CefAppManager.class);
private static volatile CefApp cefApp;
private static final CefSettings settings = new CefSettings();
// 状态跟踪
private static final AtomicBoolean isInitialized = new AtomicBoolean(false);
private static final AtomicBoolean settingsApplied = new AtomicBoolean(false);
private static final AtomicBoolean isDisposing = new AtomicBoolean(false);
// 并发控制
private static final Lock initLock = new ReentrantLock();
private static final Lock disposeLock = new ReentrantLock();
private static final AtomicBoolean shutdownHookRegistered = new AtomicBoolean(false);
static {
initializeDefaultSettings();
registerShutdownHook();
}
private static void registerShutdownHook() {
if (shutdownHookRegistered.compareAndSet(false, true)) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
logger.info("JVM shutdown hook triggered");
dispose(true);
}));
logger.debug("Shutdown hook registered successfully");
}
}
/**
* 初始化Cef
*/
private static void initializeDefaultSettings() {
initLock.lock();
try {
settings.windowless_rendering_enabled = false;
settings.javascript_flags = "";
settings.cache_path = FolderCreator.getLibraryFolder() + "/jcef/cache";
settings.root_cache_path = FolderCreator.getLibraryFolder() + "/jcef/cache";
settings.persist_session_cookies = false;
settings.log_severity = CefSettings.LogSeverity.LOGSEVERITY_WARNING;
String subprocessPath = FolderCreator.getLibraryFolder() + "/jcef/lib/win64/jcef_helper.exe";
validateSubprocessPath(subprocessPath);
settings.browser_subprocess_path = subprocessPath;
//settings.background_color = new Color(255, 255, 255, 0);
settings.command_line_args_disabled = false;
// 转换语言标识格式system:zh_CN -> zh-CN
CefApp.addAppHandler(new CefAppHandlerAdapter(null) {
@Override
public void onBeforeCommandLineProcessing(
String processType,
CefCommandLine commandLine
) {
//commandLine.appendSwitch("disable-dev-tools");
//commandLine.appendSwitch("disable-view-source");
LanguageManager.loadSavedLanguage();
LanguageManager.Language currentLang = LanguageManager.getLoadedLanguages();
if (currentLang != null){
String langCode = currentLang.getRegisteredName()
.replace("system:", "")
.replace("_", "-")
.toLowerCase();
settings.locale = langCode;
commandLine.appendSwitchWithValue("--lang", langCode);
commandLine.appendSwitchWithValue("--accept-language", langCode);
}
boolean isDarkTheme = isDarkTheme();
if (isDarkTheme) {
commandLine.appendSwitch("force-dark-mode");
commandLine.appendSwitchWithValue("enable-features", "WebContentsForceDark");
}
logger.info("CEF commandLine: {}", commandLine.getSwitches());
}
});
logger.info("Optimized CEF settings initialized");
} finally {
initLock.unlock();
}
}
/**
* 判断地区主题是否为黑色主题
* @return 是否
*/
private static boolean isDarkTheme() {
if (AxisInnovatorsBox.getMain() == null){
return false;
}
return AxisInnovatorsBox.getMain().getRegistrationTopic().isDarkMode();
}
public static CefApp getInstance() {
if (cefApp == null) {
if (initLock.tryLock()) {
try {
performSafeInitialization();
} finally {
initLock.unlock();
}
} else {
handleConcurrentInitialization();
}
}
return cefApp;
}
private static void performSafeInitialization() {
if (cefApp != null) return;
if (isDisposing.get()) {
throw new IllegalStateException("CEF is during disposal process");
}
try {
// 阶段1启动CEF运行时
if (!CefApp.startup(new String[0])) {
throw new IllegalStateException("CEF native startup failed");
}
// 阶段2应用设置仅首次
if (settingsApplied.compareAndSet(false, true)) {
cefApp = CefApp.getInstance(settings);
logger.info("CEF initialized with custom settings");
} else {
cefApp = CefApp.getInstance();
logger.info("CEF reused existing instance");
}
isInitialized.set(true);
} catch (IllegalStateException ex) {
handleInitializationError(ex);
}
}
private static void handleConcurrentInitialization() {
try {
if (initLock.tryLock(3, TimeUnit.SECONDS)) {
try {
if (cefApp == null) {
performSafeInitialization();
}
} finally {
initLock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.warn("CEF initialization interrupted");
}
}
private static void handleInitializationError(IllegalStateException ex) {
if (ex.getMessage().contains("Settings can only be passed")) {
logger.warn("Settings conflict detected, recovering...");
recoverFromSettingsConflict();
} else if (ex.getMessage().contains("was terminated")) {
handleTerminatedState(ex);
} else {
logger.error("Critical CEF error", ex);
throw new RuntimeException("CEF initialization failed", ex);
}
}
private static void recoverFromSettingsConflict() {
disposeLock.lock();
try {
if (cefApp == null) {
cefApp = CefApp.getInstance();
settingsApplied.set(true);
isInitialized.set(true);
logger.info("Recovered from settings conflict");
}
} finally {
disposeLock.unlock();
}
}
private static void handleTerminatedState(IllegalStateException ex) {
disposeLock.lock();
try {
logger.warn("CEF terminated state detected");
dispose(false);
performEmergencyRecovery();
} finally {
disposeLock.unlock();
}
}
private static void performEmergencyRecovery() {
try {
logger.info("Attempting emergency recovery...");
CefApp.startup(new String[0]);
cefApp = CefApp.getInstance();
isInitialized.set(true);
settingsApplied.set(true);
logger.info("Emergency recovery successful");
} catch (Exception e) {
logger.error("Emergency recovery failed", e);
throw new RuntimeException("Unrecoverable CEF state", e);
}
}
public static synchronized void dispose(boolean isShutdownHook) {
disposeLock.lock();
try {
if (cefApp == null || isDisposing.get()) return;
isDisposing.set(true);
try {
logger.info("Disposing CEF resources...");
cefApp.dispose();
if (!isShutdownHook) {
cefApp = null;
isInitialized.set(false);
settingsApplied.set(false);
}
logger.info("CEF resources released");
} catch (Exception e) {
logger.error("Disposal error", e);
} finally {
isDisposing.set(false);
}
} finally {
disposeLock.unlock();
}
}
private static void validateSubprocessPath(String path) {
File exeFile = new File(path);
if (!exeFile.exists()) {
String errorMsg = "JCEF helper executable missing: " + path;
logger.error(errorMsg);
throw new IllegalStateException(errorMsg);
}
logger.debug("Validated JCEF helper at: {}", path);
}
// 状态查询接口
public static String getInitStatus() {
return String.format("Initialized: %s, SettingsApplied: %s, Disposing: %s",
isInitialized.get(), settingsApplied.get(), isDisposing.get());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
package com.chuangzhou.vivid2D.browser;
import org.cef.callback.CefQueryCallback;
/**
* @author tzdwindows 7
*/
public record WindowOperation(String type, String targetWindow, CefQueryCallback callback) {
}

View File

@@ -0,0 +1,94 @@
package com.chuangzhou.vivid2D.browser;
import javax.swing.*;
import java.awt.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/**
* @author tzdwindows 7
*/
public class WindowOperationHandler {
private final WindowRegistry registry;
private final Map<String, Consumer<String>> operations;
private final Component attachedComponent;
public static class Builder {
private WindowRegistry registry = WindowRegistry.getInstance();
private final Map<String, Consumer<String>> operations = new ConcurrentHashMap<>();
private Component attachedComponent;
public Builder attachTo(Component component) {
this.attachedComponent = component;
return this;
}
public Builder withDefaultOperations() {
this.operations.put("open", target -> {
registry.getWindow(target).setVisible(true);
});
this.operations.put("close", target -> {
if (target != null) {
System.out.println("Close window: " + target);
registry.unregisterWindow(target);
} else if (attachedComponent != null) {
Window window = SwingUtilities.getWindowAncestor(attachedComponent);
if (window instanceof BrowserWindow) {
((BrowserWindow) window).closeWindow();
}
}
});
return this;
}
public Builder onOperation(String operation, Consumer<String> handler) {
this.operations.put(operation, handler);
return this;
}
public WindowOperationHandler build() {
return new WindowOperationHandler(this);
}
private void handleOpen(String targetWindow) {
registry.createNewWindow(targetWindow, builder ->
builder.title("New Window")
);
}
private void handleClose(String targetWindow) {
if (targetWindow != null) {
registry.unregisterWindow(targetWindow);
} else {
handleCurrentWindowClose();
}
}
private void handleCurrentWindowClose() {
if (attachedComponent != null) {
BrowserWindow currentWindow = (BrowserWindow)
SwingUtilities.getWindowAncestor(attachedComponent);
if (currentWindow != null) {
registry.unregisterWindow(currentWindow.getWindowId());
}
}
}
}
private WindowOperationHandler(Builder builder) {
this.registry = builder.registry;
this.attachedComponent = builder.attachedComponent;
this.operations = new ConcurrentHashMap<>(builder.operations);
}
public void handleOperation(WindowOperation operation) {
Consumer<String> handler = operations.get(operation.type());
if (handler != null) {
handler.accept(operation.targetWindow());
operation.callback().success("操作成功: " + operation.type());
} else {
operation.callback().failure(-1, "未定义的操作: " + operation.type());
}
}
}

View File

@@ -0,0 +1,153 @@
package com.chuangzhou.vivid2D.browser;
import com.axis.innovators.box.tools.FolderCreator;
import org.cef.browser.CefBrowser;
import org.cef.browser.CefFrame;
import org.cef.handler.CefLoadHandlerAdapter;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
public class WindowRegistry {
private static WindowRegistry instance;
private final ConcurrentMap<String, BrowserWindow> windows =
new ConcurrentHashMap<>();
private final ConcurrentMap<String, BrowserWindowJDialog> childWindows =
new ConcurrentHashMap<>();
private final Map<String, Consumer<String>> callbacks = new ConcurrentHashMap<>();
private WindowRegistry() {}
public static synchronized WindowRegistry getInstance() {
if (instance == null) {
instance = new WindowRegistry();
}
return instance;
}
public void registerWindow(BrowserWindow window) {
windows.put(window.getWindowId(), window);
}
public void registerChildWindow(BrowserWindowJDialog window) {
childWindows.put(window.getWindowId(), window);
}
public void registerCallback(String requestId, Consumer<String> handler) {
callbacks.put(requestId, handler);
}
public Consumer<String> getCallback(String requestId) {
return callbacks.remove(requestId);
}
public void unregisterWindow(String windowId) {
BrowserWindow window = windows.remove(windowId);
if (window != null) {
window.closeWindow();
}
}
public BrowserWindow getWindow(String windowId) {
return windows.get(windowId);
}
public void update() {
for (BrowserWindow window : windows.values()) {
if (window != null) {
window.updateTheme();
}
}
for (BrowserWindowJDialog window : childWindows.values()) {
if (window != null) {
window.updateTheme();
}
}
}
/**
* 创建一个新的窗口
* @param windowId 窗口ID
* @param config 窗口配置
*/
public void createNewWindow(String windowId, Consumer<BrowserWindow.Builder> config) {
BrowserWindow.Builder builder = new BrowserWindow.Builder(windowId);
config.accept(builder);
BrowserWindow window = builder.build();
registerWindow(window);
loadExtLibsPath(window);
}
/**
* 创建一个新的子窗口
* @param windowId 窗口ID
* @param config 窗口配置
*/
public void createNewChildWindow(String windowId, Consumer<BrowserWindowJDialog.Builder> config) {
BrowserWindowJDialog.Builder builder = new BrowserWindowJDialog.Builder(windowId);
config.accept(builder);
BrowserWindowJDialog window = builder.build();
registerChildWindow(window);
loadExtLibsPath(window);
}
private void loadExtLibsPath(BrowserWindow window) {
CefBrowser cefBrowser = window.getBrowser();
if (cefBrowser != null)
// 使用 CefClient 的调度方法(如果可用)或直接添加 LoadHandler
cefBrowser.getClient().addLoadHandler(new CefLoadHandlerAdapter() {
@Override
public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) {
if (frame.isMain()) {
try {
String extLibsPath = FolderCreator.getJavaScriptFolder() + "\\" + "extLibs";
File extLibsDir = new File(extLibsPath);
if (!extLibsDir.exists() || !extLibsDir.isDirectory()) {
throw new IOException("extLibs目录无效: " + extLibsPath);
}
String script = "window.extLibsPath = " + JSONObject.valueToString(extLibsPath) + ";";
browser.executeJavaScript(script, frame.getURL(), 0);
} catch (Exception e) {
System.err.println("注入extLibsPath失败: " + e.getMessage());
e.printStackTrace();
}
}
}
});
}
private void loadExtLibsPath(BrowserWindowJDialog window) {
CefBrowser cefBrowser = window.getBrowser();
if (cefBrowser != null)
// 使用 CefClient 的调度方法(如果可用)或直接添加 LoadHandler
cefBrowser.getClient().addLoadHandler(new CefLoadHandlerAdapter() {
@Override
public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) {
if (frame.isMain()) {
try {
String extLibsPath = FolderCreator.getJavaScriptFolder() + "\\" + "extLibs";
File extLibsDir = new File(extLibsPath);
if (!extLibsDir.exists() || !extLibsDir.isDirectory()) {
throw new IOException("extLibs目录无效: " + extLibsPath);
}
String script = "window.extLibsPath = " + JSONObject.valueToString(extLibsPath) + ";";
browser.executeJavaScript(script, frame.getURL(), 0);
} catch (Exception e) {
System.err.println("注入extLibsPath失败: " + e.getMessage());
e.printStackTrace();
}
}
}
});
}
}

View File

@@ -0,0 +1,346 @@
package com.chuangzhou.vivid2D.browser.util;
import javax.tools.JavaCompiler;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class CodeExecutor {
// 用于捕获输出的回调接口
public interface OutputListener {
void onOutput(String newOutput);
}
/**
* 执行代码
* @param code 代码字符串
* @param language 代码类型
* @param listener 回调,代码的输出回调
* @return 返回i执行结果
*/
public static String executeCode(String code, String language, OutputListener listener) {
switch (language.toLowerCase()) {
case "python":
return executePythonNative(code, listener);
case "c":
case "cpp":
return executeC(code, listener);
case "java":
return executeJavaCode(code, listener);
default:
return "不支持的语言类型: " + language;
}
}
/**
* 执行Java代码
* @return 返回执行结果
*/
public static String executeJavaCode(String code, OutputListener listener) {
Path tempDir = null;
try {
// ===== 1. 创建临时目录 =====
tempDir = Files.createTempDirectory("javaCode");
// ===== 2. 写入Java源文件强制UTF-8=====
Path javaFile = tempDir.resolve("Main.java");
Files.writeString(javaFile,
code,
StandardCharsets.UTF_8
);
// ===== 3. 编译时指定编码 =====
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8);
List<String> options = new ArrayList<>();
options.add("-encoding");
options.add("UTF-8");
options.add("-d");
options.add(tempDir.toString());
JavaCompiler.CompilationTask task = compiler.getTask(
null,
fileManager,
null,
options,
null,
fileManager.getJavaFileObjects(javaFile)
);
if (!task.call()) {
return "编译失败";
}
// ===== 4. 执行配置 =====
String javaExe = Path.of(System.getProperty("java.home"), "bin", "java").toString();
ProcessBuilder pb = new ProcessBuilder(
javaExe,
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8", // 针对OpenJDK的特殊设置
"-Dsun.stderr.encoding=UTF-8",
"-cp",
tempDir.toString(),
"Main"
);
// ===== 5. 设置环境变量 =====
Map<String, String> env = pb.environment();
env.put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
env.put("LANG", "en_US.UTF-8"); // Linux/macOS
env.put("LC_ALL", "en_US.UTF-8");
// ===== 6. 输出处理 =====
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder output = new StringBuilder();
char[] buffer = new char[4096];
int charsRead;
while ((charsRead = reader.read(buffer)) != -1) {
String chunk = new String(buffer, 0, charsRead);
output.append(chunk);
if (listener != null) {
// 处理控制台编码转换Windows专用
if (System.getProperty("os.name").startsWith("Windows")) {
chunk = new String(chunk.getBytes(StandardCharsets.UTF_8), "GBK");
}
listener.onOutput(chunk);
}
}
int exitCode = process.waitFor();
return output.toString() + "\n退出码: " + exitCode;
}
} catch (Exception e) {
return "执行错误: " + e.getMessage();
} finally {
// 清理代码...
}
}
/**
* 需要用户安装python环境
*/
private static String executePythonNative(String code, OutputListener listener) {
try {
Path pythonFile = Files.createTempFile("script_", ".py");
Files.writeString(pythonFile,
"# -*- coding: utf-8 -*-\n" + code,
StandardCharsets.UTF_8
);
ProcessBuilder pb = new ProcessBuilder("python", pythonFile.toString())
.redirectErrorStream(true);
Map<String, String> env = pb.environment();
env.put("PYTHONIOENCODING", "UTF-8");
env.put("PYTHONUTF8", "1");
Process process = pb.start();
StringBuilder output = new StringBuilder();
Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String finalLine = line + "\n";
output.append(finalLine);
if (listener != null) {
listener.onOutput(finalLine);
}
}
} catch (IOException e) {
e.printStackTrace();
}
});
outputThread.start();
// 等待执行完成
int exitCode = process.waitFor();
outputThread.join();
// 清理文件
Files.deleteIfExists(pythonFile);
return String.format("退出码: %d\n输出内容:\n%s", exitCode, output);
} catch (Exception e) {
return "执行错误: " + e.getMessage();
}
}
/**
* 执行C代码
*/
private static String executeC(String code, OutputListener listener) {
Path tempDir = null;
Path cFile = null;
Path exeFile = null;
try {
// 创建临时工作目录
tempDir = Files.createTempDirectory("c_compile_");
// 生成C源代码文件
cFile = tempDir.resolve("program.c");
Files.writeString(cFile, code, StandardCharsets.UTF_8);
// 生成可执行文件路径
exeFile = tempDir.resolve("program.exe");
// 1. 编译代码 -------------------------------------------------
String tccPath =System.getProperty("user.dir") + "/library/tcc/tcc.exe";
Process compileProcess;
if (listener != null) {
compileProcess = new ProcessBuilder(
tccPath,
"-o", exeFile.toString(),
cFile.toString()
)
.directory(tempDir.toFile())
.redirectErrorStream(true)
.start();
// 捕获编译输出
StringBuilder compileOutput = new StringBuilder();
Thread compileOutputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(compileProcess.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
compileOutput.append(line).append("\n");
if (listener != null) {
listener.onOutput("[编译输出] " + line + "\n");
}
}
} catch (IOException e) {
e.printStackTrace();
}
});
compileOutputThread.start();
// 等待编译完成
int compileExitCode = compileProcess.waitFor();
compileOutputThread.join(1000);
if (compileExitCode != 0) {
return "编译失败:\n" + compileOutput;
}
// 2. 执行程序 -------------------------------------------------
Process executeProcess = new ProcessBuilder(exeFile.toString())
.directory(tempDir.toFile())
.redirectErrorStream(true)
.start();
// 实时输出处理
AtomicReference<StringBuilder> execOutput = new AtomicReference<>(new StringBuilder());
Thread executeOutputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(executeProcess.getInputStream(), StandardCharsets.UTF_8))) {
char[] buffer = new char[1024];
int charsRead;
while ((charsRead = reader.read(buffer)) != -1) {
String outputChunk = new String(buffer, 0, charsRead);
execOutput.get().append(outputChunk);
if (listener != null) {
listener.onOutput(outputChunk);
}
}
} catch (IOException e) {
e.printStackTrace();
}
});
executeOutputThread.start();
// 等待执行完成最多10秒
boolean finished = executeProcess.waitFor(10, TimeUnit.SECONDS);
executeOutputThread.join(1000);
if (!finished) {
executeProcess.destroyForcibly();
return "执行超时\n部分输出:\n" + execOutput.get();
}
// 获取最终输出
String finalOutput = execOutput.get().toString();
int exitCode = executeProcess.exitValue();
return String.format("执行结果: %s\n退出码: %d\n输出内容:\n%s",
exitCode == 0 ? "成功" : "失败",
exitCode,
finalOutput);
} else {
new ProcessBuilder(
tccPath,
"-o", exeFile.toString(),
cFile.toString()
)
.directory(tempDir.toFile())
.redirectErrorStream(true)
.start();
new ProcessBuilder(
"cmd.exe",
"/c",
"start",
"\"Tzd输出窗口\"",
"cmd.exe",
"/K",
"chcp 65001 & ",
exeFile.toString()
).start();
return String.format("执行结果: %s\n退出码: %d\n输出内容:\n%s",
"成功",
0,
"");
}
} catch (Exception e) {
return "执行错误: " + e.getMessage();
} finally {
// 清理临时文件
try {
if (listener != null){
if (cFile != null) Files.deleteIfExists(cFile);
if (exeFile != null) Files.deleteIfExists(exeFile);
if (tempDir != null) Files.deleteIfExists(tempDir);
}
} catch (IOException e) {
System.err.println("临时文件清理失败: " + e.getMessage());
}
}
}
// 使用方法
public static void main(String[] args) {
String pythonCode = "#include <stdio.h>\n" +
"\n" +
"int main() {\n" +
" while (1){\n" +
" printf(\"Hello World\\n\");\n" +
"}\n" +
" return 0;\n" +
"}";
executeCode(pythonCode, "c", null);
}
}

View File

@@ -0,0 +1,399 @@
package com.chuangzhou.vivid2D.browser.util;
import java.sql.*;
import java.util.Map;
import java.util.Properties;
/**
* 数据库连接管理器
* @author tzdwindows 7
*/
public class DatabaseConnectionManager {
private static final Map<String, Connection> connections = new java.util.concurrent.ConcurrentHashMap<>();
private static final Map<String, DatabaseInfo> connectionInfo = new java.util.concurrent.ConcurrentHashMap<>();
public static class DatabaseInfo {
public String driver;
public String url;
public String host;
public String port;
public String database;
public String username;
public DatabaseInfo(String driver, String url, String host, String port, String database, String username) {
this.driver = driver;
this.url = url;
this.host = host;
this.port = port;
this.database = database;
this.username = username;
}
}
public static String connect(String driver, String host, String port,
String database, String username, String password) throws SQLException {
String connectionId = "conn_" + System.currentTimeMillis();
String drv = driver == null ? "" : driver.toLowerCase();
// 规范化 database 路径(特别是 Windows 反斜杠问题)
if (database != null) {
database = database.replace("\\", "/");
} else {
database = "";
}
// 先显式加载驱动,避免因为 classloader 问题找不到驱动
try {
switch (drv) {
case "mysql":
Class.forName("com.mysql.cj.jdbc.Driver");
break;
case "postgresql":
Class.forName("org.postgresql.Driver");
break;
case "sqlite":
Class.forName("org.sqlite.JDBC");
break;
case "oracle":
Class.forName("oracle.jdbc.OracleDriver");
break;
case "h2":
Class.forName("org.h2.Driver");
break;
default:
// 不抛出,使后续 URL 构造仍可检查类型
}
} catch (ClassNotFoundException e) {
throw new SQLException("JDBC 驱动未找到,请确认对应驱动已加入 classpath: " + e.getMessage(), e);
}
String url = buildConnectionUrl(driver, host, port, database);
Connection connection;
Properties props = new Properties();
if (username != null && !username.isEmpty()) props.setProperty("user", username);
if (password != null && !password.isEmpty()) props.setProperty("password", password);
switch (drv) {
case "mysql":
props.setProperty("useSSL", "false");
props.setProperty("serverTimezone", "UTC");
props.setProperty("allowPublicKeyRetrieval", "true");
props.setProperty("useUnicode", "true");
props.setProperty("characterEncoding", "UTF-8");
connection = DriverManager.getConnection(url, props);
break;
case "postgresql":
connection = DriverManager.getConnection(url, props);
break;
case "sqlite":
// sqlite 不需要 propsURL 已经是文件路径(已做过替换)
connection = DriverManager.getConnection(url);
break;
case "oracle":
connection = DriverManager.getConnection(url, props);
break;
case "h2":
// H2 使用默认用户 sa / 空密码(如果需要可调整)
connection = DriverManager.getConnection(url, "sa", "");
break;
default:
throw new SQLException("不支持的数据库类型: " + driver);
}
connections.put(connectionId, connection);
connectionInfo.put(connectionId, new DatabaseInfo(driver, url, host, port, database, username));
return connectionId;
}
public static void disconnect(String connectionId) throws SQLException {
Connection connection = connections.get(connectionId);
if (connection != null && !connection.isClosed()) {
connection.close();
}
connections.remove(connectionId);
connectionInfo.remove(connectionId);
}
public static Connection getConnection(String connectionId) {
return connections.get(connectionId);
}
public static DatabaseInfo getConnectionInfo(String connectionId) {
return connectionInfo.get(connectionId);
}
private static String buildConnectionUrl(String driver, String host, String port, String database) {
String drv = driver == null ? "" : driver.toLowerCase();
switch (drv) {
case "mysql":
return "jdbc:mysql://" + host + ":" + port + "/" + database;
case "postgresql":
return "jdbc:postgresql://" + host + ":" + port + "/" + database;
case "sqlite":
// 对于 SQLitedatabase 可能是绝对路径或相对文件名,先把反斜杠替成正斜杠
if (database == null || database.isEmpty()) {
return "jdbc:sqlite::memory:";
}
String normalized = database.replace("\\", "/");
// 如果看起来像相对文件名(不含冒号也不以 / 开头),则当作相对于用户目录的路径
if (!normalized.contains(":") && !normalized.startsWith("/")) {
String userHome = System.getProperty("user.home").replace("\\", "/");
normalized = userHome + "/" + normalized;
}
return "jdbc:sqlite:" + normalized;
case "oracle":
return "jdbc:oracle:thin:@" + host + ":" + port + ":" + database;
case "h2":
// H2 文件路径同样做反斜杠处理
if (database == null || database.isEmpty()) {
String userHome = System.getProperty("user.home").replace("\\", "/");
return "jdbc:h2:file:" + userHome + "/.axis_innovators_box/databases/h2db";
} else {
String norm = database.replace("\\", "/");
// 如果传入仅是名字(无斜杠或冒号),则存到用户目录下
if (!norm.contains("/") && !norm.contains(":")) {
String userHome = System.getProperty("user.home").replace("\\", "/");
norm = userHome + "/.axis_innovators_box/databases/" + norm;
}
return "jdbc:h2:file:" + norm;
}
default:
throw new IllegalArgumentException("不支持的数据库类型: " + driver);
}
}
/**
* 在服务器上创建数据库MySQL / PostgreSQL / Oracle示例
* @param driver mysql | postgresql | oracle
* @param host 数据库主机
* @param port 端口
* @param dbName 要创建的数据库名(或 schema 名)
* @param adminUser 管理员用户名(用于创建数据库)
* @param adminPassword 管理员密码
* @return 如果创建成功返回一个简短消息,否则抛出 SQLException
* @throws SQLException
*/
public static String createDatabaseOnServer(String driver, String host, String port,
String dbName, String adminUser, String adminPassword) throws SQLException {
if (driver == null) throw new SQLException("driver 不能为空");
String drv = driver.toLowerCase().trim();
// 简单校验 dbName避免注入——只允许字母数字下划线
if (dbName == null || !dbName.matches("[A-Za-z0-9_]+")) {
throw new SQLException("不合法的数据库名: " + dbName);
}
try {
switch (drv) {
case "mysql":
// 加载驱动(如果尚未加载)
try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) {
throw new SQLException("MySQL 驱动未找到,请加入 mysql-connector-java 到 classpath", e);
}
// 连接到服务器的默认库(不指定数据库)以执行 CREATE DATABASE
String mysqlUrl = "jdbc:mysql://" + host + ":" + port + "/?useSSL=false&serverTimezone=UTC";
try (Connection conn = DriverManager.getConnection(mysqlUrl, adminUser, adminPassword);
Statement st = conn.createStatement()) {
String sql = "CREATE DATABASE `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci";
st.executeUpdate(sql);
}
return "MySQL 数据库创建成功: " + dbName;
case "postgresql":
case "postgres":
try { Class.forName("org.postgresql.Driver"); } catch (ClassNotFoundException e) {
throw new SQLException("Postgres 驱动未找到,请加入 postgresql 到 classpath", e);
}
// 连接到默认 postgres 数据库以创建新数据库
String pgUrl = "jdbc:postgresql://" + host + ":" + port + "/postgres";
try (Connection conn = DriverManager.getConnection(pgUrl, adminUser, adminPassword);
Statement st = conn.createStatement()) {
String sql = "CREATE DATABASE " + dbName + " WITH ENCODING 'UTF8'";
st.executeUpdate(sql);
}
return "PostgreSQL 数据库创建成功: " + dbName;
case "oracle":
// Oracle 数据库“创建数据库”通常由 DBA 完成(复杂),这里示例创建用户/模式(更常见)
try { Class.forName("oracle.jdbc.OracleDriver"); } catch (ClassNotFoundException e) {
throw new SQLException("Oracle 驱动未找到,请把 ojdbc.jar 加入 classpath", e);
}
// 需使用具有足够权限的账户(通常为 sys or system并且 URL 需要正确SID / ServiceName
// 下面示例假设通过 SID 连接: jdbc:oracle:thin:@host:port:SID
String oracleUrl = "jdbc:oracle:thin:@" + host + ":" + port + ":" + "ORCL"; // 把 ORCL 换成实际 SID
try (Connection conn = DriverManager.getConnection(oracleUrl, adminUser, adminPassword);
Statement st = conn.createStatement()) {
// 创建 userschema示例
String pwd = adminPassword; // 实际应使用独立密码,不推荐用 adminPassword
String createUser = "CREATE USER " + dbName + " IDENTIFIED BY \"" + pwd + "\"";
String grant = "GRANT CONNECT, RESOURCE TO " + dbName;
st.executeUpdate(createUser);
st.executeUpdate(grant);
} catch (SQLException ex) {
// Oracle 操作更容易失败,给出提示
throw new SQLException("Oracle: 无法创建用户/模式,请检查权限和 URL通常需由 DBA 操作): " + ex.getMessage(), ex);
}
return "Oracle 用户/模式创建成功(注意:真正的 DB 实例通常由 DBA 管理): " + dbName;
default:
throw new SQLException("不支持的数据库类型: " + driver);
}
} catch (SQLException se) {
// 透传 SQLException调用方会拿到 message 并反馈给前端
throw se;
} catch (Exception e) {
throw new SQLException("创建数据库时发生异常: " + e.getMessage(), e);
}
}
public static String createLocalDatabase(String driver, String dbName) throws SQLException {
switch (driver.toLowerCase()) {
case "sqlite":
// 创建目录并构造规范化路径(确保路径使用正斜杠)
String dbFileName = dbName.endsWith(".db") ? dbName : (dbName + ".db");
java.nio.file.Path dbDir = java.nio.file.Paths.get(System.getProperty("user.home"), ".axis_innovators_box", "databases");
try {
java.nio.file.Files.createDirectories(dbDir);
} catch (Exception e) {
throw new SQLException("无法创建数据库目录: " + e.getMessage(), e);
}
String dbPath = dbDir.resolve(dbFileName).toAbsolutePath().toString().replace("\\", "/");
// 显式加载 sqlite 驱动(避免 No suitable driver
try {
Class.forName("org.sqlite.JDBC");
} catch (ClassNotFoundException e) {
throw new SQLException("未找到 sqlite 驱动,请确认 sqlite-jdbc 已加入 classpath", e);
}
// 直接使用 connect 构建连接connect 中会通过 buildConnectionUrl 处理 path
String connectionId = connect("sqlite", "", "", dbPath, "", "");
// 创建示例表
createSampleTables(connectionId);
return connectionId;
case "h2":
java.nio.file.Path h2Dir = java.nio.file.Paths.get(System.getProperty("user.home"), ".axis_innovators_box", "databases");
try {
java.nio.file.Files.createDirectories(h2Dir);
} catch (Exception e) {
throw new SQLException("无法创建数据库目录: " + e.getMessage(), e);
}
String h2Path = h2Dir.resolve(dbName).toAbsolutePath().toString().replace("\\", "/");
String h2Url = "jdbc:h2:file:" + h2Path;
try {
Class.forName("org.h2.Driver");
} catch (ClassNotFoundException e) {
throw new SQLException("未找到 H2 驱动,请确认 h2.jar 已加入 classpath", e);
}
Connection h2Conn = DriverManager.getConnection(h2Url, "sa", "");
String h2ConnectionId = "conn_" + System.currentTimeMillis();
connections.put(h2ConnectionId, h2Conn);
connectionInfo.put(h2ConnectionId, new DatabaseInfo("h2", h2Url, "localhost", "", dbName, "sa"));
createSampleTables(h2ConnectionId);
return h2ConnectionId;
default:
throw new SQLException("不支持创建本地数据库类型: " + driver);
}
}
private static void createSampleTables(String connectionId) throws SQLException {
Connection conn = getConnection(connectionId);
DatabaseInfo info = getConnectionInfo(connectionId);
if ("sqlite".equals(info.driver) || "h2".equals(info.driver)) {
try (Statement stmt = conn.createStatement()) {
// 创建用户表
stmt.execute("CREATE TABLE IF NOT EXISTS users (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"username VARCHAR(50) NOT NULL UNIQUE, " +
"email VARCHAR(100) NOT NULL, " +
"password VARCHAR(100) NOT NULL, " +
"status VARCHAR(20) DEFAULT 'active', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
")");
// 创建产品表
stmt.execute("CREATE TABLE IF NOT EXISTS products (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"name VARCHAR(100) NOT NULL, " +
"description TEXT, " +
"price DECIMAL(10,2) NOT NULL, " +
"stock INTEGER DEFAULT 0, " +
"category VARCHAR(50), " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
")");
// 创建订单表
stmt.execute("CREATE TABLE IF NOT EXISTS orders (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"user_id INTEGER, " +
"product_id INTEGER, " +
"quantity INTEGER NOT NULL, " +
"total_price DECIMAL(10,2) NOT NULL, " +
"status VARCHAR(20) DEFAULT 'pending', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"FOREIGN KEY (user_id) REFERENCES users(id), " +
"FOREIGN KEY (product_id) REFERENCES products(id)" +
")");
// 插入示例数据
insertSampleData(conn);
}
}
}
private static void insertSampleData(Connection conn) throws SQLException {
// 检查是否已有数据
try (Statement checkStmt = conn.createStatement();
ResultSet rs = checkStmt.executeQuery("SELECT COUNT(*) FROM users")) {
if (rs.next() && rs.getInt(1) == 0) {
// 插入用户数据
try (PreparedStatement pstmt = conn.prepareStatement(
"INSERT INTO users (username, email, password) VALUES (?, ?, ?)")) {
String[][] users = {
{"admin", "admin@example.com", "password123"},
{"user1", "user1@example.com", "password123"},
{"user2", "user2@example.com", "password123"}
};
for (String[] user : users) {
pstmt.setString(1, user[0]);
pstmt.setString(2, user[1]);
pstmt.setString(3, user[2]);
pstmt.executeUpdate();
}
}
// 插入产品数据
try (PreparedStatement pstmt = conn.prepareStatement(
"INSERT INTO products (name, description, price, stock, category) VALUES (?, ?, ?, ?, ?)")) {
Object[][] products = {
{"笔记本电脑", "高性能笔记本电脑", 5999.99, 50, "电子"},
{"智能手机", "最新款智能手机", 3999.99, 100, "电子"},
{"办公椅", "舒适办公椅", 299.99, 30, "家居"},
{"咖啡机", "全自动咖啡机", 899.99, 20, "家电"}
};
for (Object[] product : products) {
pstmt.setString(1, (String) product[0]);
pstmt.setString(2, (String) product[1]);
pstmt.setDouble(3, (Double) product[2]);
pstmt.setInt(4, (Integer) product[3]);
pstmt.setString(5, (String) product[4]);
pstmt.executeUpdate();
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
package com.chuangzhou.vivid2D.render.awt;
import javax.swing.*;
public class EventPanel extends JPanel {
}

View File

@@ -2,7 +2,6 @@ package com.chuangzhou.vivid2D.render.awt.manager;
import com.chuangzhou.vivid2D.render.model.Model2D;
import com.chuangzhou.vivid2D.render.model.ModelPart;
import org.joml.Vector2f;
import java.io.Serial;
import java.io.Serializable;

View File

@@ -8,6 +8,9 @@ import com.chuangzhou.vivid2D.render.model.AnimationParameter;
import com.chuangzhou.vivid2D.render.model.ModelEvent;
import com.chuangzhou.vivid2D.render.model.ModelPart;
import com.chuangzhou.vivid2D.render.model.util.Vertex;
import com.chuangzhou.vivid2D.render.systems.Matrix3fUtils;
import org.joml.Matrix3f;
import org.joml.Vector2f;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,21 +26,26 @@ public class ParametersManagement {
public ParametersManagement(ParametersPanel parametersPanel) {
this.parametersPanel = parametersPanel;
ModelRenderPanel renderPanel = parametersPanel.getRenderPanel();
installingCallbacks();
renderPanel.getModel().addEvent((eventName, eventBus) -> {
if (eventName.equals("model_part_added")) {
installingCallbacks();
}
});
}
/**
* 安装参数管理回调
*/
public void installingCallbacks() {
ModelRenderPanel renderPanel = parametersPanel.getRenderPanel();
for (int i = 0; i < renderPanel.getModel().getParts().size(); i++) {
ModelPart modelPart = renderPanel.getModel().getParts().get(i);
modelPart.addEvent((eventName, eventBus) -> {
if (eventName.equals("vertex")){
if (!(eventBus instanceof Map)) {
logger.error("Error: eventBus is not a Map for vertex event.");
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> eventPayload = (Map<String, Object>) eventBus;
ModelPart caller = (ModelPart) eventPayload.get("caller");
Vertex oldVertexObj = (Vertex) eventPayload.get("oldVertex");
Vertex newVertexObj = (Vertex) eventPayload.get("newVertex");
updateVertex(caller, oldVertexObj, newVertexObj);
if (eventName.equals("vertex_position")){
//logger.info("顶点位置已更新: {}", eventBus);
updateVertex((ModelPart) eventBus);
}
});
}
@@ -50,68 +58,72 @@ public class ParametersManagement {
* - 如果没有找到,则调用 broadcast 创建一个新的关键帧记录。
*
* @param caller 触发事件的 ModelPart
* @param oldVertexObj 变化前的 Vertex 状态,用于查找要替换的目标
* @param newVertexObj 变化后的 Vertex 状态,用于提供新的数据
*/
public void updateVertex(ModelPart caller, Vertex oldVertexObj, Vertex newVertexObj) {
if (newVertexObj == null || newVertexObj.getName() == null || oldVertexObj == null || oldVertexObj.getName() == null) {
return;
}
boolean updatedExisting = false;
public void updateVertex(ModelPart caller) {
for (int i = 0; i < oldValues.size(); i++) {
Parameter existingParameter = oldValues.get(i);
if (existingParameter.modelPart().equals(caller)) {
List<Object> values = existingParameter.value();
for (int j = 0; j < values.size(); j++) {
if (!"meshVertices".equals(existingParameter.paramId().get(j))) {
continue;
}
Object value = values.get(j);
if (!(value instanceof Map)) {
continue;
}
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) value;
String storedVertexId = (String) payload.get("id");
float[] storedPosition = (float[]) payload.get("Vertex");
if (!Objects.equals(storedVertexId, oldVertexObj.getName())) {
continue;
}
if (storedPosition == null || storedPosition.length != 2) {
continue;
}
final float epsilon = 1e-5f;
boolean positionMatches = Math.abs(storedPosition[0] - oldVertexObj.position.x) < epsilon &&
Math.abs(storedPosition[1] - oldVertexObj.position.y) < epsilon;
if (positionMatches) {
logger.debug("在{}关键帧中找到原来匹配的顶点ID:{})并执行原地更新。", existingParameter.keyframe().get(j), oldVertexObj.getName());
Map<String, Object> newVertexUpdatePayload = Map.of(
"id", newVertexObj.getName(),
"Vertex", new float[]{newVertexObj.position.x, newVertexObj.position.y}
);
List<Object> newValues = new ArrayList<>(values);
newValues.set(j, newVertexUpdatePayload);
Parameter updatedParameter = new Parameter(
existingParameter.modelPart(),
new ArrayList<>(existingParameter.animationParameter()),
new ArrayList<>(existingParameter.paramId()),
newValues,
new ArrayList<>(existingParameter.keyframe()),
new ArrayList<>(existingParameter.isKeyframe())
);
oldValues.set(i, updatedParameter);
updatedExisting = true;
// break;
}
}
if (!existingParameter.modelPart().equals(caller)) {
continue;
}
if (updatedExisting) {
break;
List<Object> originalValues = existingParameter.value();
List<Object> updatedValues = new ArrayList<>(originalValues);
boolean vertexDataChanged = false;
for (int j = 0; j < updatedValues.size(); j++) {
if (!"meshVertices".equals(existingParameter.paramId().get(j))) {
continue;
}
Object value = originalValues.get(j);
if (!(value instanceof Map)) {
continue;
}
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) value;
String storedVertexId = (String) payload.get("id");
Vertex sourceVertex = (Vertex) payload.get("Vertex");
if (sourceVertex == null) continue;
//Vertex newVertex = findLiveVertex(caller,sourceVertex).copy();
Vertex newVertex = sourceVertex.copy();
Vector2f worldPoint = Matrix3fUtils.transformPoint(caller.getWorldTransform(), newVertex.originalPosition);
newVertex.position.set(worldPoint);
Map<String, Object> newVertexUpdatePayload = new HashMap<>();
newVertexUpdatePayload.put("id", storedVertexId);
newVertexUpdatePayload.put("Vertex", newVertex);
updatedValues.set(j, newVertexUpdatePayload);
vertexDataChanged = true;
//logger.info("已更新顶点: {} -> {}", storedVertexId, worldPoint);
}
if (vertexDataChanged) {
Parameter updatedParameter = new Parameter(
existingParameter.modelPart(),
new ArrayList<>(existingParameter.animationParameter()),
new ArrayList<>(existingParameter.paramId()),
updatedValues,
new ArrayList<>(existingParameter.keyframe()),
new ArrayList<>(existingParameter.isKeyframe())
);
oldValues.set(i, updatedParameter);
}
}
}
private Vertex findLiveVertex(ModelPart part, Vertex vertex) {
for (com.chuangzhou.vivid2D.render.model.Mesh2D mesh : part.getMeshes()) {
for (Vertex v : mesh.getActiveVertexList()) {
if (vertex._equals(v)) {
return v;
}
}
}
logger.warn("未找到匹配的顶点: {}", vertex);
return vertex;
}
public static ParametersManagement getInstance(ParametersPanel parametersPanel) {
String managementFilePath = parametersPanel.getRenderPanel().getGlContextManager().getModelPath() + ".data";

View File

@@ -6,14 +6,20 @@ import com.chuangzhou.vivid2D.render.model.AnimationParameter;
import com.chuangzhou.vivid2D.render.model.ModelPart;
import com.chuangzhou.vivid2D.render.model.data.ParameterData;
import com.chuangzhou.vivid2D.render.model.data.PartData;
import com.chuangzhou.vivid2D.render.model.data.VertexData;
import com.chuangzhou.vivid2D.render.model.util.Vertex; // 导入 Vertex
import org.joml.Vector2f; // 导入 Vector2f
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* ParametersManagement 的序列化数据类
* 修复了直接序列化 Vertex 运行时对象导致的问题
*/
public class ParametersManagementData implements Serializable {
@Serial
@@ -43,21 +49,9 @@ public class ParametersManagementData implements Serializable {
}
public ParametersManagement toParametersManagement(ParametersPanel parametersPanel) {
ParametersManagement management = new ParametersManagement(parametersPanel);
if (this.oldValues != null) {
for (ManagementParameterRecord paramRecord : this.oldValues) {
ParametersManagement.Parameter param = paramRecord.toParameter();
management.oldValues.add(param);
}
}
return management;
return toParametersManagement(parametersPanel, null);
}
/**
* 使用 ModelPart 列表来重新关联 ModelPart 引用
*/
public ParametersManagement toParametersManagement(ParametersPanel parametersPanel, List<ModelPart> modelParts) {
ParametersManagement management = new ParametersManagement(parametersPanel);
@@ -74,6 +68,7 @@ public class ParametersManagementData implements Serializable {
public ParametersManagementData copy() {
ParametersManagementData copy = new ParametersManagementData();
copy.oldValues = new ArrayList<>();
copy.isBreakage = this.isBreakage;
if (this.oldValues != null) {
for (ManagementParameterRecord paramRecord : this.oldValues) {
copy.oldValues.add(paramRecord.copy());
@@ -82,32 +77,17 @@ public class ParametersManagementData implements Serializable {
return copy;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("ParametersManagementData:\n");
if (oldValues == null || oldValues.isEmpty()) {
sb.append(" No parameter records\n");
} else {
for (int i = 0; i < oldValues.size(); i++) {
ManagementParameterRecord paramRecord = oldValues.get(i);
sb.append(String.format(" Record %d: %s\n", i, paramRecord.toString()));
}
}
return sb.toString();
}
// ==================== 内部类 ====================
public static class ManagementParameterRecord implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
public String modelPartName; // 通过名称引用 ModelPart
public PartData modelPartData; // 新增:完整的 ModelPart 数据
public String modelPartName;
public PartData modelPartData;
public List<ParameterData> animationParameters;
public List<String> paramIds;
public List<Object> values;
public List<Object> values; // 这里将存储 SerializableVertex 而不是 Vertex
public List<Float> keyframes;
public List<Boolean> isKeyframes;
@@ -123,25 +103,26 @@ public class ParametersManagementData implements Serializable {
this();
if (parameter.modelPart() != null) {
this.modelPartName = parameter.modelPart().getName();
// 序列化完整的 ModelPart 数据
this.modelPartData = new PartData(parameter.modelPart());
}
// 序列化 AnimationParameter 列表
if (parameter.animationParameter() != null) {
for (AnimationParameter animParam : parameter.animationParameter()) {
ParameterData animParamData = new ParameterData(animParam);
this.animationParameters.add(animParamData);
this.animationParameters.add(new ParameterData(animParam));
}
}
// 序列化其他列表
if (parameter.paramId() != null) {
this.paramIds.addAll(parameter.paramId());
}
// [核心修复]:深拷贝 values并检查是否包含 Vertex 对象
if (parameter.value() != null) {
this.values.addAll(parameter.value());
for (Object val : parameter.value()) {
this.values.add(convertValueForSerialization(val));
}
}
if (parameter.keyframe() != null) {
this.keyframes.addAll(parameter.keyframe());
}
@@ -150,33 +131,58 @@ public class ParametersManagementData implements Serializable {
}
}
public ParametersManagement.Parameter toParameter() {
// 注意ModelPart 需要通过名称在反序列化时重新关联
List<AnimationParameter> animParams = new ArrayList<>();
if (this.animationParameters != null) {
for (ParameterData animParamData : this.animationParameters) {
AnimationParameter animParam = animParamData.toAnimationParameter();
animParams.add(animParam);
}
}
/**
* 将运行时对象转换为可序列化对象
*/
private Object convertValueForSerialization(Object val) {
// 检查是否是包含 Vertex 的 Map (对应 "meshVertices" 参数)
if (val instanceof Map) {
Map<?, ?> originalMap = (Map<?, ?>) val;
// 浅拷贝 Map 结构
Map<String, Object> newMap = new HashMap<>();
for (Map.Entry<?, ?> entry : originalMap.entrySet()) {
String key = String.valueOf(entry.getKey());
Object value = entry.getValue();
return new ParametersManagement.Parameter(
null, // ModelPart 需要在外部重新设置
animParams,
this.paramIds != null ? new ArrayList<>(this.paramIds) : new ArrayList<>(),
this.values != null ? new ArrayList<>(this.values) : new ArrayList<>(),
this.keyframes != null ? new ArrayList<>(this.keyframes) : new ArrayList<>(),
this.isKeyframes != null ? new ArrayList<>(this.isKeyframes) : new ArrayList<>()
);
if ("Vertex".equals(key) && value instanceof Vertex) {
// 将 Vertex 转换为 DTO
newMap.put(key, new VertexData((Vertex) value));
} else {
// 递归处理还是直接存放? 目前直接存放假设其他都是基本类型或String
newMap.put(key, value);
}
}
return newMap;
}
return val;
}
/**
* 使用 ModelPart 列表重新关联 ModelPart 引用
* 将序列化对象还原为运行时对象
*/
private Object convertValueFromSerialization(Object val) {
if (val instanceof Map) {
Map<?, ?> storedMap = (Map<?, ?>) val;
Map<String, Object> runtimeMap = new HashMap<>();
for (Map.Entry<?, ?> entry : storedMap.entrySet()) {
String key = String.valueOf(entry.getKey());
Object value = entry.getValue();
if ("Vertex".equals(key) && value instanceof VertexData) {
// 将 DTO 还原为 Vertex
runtimeMap.put(key, ((VertexData) value).toVertex());
} else {
runtimeMap.put(key, value);
}
}
return runtimeMap;
}
return val;
}
public ParametersManagement.Parameter toParameter(List<ModelPart> modelParts) {
ModelPart modelPart = null;
// 通过名称查找对应的 ModelPart
if (this.modelPartName != null && modelParts != null) {
for (ModelPart part : modelParts) {
if (this.modelPartName.equals(part.getName())) {
@@ -185,22 +191,24 @@ public class ParametersManagementData implements Serializable {
}
}
}
// 如果没找到,尝试使用 modelPartData 重建
if (modelPart == null && this.modelPartData != null) {
try {
// 创建一个空的 meshMap因为这里可能没有完整的网格上下文
modelPart = this.modelPartData.toModelPart(new java.util.HashMap<>());
} catch (Exception e) {
System.err.println("重建 ModelPart 失败: " + e.getMessage());
}
modelPart = this.modelPartData.toModelPart(new HashMap<>());
} catch (Exception ignored) {}
}
List<AnimationParameter> animParams = new ArrayList<>();
if (this.animationParameters != null) {
for (ParameterData animParamData : this.animationParameters) {
AnimationParameter animParam = animParamData.toAnimationParameter();
animParams.add(animParam);
for (ParameterData p : this.animationParameters) {
animParams.add(p.toAnimationParameter());
}
}
// [核心修复]:还原 values 中的 Vertex 对象
List<Object> runtimeValues = new ArrayList<>();
if (this.values != null) {
for (Object val : this.values) {
runtimeValues.add(convertValueFromSerialization(val));
}
}
@@ -208,7 +216,7 @@ public class ParametersManagementData implements Serializable {
modelPart,
animParams,
this.paramIds != null ? new ArrayList<>(this.paramIds) : new ArrayList<>(),
this.values != null ? new ArrayList<>(this.values) : new ArrayList<>(),
runtimeValues,
this.keyframes != null ? new ArrayList<>(this.keyframes) : new ArrayList<>(),
this.isKeyframes != null ? new ArrayList<>(this.isKeyframes) : new ArrayList<>()
);
@@ -219,29 +227,20 @@ public class ParametersManagementData implements Serializable {
copy.modelPartName = this.modelPartName;
copy.modelPartData = this.modelPartData != null ? this.modelPartData.copy() : null;
// 深拷贝 animationParameters
copy.animationParameters = new ArrayList<>();
if (this.animationParameters != null) {
for (ParameterData animParam : this.animationParameters) {
copy.animationParameters.add(animParam.copy());
}
for (ParameterData p : this.animationParameters) copy.animationParameters.add(p.copy());
}
// 深拷贝其他列表
copy.paramIds = this.paramIds != null ? new ArrayList<>(this.paramIds) : new ArrayList<>();
// 注意values 里的 SerializableVertex 也应该 copy或者依赖其不可变性/序列化特性
// 这里做简单的列表拷贝,因为 SerializableVertex 通常是纯数据
copy.values = this.values != null ? new ArrayList<>(this.values) : new ArrayList<>();
copy.keyframes = this.keyframes != null ? new ArrayList<>(this.keyframes) : new ArrayList<>();
copy.isKeyframes = this.isKeyframes != null ? new ArrayList<>(this.isKeyframes) : new ArrayList<>();
return copy;
}
@Override
public String toString() {
return String.format(
"ManagementParameterRecord[Part=%s, Params=%s, Values=%s, Keyframes=%s]",
modelPartName, paramIds, values, keyframes
);
}
}
}

View File

@@ -1247,16 +1247,7 @@ public class SelectionTool extends Tool {
logger.info("选择工具请求进入液化模式: {}", targetMesh.getName());
}
public void updateMeshVertices() {
for (Mesh2D mesh : selectedMeshes) {
updateMeshVertices(mesh);
}
updateMeshVertices(hoveredMesh);
}
public void updateMeshVertices(Mesh2D mesh) {
//
}
public void updateMeshVertices() {}
/**
* 获取鼠标悬停的网格

View File

@@ -97,17 +97,13 @@ public class VertexDeformationTool extends Tool {
// [核心修正] 检查 Alt 键是否被按下
if (e.isAltDown()) {
// --- 进入“推/拉”模式 ---
isPushPullMode = true;
dragStartPoint = new Vector2f(modelX, modelY);
// 创建当前网格顶点状态的快照,这是计算位移的基准
dragBaseState = new ArrayList<>(targetMesh.getActiveVertexList().size());
for(Vertex v : targetMesh.getActiveVertexList()){
dragBaseState.add(v.copy()); // 必须是深拷贝
dragBaseState.add(v.copy());
}
currentDragMode = ModelRenderPanel.DragMode.NONE; // 确保不触发控制点拖动
currentDragMode = ModelRenderPanel.DragMode.NONE;
logger.debug("进入推/拉模式,起点: ({}, {})", modelX, modelY);
} else {
@@ -182,7 +178,7 @@ public class VertexDeformationTool extends Tool {
renderPanel.getGlContextManager().executeInGLContext(() -> {
try {
Map<String, Object> parameters = Map.of("id", primaryVertex.getName(),
"Vertex", new float[]{modelX, modelY});
"Vertex", primaryVertex);
renderPanel.getParametersManagement().broadcast(
targetMesh.getModelPart(),
"meshVertices",

View File

@@ -44,16 +44,21 @@ public class FrameInterpolator {
} else if (o != null && o.getClass().isArray()) {
try {
// 处理 float[] 的情况
if (o instanceof float[]) {
float[] arr = (float[]) o;
if (o instanceof float[] arr) {
if (arr.length > 0) out[0] = arr[0];
if (arr.length > 1) out[1] = arr[1];
} else {
Object[] arr = (Object[]) o;
if (arr.length > 0) out[0] = toFloat(arr[0]);
if (arr.length > 1) out[1] = toFloat(arr[1]);
Object[] arr = null;
if (o instanceof Object[]) {
arr = (Object[]) o;
}
if (arr != null && arr.length > 0) out[0] = toFloat(arr[0]);
if (arr != null && arr.length > 1) out[1] = toFloat(arr[1]);
}
} catch (Exception ignored) {}
} if (o instanceof Vertex vertex) {
out[0] = vertex.position.x;
out[1] = vertex.position.y;
} else if (o instanceof Number || o instanceof String) {
float v = toFloat(o);
out[0] = out[1] = v;

View File

@@ -434,6 +434,19 @@ public class Mesh2D {
markDirty();
}
/**
* 直接设置此网格的活动顶点列表。
* 这是从序列化数据恢复网格状态的首选方法,因为它可以保留每个顶点的完整信息。
*
* @param vertexList 包含完整顶点信息的新顶点列表。
*/
public void setActiveVertexList(VertexList vertexList) {
if (vertexList != null) {
this.activeVertexList = vertexList;
markDirty(); // 标记网格需要更新
}
}
/**
* 设置是否为渲染顶点
*/

View File

@@ -70,10 +70,20 @@ public class Model2D {
// ==================== 光源系统 ====================
private final List<LightSource> lights;
private final List<ModelEvent> events = new java.util.concurrent.CopyOnWriteArrayList<>();
// ==================== 构造器 ====================
public Model2D() {
this.uuid = UUID.randomUUID();
this.parts = new ArrayList<>();
this.parts = new ArrayList<>() {
@Override
public boolean add(ModelPart modelPart) {triggerEvent("model_part_added");return super.add(modelPart);}
@Override
public void add(int index, ModelPart element) {triggerEvent("model_part_added");super.add(index, element);}
@Override
public ModelPart set(int index, ModelPart element) {triggerEvent("model_part_added");return super.set(index, element);}
};
this.partMap = new HashMap<>();
this.meshes = new ArrayList<>();
this.textures = new HashMap<>();
@@ -193,6 +203,20 @@ public class Model2D {
return part;
}
private void triggerEvent(String eventName) {
for (ModelEvent event : events) {
if (event != null)
event.trigger(eventName,this);
}
}
public void addEvent(ModelEvent event) {
events.add(event);
}
public void removeEvent(ModelEvent event) {
events.remove(event);
}
public void addPart(ModelPart part) {
if (partMap.containsKey(part.getName())) {
throw new IllegalArgumentException("Part already exists: " + part.getName());
@@ -204,6 +228,8 @@ public class Model2D {
if (rootPart == null) {
rootPart = part;
}
triggerEvent("model_part_added");
}
public ModelPart getPart(String name) {

View File

@@ -1608,18 +1608,10 @@ public class ModelPart {
for (Mesh2D mesh : meshes) {
if (mesh == null) continue;
for (Vertex vertex : mesh.getActiveVertexList()) {
Vertex oldVertexCopy = vertex.copy();
Vector2f localPoint = vertex.originalPosition;
Vector2f worldPoint = Matrix3fUtils.transformPoint(this.worldTransform, localPoint);
vertex.position.set(worldPoint);
Map<String, Object> eventPayload = new HashMap<>();
eventPayload.put("caller", this);
eventPayload.put("oldVertex", oldVertexCopy);
eventPayload.put("newVertex", vertex);
for (ModelEvent event : events) {
if (event != null)
event.trigger("vertex", eventPayload);
}
triggerEvent("vertex_position");
}
mesh.markDirty();
}

View File

@@ -1,21 +1,34 @@
package com.chuangzhou.vivid2D.render.model.data;
import com.chuangzhou.vivid2D.render.model.Mesh2D;
import com.chuangzhou.vivid2D.render.model.ModelPart;
import com.chuangzhou.vivid2D.render.model.util.Vertex;
import com.chuangzhou.vivid2D.render.model.util.VertexList;
import com.chuangzhou.vivid2D.render.systems.Matrix3fUtils;
import org.joml.Matrix3f;
import org.joml.Vector2f;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* Mesh2D的数据传输对象DTO用于序列化和反序列化网格数据。
* 负责处理从世界坐标到局部坐标的转换,以便正确保存模型数据。
*/
public class MeshData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private static final long serialVersionUID = 2L;
public VertexListData vertexListData;
public VertexListData deformationControlVerticesData;
public String name;
public float[] vertices;
public float[] uvs;
public int[] indices;
public String textureName;
public boolean visible;
public int drawMode;
public float pivotX, pivotY;
public float originalPivotX, originalPivotY;
public MeshData() {
this.visible = true;
@@ -23,35 +36,141 @@ public class MeshData implements Serializable {
}
public MeshData(Mesh2D mesh) {
this();
this.name = mesh.getName();
this.vertices = mesh.getVertices();
this.uvs = mesh.getUVs();
this.indices = mesh.getIndices();
this.visible = mesh.isVisible();
this.drawMode = mesh.getDrawMode();
if (mesh.getTexture() != null) {
this.textureName = mesh.getTexture().getName();
}
// ==================== 核心修复开始 ====================
// 1. 获取逆变换矩阵:用于将 世界坐标 -> 局部坐标
// 这是为了确保保存的是模型相对于父级的原始形状,而不是经过变换后的世界状态
Matrix3f inverseTransform = new Matrix3f();
ModelPart parentPart = mesh.getModelPart();
if (parentPart != null) {
parentPart.getWorldTransform().invert(inverseTransform);
}
// 2. 处理顶点数据 (Active Vertex List)
// 我们必须创建一个顶点的深拷贝,以免修改运行时正在渲染的 Mesh2D 对象
VertexList originalVertexList = mesh.getActiveVertexList();
List<Vertex> localSpaceVertices = new ArrayList<>();
if (originalVertexList != null) {
for (int i = 0; i < originalVertexList.size(); i++) {
// 深拷贝顶点
Vertex vCopy = originalVertexList.get(i).copy();
// 变换位置pos = inverse * pos (将世界坐标还原为局部坐标)
Matrix3fUtils.transformPoint(inverseTransform, vCopy.position);
// 重要:同时重置 originalPosition 为局部坐标
// 这样加载后deformation 算法才能基于正确的局部 bind-pose 进行计算
vCopy.originalPosition.set(vCopy.position);
localSpaceVertices.add(vCopy);
}
}
// 重建一个临时的 VertexList 用于封装数据(保留原有的索引)
VertexList tempLocalVertexList = new VertexList(
originalVertexList != null ? originalVertexList.getName() : "temp",
localSpaceVertices,
originalVertexList != null ? originalVertexList.getIndices() : new int[0]
);
this.vertexListData = new VertexListData(tempLocalVertexList);
// 3. 处理变形控制点 (Deformation Control Vertices)
// 控制点同样存在于世界空间,必须还原回局部
if (mesh.getDeformationControlVertices() != null) {
List<Vertex> tempControlVertices = new ArrayList<>();
for (Vertex v : mesh.getDeformationControlVertices()) {
// 深拷贝
Vertex vCopy = v.copy();
// 还原坐标
Matrix3fUtils.transformPoint(inverseTransform, vCopy.position);
vCopy.originalPosition.set(vCopy.position); // 同步 original
tempControlVertices.add(vCopy);
}
// 重建 VertexList 结构用于存储
VertexList controlListWrapper = new VertexList("control_points", tempControlVertices, new int[0]);
this.deformationControlVerticesData = new VertexListData(controlListWrapper);
}
// 4. 处理 Pivot (中心点)
// Pivot 在 Mesh2D 中通常随着 ModelPart 变换被推到了世界坐标
Vector2f currentPivot = new Vector2f(mesh.getPivot());
Matrix3fUtils.transformPoint(inverseTransform, currentPivot); // 还原回局部
this.pivotX = currentPivot.x;
this.pivotY = currentPivot.y;
// OriginalPivot 通常本身就是局部的,但为了保险,我们使用刚刚逆变换计算出来的 currentPivot
// 因为在未变形状态下OriginalPivot 应该等于 Pivot
this.originalPivotX = currentPivot.x;
this.originalPivotY = currentPivot.y;
// ==================== 核心修复结束 ====================
}
public Mesh2D toMesh2D() {
Mesh2D mesh = new Mesh2D(name, vertices, uvs, indices);
mesh.setVisible(visible);
mesh.setDrawMode(drawMode);
Mesh2D mesh = new Mesh2D(this.name);
// 恢复顶点 (此时它们是局部坐标)
if (this.vertexListData != null) {
VertexList restoredVertexList = this.vertexListData.toVertexList();
mesh.setActiveVertexList(restoredVertexList);
// 恢复控制点 (此时它们是局部坐标)
if (this.deformationControlVerticesData != null) {
VertexList restoredControlListData = this.deformationControlVerticesData.toVertexList();
List<Vertex> controlVertices = new ArrayList<>();
// 尝试将控制点链接回主网格的顶点引用(如果索引匹配)
// 这样拖动控制点时,主网格的顶点也会被标记为被控制
for (Vertex restoredControlInfoVertex : restoredControlListData) {
int originalIndex = restoredControlInfoVertex.getIndex();
if (originalIndex >= 0 && originalIndex < restoredVertexList.size()) {
Vertex actualVertexInMainList = restoredVertexList.get(originalIndex);
// 确保位置一致(理论上现在都是局部坐标,应该是一致的)
controlVertices.add(actualVertexInMainList);
} else {
// 如果是独立的控制点(不在网格上,或者 cage 顶点),则直接添加
controlVertices.add(restoredControlInfoVertex);
}
}
mesh.setDeformationControlVertices(controlVertices);
}
}
mesh.setVisible(this.visible);
mesh.setDrawMode(this.drawMode);
// 恢复 Pivot (局部坐标)
// 注意:这里设置的是局部 Pivot。
// 当 mesh 被 addMesh 加入 ModelPart 时ModelPart 会根据自身的变换
// 将这个局部 Pivot 再次推算到新的世界 Pivot 位置。
mesh.setPivot(this.pivotX, this.pivotY);
mesh.setOriginalPivot(new Vector2f(this.originalPivotX, this.originalPivotY));
mesh.markDirty();
return mesh;
}
public MeshData copy() {
MeshData copy = new MeshData();
copy.name = this.name;
copy.vertices = this.vertices != null ? this.vertices.clone() : null;
copy.uvs = this.uvs != null ? this.uvs.clone() : null;
copy.indices = this.indices != null ? this.indices.clone() : null;
copy.textureName = this.textureName;
copy.visible = this.visible;
copy.drawMode = this.drawMode;
return copy;
MeshData c = new MeshData();
c.vertexListData = this.vertexListData != null ? this.vertexListData.copy() : null;
c.deformationControlVerticesData = this.deformationControlVerticesData != null ? this.deformationControlVerticesData.copy() : null;
c.name = this.name;
c.textureName = this.textureName;
c.visible = this.visible;
c.drawMode = this.drawMode;
c.pivotX = this.pivotX;
c.pivotY = this.pivotY;
c.originalPivotX = this.originalPivotX;
c.originalPivotY = this.originalPivotY;
return c;
}
}

View File

@@ -0,0 +1,84 @@
package com.chuangzhou.vivid2D.render.model.data;
import com.chuangzhou.vivid2D.render.model.util.Vertex;
import com.chuangzhou.vivid2D.render.model.util.VertexTag;
import org.joml.Vector2f;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
public class VertexData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
public float posX, posY;
public float uvX, uvY;
public float originalPosX, originalPosY;
public VertexTag tag;
public boolean selected;
public String name;
public boolean isDelete;
public int index;
public List<Integer> controlledTriangles;
public VertexData() {
this.controlledTriangles = new ArrayList<>();
}
public VertexData(Vertex vertex) {
this.posX = vertex.position.x;
this.posY = vertex.position.y;
this.uvX = vertex.uv.x;
this.uvY = vertex.uv.y;
this.originalPosX = vertex.originalPosition.x;
this.originalPosY = vertex.originalPosition.y;
this.tag = vertex.getTag();
this.selected = vertex.isSelected();
this.name = vertex.getName();
this.isDelete = vertex.isDelete();
this.index = vertex.getIndex();
this.controlledTriangles = new ArrayList<>(vertex.getControlledTriangles());
}
public Vertex toVertex() {
Vertex vertex = new Vertex(
new Vector2f(posX, posY),
new Vector2f(uvX, uvY),
new Vector2f(originalPosX, originalPosY)
);
vertex.setTag(this.tag);
vertex.setSelected(this.selected);
vertex.setName(this.name);
vertex.setIndex(this.index);
vertex.setControlledTriangles(this.controlledTriangles);
if (this.isDelete) {
vertex.delete();
}
return vertex;
}
/**
* 创建此对象的深拷贝。
* Creates a deep copy of this object.
* @return a new, independent VertexData instance.
*/
public VertexData copy() {
VertexData c = new VertexData();
c.posX = this.posX;
c.posY = this.posY;
c.uvX = this.uvX;
c.uvY = this.uvY;
c.originalPosX = this.originalPosX;
c.originalPosY = this.originalPosY;
c.tag = this.tag; // Enums are immutable, direct copy is fine
c.selected = this.selected;
c.name = this.name; // Strings are immutable, direct copy is fine
c.isDelete = this.isDelete;
c.index = this.index;
// Create a new list instance to ensure the copy is independent
c.controlledTriangles = new ArrayList<>(this.controlledTriangles);
return c;
}
}

View File

@@ -0,0 +1,57 @@
package com.chuangzhou.vivid2D.render.model.data;
import com.chuangzhou.vivid2D.render.model.util.Vertex;
import com.chuangzhou.vivid2D.render.model.util.VertexList;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class VertexListData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
public String name;
public List<VertexData> vertices;
public int[] indices;
public VertexListData() {
this.vertices = new ArrayList<>();
}
public VertexListData(VertexList vertexList) {
this.name = vertexList.getName();
this.indices = vertexList.getIndices();
this.vertices = vertexList.vertices.stream()
.map(VertexData::new)
.collect(Collectors.toList());
}
public VertexList toVertexList() {
List<Vertex> restoredVertices = this.vertices.stream()
.map(VertexData::toVertex)
.collect(Collectors.toList());
return new VertexList(this.name, restoredVertices, this.indices);
}
/**
* 创建此对象的深拷贝。
* Creates a deep copy of this object.
* @return a new, independent VertexListData instance.
*/
public VertexListData copy() {
VertexListData c = new VertexListData();
c.name = this.name;
// The indices array must be cloned to be independent
c.indices = this.indices != null ? this.indices.clone() : null;
// Create a new list and fill it with copies of each VertexData
if (this.vertices != null) {
c.vertices = this.vertices.stream()
.map(VertexData::copy) // Call copy on each element
.collect(Collectors.toList());
}
return c;
}
}

View File

@@ -2,6 +2,8 @@ package com.chuangzhou.vivid2D.render.model.util;
import org.joml.Vector2f;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -9,9 +11,12 @@ import java.util.Objects;
/**
* 封装一个2D顶点包含位置、UV坐标和原始位置。
*
* @author Gemini
* @author tzdwindows 7
*/
public class Vertex {
public class Vertex implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private List<Integer> controlledTriangles = new ArrayList<>();
public Vector2f position; // 当前顶点位置 (x, y)
public Vector2f uv; // UV坐标 (u, v)
@@ -147,8 +152,15 @@ public class Vertex {
copy.setTag(this.tag);
copy.setSelected(this.selected);
copy.setName(this.name);
copy.setControlledTriangles(this.controlledTriangles);
copy.setIndex(this.index);
if (this.controlledTriangles != null) {
copy.setControlledTriangles(new ArrayList<>(this.controlledTriangles));
} else {
copy.setControlledTriangles(new ArrayList<>());
}
if (this.isDelete) {
copy.delete();
}
return copy;
}
@@ -166,6 +178,16 @@ public class Vertex {
Objects.equals(name, vertex.name);
}
public boolean _equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Vertex vertex = (Vertex) o;
return isDelete == vertex.isDelete &&
Objects.equals(position, vertex.position) &&
tag == vertex.tag &&
Objects.equals(name, vertex.name);
}
@Override
public int hashCode() {
return Objects.hash(position, uv, originalPosition, tag, selected, name, isDelete);

View File

@@ -497,6 +497,7 @@ public class MainWindow extends JFrame {
} catch (ExecutionException e) {
// 实际异常被包装在 ExecutionException 中
Throwable cause = e.getCause();
e.printStackTrace();
System.err.println("保存失败: " + cause.getMessage());
statusBarLabel.setText("保存失败!错误: " + cause.getMessage());
JOptionPane.showMessageDialog(MainWindow.this,