refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)
* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 * feat(editor): 添加插件市场功能 * feat(editor): 重构插件市场以支持版本管理和ZIP打包 * feat(editor): 重构插件发布流程并修复React渲染警告 * fix(plugin): 修复插件发布和市场的路径不一致问题 * feat: 重构插件发布流程并添加插件删除功能 * fix(editor): 完善插件删除功能并修复多个关键问题 * fix(auth): 修复自动登录与手动登录的竞态条件问题 * feat(editor): 重构插件管理流程 * feat(editor): 支持 ZIP 文件直接发布插件 - 新增 PluginSourceParser 解析插件源 - 重构发布流程支持文件夹和 ZIP 两种方式 - 优化发布向导 UI * feat(editor): 插件市场支持多版本安装 - 插件解压到项目 plugins 目录 - 新增 Tauri 后端安装/卸载命令 - 支持选择任意版本安装 - 修复打包逻辑,保留完整 dist 目录结构 * feat(editor): 个人中心支持多版本管理 - 合并同一插件的不同版本 - 添加版本历史展开/折叠功能 - 禁止有待审核 PR 时更新插件 * fix(editor): 修复 InspectorRegistry 服务注册 - InspectorRegistry 实现 IService 接口 - 注册到 Core.services 供插件使用 * feat(behavior-tree-editor): 完善插件注册和文件操作 - 添加文件创建模板和操作处理器 - 实现右键菜单创建行为树功能 - 修复文件读取权限问题(使用 Tauri 命令) - 添加 BehaviorTreeEditorPanel 组件 - 修复 rollup 配置支持动态导入 * feat(plugin): 完善插件构建和发布流程 * fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成 * fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能 * fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式 * refactor(behavior-tree-editor): 移除调试面板功能简化代码结构 * refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑 * feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性 * fix(lint): 修复ESLint错误确保CI通过 * refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能 * refactor(behavior-tree-editor): 清理技术债务,优化代码质量 * fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
@@ -16,19 +16,28 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/behavior-tree": "file:../behavior-tree",
|
||||
"@esengine/behavior-tree-editor": "file:../behavior-tree-editor",
|
||||
"@esengine/ecs-framework": "file:../core",
|
||||
"@esengine/editor-core": "file:../editor-core",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"flexlayout-react": "^0.8.17",
|
||||
"i18next": "^25.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"tsyringe": "^4.10.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+312
-4
@@ -8,6 +8,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
@@ -158,6 +169,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -248,6 +265,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
@@ -322,6 +359,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -378,6 +417,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -397,6 +446,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -409,10 +464,39 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -436,7 +520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -449,7 +533,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -574,6 +658,12 @@ version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.4"
|
||||
@@ -616,6 +706,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -700,6 +791,15 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
@@ -746,6 +846,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
name = "ecs-editor"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"glob",
|
||||
@@ -755,10 +856,12 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1359,6 +1462,25 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.11.4",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1389,6 +1511,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -1457,6 +1588,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1504,9 +1636,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1688,6 +1822,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -1774,6 +1917,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
@@ -1898,6 +2051,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2505,12 +2664,35 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
"password-hash",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2793,6 +2975,22 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -3074,8 +3272,12 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -3084,6 +3286,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3750,6 +3953,27 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3771,7 +3995,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"block2 0.6.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -4004,6 +4228,30 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "938a3d7051c9a82b431e3a0f3468f85715b3442b3c3a3913095e9fa509e2652c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.1"
|
||||
@@ -4054,7 +4302,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5174,6 +5422,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -5734,6 +5993,26 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
@@ -5746,6 +6025,35 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.11.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "5.0.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.7.0"
|
||||
|
||||
@@ -18,6 +18,7 @@ tauri-plugin-shell = "2.0"
|
||||
tauri-plugin-dialog = "2.0"
|
||||
tauri-plugin-fs = "2.0"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-http = "2.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
glob = "0.3"
|
||||
@@ -25,6 +26,8 @@ tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
futures-util = "0.3"
|
||||
chrono = "0.4"
|
||||
zip = "0.6"
|
||||
base64 = "0.22"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"identifier": "http-capability",
|
||||
"description": "HTTP permissions for GitHub API access and plugin marketplace",
|
||||
"local": true,
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://github.com/**" },
|
||||
{ "url": "https://api.github.com/**" },
|
||||
{ "url": "https://raw.githubusercontent.com/**" },
|
||||
{ "url": "https://cdn.jsdelivr.net/**" },
|
||||
{ "url": "https://fastly.jsdelivr.net/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EditorConfig {
|
||||
pub theme: String,
|
||||
pub auto_save: bool,
|
||||
pub recent_projects: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for EditorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "dark".to_string(),
|
||||
auto_save: true,
|
||||
recent_projects: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<Mutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_behavior_tree_file(file_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接读取文件,绕过 Tauri 的 scope 限制
|
||||
fs::read_to_string(&file_path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_behavior_tree_file(file_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接写入文件
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_global_blackboard(project_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let config_path = Path::new(&project_path).join(".ecs").join("global-blackboard.json");
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(String::from(r#"{"version":"1.0","variables":[]}"#));
|
||||
}
|
||||
|
||||
fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read global blackboard: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_global_blackboard(project_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let ecs_dir = Path::new(&project_path).join(".ecs");
|
||||
let config_path = ecs_dir.join("global-blackboard.json");
|
||||
|
||||
// 创建 .ecs 目录(如果不存在)
|
||||
if !ecs_dir.exists() {
|
||||
fs::create_dir_all(&ecs_dir)
|
||||
.map_err(|e| format!("Failed to create .ecs directory: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write global blackboard: {}", e))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Dialog operations
|
||||
//!
|
||||
//! Generic system dialog commands for file/folder selection.
|
||||
//! No business-specific logic - all filtering is done via parameters.
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
/// File filter definition
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FileFilter {
|
||||
pub name: String,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Open folder selection dialog
|
||||
#[tauri::command]
|
||||
pub async fn open_folder_dialog(
|
||||
app: AppHandle,
|
||||
title: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
if let Some(t) = title {
|
||||
dialog = dialog.set_title(&t);
|
||||
} else {
|
||||
dialog = dialog.set_title("Select Folder");
|
||||
}
|
||||
|
||||
let folder = dialog.blocking_pick_folder();
|
||||
|
||||
Ok(folder.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
/// Open file selection dialog (generic)
|
||||
#[tauri::command]
|
||||
pub async fn open_file_dialog(
|
||||
app: AppHandle,
|
||||
title: Option<String>,
|
||||
filters: Option<Vec<FileFilter>>,
|
||||
multiple: Option<bool>,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
if let Some(t) = title {
|
||||
dialog = dialog.set_title(&t);
|
||||
} else {
|
||||
dialog = dialog.set_title("Select File");
|
||||
}
|
||||
|
||||
if let Some(filter_list) = filters {
|
||||
for filter in filter_list {
|
||||
let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter(&filter.name, &extensions);
|
||||
}
|
||||
}
|
||||
|
||||
if multiple.unwrap_or(false) {
|
||||
let files = dialog.blocking_pick_files();
|
||||
Ok(files.map(|paths| paths.iter().map(|p| p.to_string()).collect()))
|
||||
} else {
|
||||
let file = dialog.blocking_pick_file();
|
||||
Ok(file.map(|path| vec![path.to_string()]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Save file dialog (generic)
|
||||
#[tauri::command]
|
||||
pub async fn save_file_dialog(
|
||||
app: AppHandle,
|
||||
title: Option<String>,
|
||||
default_name: Option<String>,
|
||||
filters: Option<Vec<FileFilter>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
if let Some(t) = title {
|
||||
dialog = dialog.set_title(&t);
|
||||
} else {
|
||||
dialog = dialog.set_title("Save File");
|
||||
}
|
||||
|
||||
if let Some(name) = default_name {
|
||||
dialog = dialog.set_file_name(&name);
|
||||
}
|
||||
|
||||
if let Some(filter_list) = filters {
|
||||
for filter in filter_list {
|
||||
let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter(&filter.name, &extensions);
|
||||
}
|
||||
}
|
||||
|
||||
let file = dialog.blocking_save_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Generic file system operations
|
||||
//!
|
||||
//! Provides low-level file system commands that can be composed by the frontend
|
||||
//! for business logic. No business-specific logic should be in this module.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Directory entry information
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DirectoryEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: Option<u64>,
|
||||
pub modified: Option<u64>,
|
||||
}
|
||||
|
||||
/// Read text file content
|
||||
#[tauri::command]
|
||||
pub fn read_file_content(path: String) -> Result<String, String> {
|
||||
fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Write text content to file (auto-creates parent directories)
|
||||
#[tauri::command]
|
||||
pub fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&path).parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Write binary content to file (auto-creates parent directories)
|
||||
#[tauri::command]
|
||||
pub async fn write_binary_file(file_path: String, content: Vec<u8>) -> Result<(), String> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&file_path).parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write binary file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
/// Check if path exists
|
||||
#[tauri::command]
|
||||
pub fn path_exists(path: String) -> Result<bool, String> {
|
||||
Ok(Path::new(&path).exists())
|
||||
}
|
||||
|
||||
/// Create directory (recursive)
|
||||
#[tauri::command]
|
||||
pub fn create_directory(path: String) -> Result<(), String> {
|
||||
fs::create_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Create empty file
|
||||
#[tauri::command]
|
||||
pub fn create_file(path: String) -> Result<(), String> {
|
||||
fs::File::create(&path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete file
|
||||
#[tauri::command]
|
||||
pub fn delete_file(path: String) -> Result<(), String> {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to delete file {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Delete directory (recursive)
|
||||
#[tauri::command]
|
||||
pub fn delete_folder(path: String) -> Result<(), String> {
|
||||
fs::remove_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to delete folder {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Rename or move file/folder
|
||||
#[tauri::command]
|
||||
pub fn rename_file_or_folder(old_path: String, new_path: String) -> Result<(), String> {
|
||||
fs::rename(&old_path, &new_path)
|
||||
.map_err(|e| format!("Failed to rename {} to {}: {}", old_path, new_path, e))
|
||||
}
|
||||
|
||||
/// List directory contents with metadata
|
||||
#[tauri::command]
|
||||
pub fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
|
||||
let dir_path = Path::new(&path);
|
||||
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("Path is not a directory: {}", path));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
let read_dir = fs::read_dir(dir_path)
|
||||
.map_err(|e| format!("Failed to read directory {}: {}", path, e))?;
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if let Some(name) = entry_path.file_name() {
|
||||
let is_dir = entry_path.is_dir();
|
||||
|
||||
let (size, modified) = fs::metadata(&entry_path)
|
||||
.map(|metadata| {
|
||||
let size = if is_dir { None } else { Some(metadata.len()) };
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| {
|
||||
time.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| d.as_secs())
|
||||
});
|
||||
(size, modified)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
entries.push(DirectoryEntry {
|
||||
name: name.to_string_lossy().to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Scan directory for files matching a glob pattern
|
||||
#[tauri::command]
|
||||
pub fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
|
||||
use glob::glob;
|
||||
|
||||
let base_path = Path::new(&path);
|
||||
if !base_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
let separator = if path.contains('\\') { '\\' } else { '/' };
|
||||
let glob_pattern = format!(
|
||||
"{}{}{}",
|
||||
path.trim_end_matches(&['/', '\\'][..]),
|
||||
separator,
|
||||
pattern
|
||||
);
|
||||
|
||||
let normalized_pattern = if cfg!(windows) {
|
||||
glob_pattern.replace('/', "\\")
|
||||
} else {
|
||||
glob_pattern.replace('\\', "/")
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
match glob(&normalized_pattern) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
if entry.is_file() {
|
||||
files.push(entry.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to scan directory: {}", e)),
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Read file as base64 encoded string
|
||||
#[tauri::command]
|
||||
pub fn read_file_as_base64(file_path: String) -> Result<String, String> {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
||||
let file_content = fs::read(&file_path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))?;
|
||||
|
||||
Ok(general_purpose::STANDARD.encode(&file_content))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Command modules
|
||||
//!
|
||||
//! All Tauri commands organized by domain.
|
||||
|
||||
pub mod dialog;
|
||||
pub mod file_system;
|
||||
pub mod plugin;
|
||||
pub mod profiler;
|
||||
pub mod project;
|
||||
pub mod system;
|
||||
|
||||
// Re-export all commands for convenience
|
||||
pub use dialog::*;
|
||||
pub use file_system::*;
|
||||
pub use plugin::*;
|
||||
pub use profiler::*;
|
||||
pub use project::*;
|
||||
pub use system::*;
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Plugin management commands
|
||||
//!
|
||||
//! Building, installing, and uninstalling editor plugins.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use zip::write::FileOptions;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Build progress event payload
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct BuildProgress {
|
||||
pub step: String,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a plugin from source
|
||||
#[tauri::command]
|
||||
pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<String, String> {
|
||||
let plugin_path = Path::new(&plugin_folder);
|
||||
if !plugin_path.exists() {
|
||||
return Err(format!("Plugin folder does not exist: {}", plugin_folder));
|
||||
}
|
||||
|
||||
let package_json_path = plugin_path.join("package.json");
|
||||
if !package_json_path.exists() {
|
||||
return Err("package.json not found in plugin folder".to_string());
|
||||
}
|
||||
|
||||
let build_cache_dir = plugin_path.join(".build-cache");
|
||||
if !build_cache_dir.exists() {
|
||||
fs::create_dir_all(&build_cache_dir)
|
||||
.map_err(|e| format!("Failed to create .build-cache directory: {}", e))?;
|
||||
}
|
||||
|
||||
let npm_command = if cfg!(target_os = "windows") {
|
||||
"npm.cmd"
|
||||
} else {
|
||||
"npm"
|
||||
};
|
||||
|
||||
// Step 1: Install dependencies
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "install".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let install_output = Command::new(npm_command)
|
||||
.args(["install"])
|
||||
.current_dir(&plugin_folder)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run npm install: {}", e))?;
|
||||
|
||||
if !install_output.status.success() {
|
||||
return Err(format!(
|
||||
"npm install failed: {}",
|
||||
String::from_utf8_lossy(&install_output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
// Step 2: Build
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "build".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let build_output = Command::new(npm_command)
|
||||
.args(["run", "build"])
|
||||
.current_dir(&plugin_folder)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run npm run build: {}", e))?;
|
||||
|
||||
if !build_output.status.success() {
|
||||
return Err(format!(
|
||||
"npm run build failed: {}",
|
||||
String::from_utf8_lossy(&build_output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let dist_path = plugin_path.join("dist");
|
||||
if !dist_path.exists() {
|
||||
return Err("dist directory not found after build".to_string());
|
||||
}
|
||||
|
||||
// Step 3: Package
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "package".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let zip_path = build_cache_dir.join("index.zip");
|
||||
let zip_file =
|
||||
fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?;
|
||||
|
||||
let mut zip = zip::ZipWriter::new(zip_file);
|
||||
let options = FileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o755);
|
||||
|
||||
// Add package.json
|
||||
let package_json_content = fs::read(&package_json_path)
|
||||
.map_err(|e| format!("Failed to read package.json: {}", e))?;
|
||||
zip.start_file("package.json", options)
|
||||
.map_err(|e| format!("Failed to add package.json to zip: {}", e))?;
|
||||
zip.write_all(&package_json_content)
|
||||
.map_err(|e| format!("Failed to write package.json to zip: {}", e))?;
|
||||
|
||||
// Add dist directory
|
||||
add_directory_to_zip(&mut zip, plugin_path, &dist_path, options)
|
||||
.map_err(|e| format!("Failed to add dist directory to zip: {}", e))?;
|
||||
|
||||
zip.finish()
|
||||
.map_err(|e| format!("Failed to finalize zip: {}", e))?;
|
||||
|
||||
// Step 4: Complete
|
||||
app.emit(
|
||||
"plugin-build-progress",
|
||||
BuildProgress {
|
||||
step: "complete".to_string(),
|
||||
output: None,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
Ok(zip_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn add_directory_to_zip<W: std::io::Write + std::io::Seek>(
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
base_path: &Path,
|
||||
current_path: &Path,
|
||||
options: FileOptions,
|
||||
) -> Result<(), String> {
|
||||
let entries = fs::read_dir(current_path)
|
||||
.map_err(|e| format!("Failed to read directory {}: {}", current_path.display(), e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
add_directory_to_zip(zip, base_path, &path, options)?;
|
||||
} else {
|
||||
let relative_path = path
|
||||
.strip_prefix(base_path)
|
||||
.map_err(|e| format!("Failed to get relative path: {}", e))?;
|
||||
|
||||
let zip_path = relative_path.to_string_lossy().replace('\\', "/");
|
||||
|
||||
let file_content = fs::read(&path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?;
|
||||
|
||||
zip.start_file(&zip_path, options)
|
||||
.map_err(|e| format!("Failed to add file {} to zip: {}", zip_path, e))?;
|
||||
|
||||
zip.write_all(&file_content)
|
||||
.map_err(|e| format!("Failed to write file {} to zip: {}", zip_path, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a plugin from marketplace
|
||||
#[tauri::command]
|
||||
pub async fn install_marketplace_plugin(
|
||||
project_path: String,
|
||||
plugin_id: String,
|
||||
zip_data_base64: String,
|
||||
) -> Result<String, String> {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
||||
let project_path = Path::new(&project_path);
|
||||
if !project_path.exists() {
|
||||
return Err(format!(
|
||||
"Project path does not exist: {}",
|
||||
project_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let plugins_dir = project_path.join("plugins");
|
||||
if !plugins_dir.exists() {
|
||||
fs::create_dir_all(&plugins_dir)
|
||||
.map_err(|e| format!("Failed to create plugins directory: {}", e))?;
|
||||
}
|
||||
|
||||
let plugin_dir = plugins_dir.join(&plugin_id);
|
||||
if plugin_dir.exists() {
|
||||
fs::remove_dir_all(&plugin_dir)
|
||||
.map_err(|e| format!("Failed to remove old plugin directory: {}", e))?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&plugin_dir)
|
||||
.map_err(|e| format!("Failed to create plugin directory: {}", e))?;
|
||||
|
||||
let zip_bytes = general_purpose::STANDARD
|
||||
.decode(&zip_data_base64)
|
||||
.map_err(|e| format!("Failed to decode base64 ZIP data: {}", e))?;
|
||||
|
||||
let cursor = Cursor::new(zip_bytes);
|
||||
let mut archive =
|
||||
ZipArchive::new(cursor).map_err(|e| format!("Failed to read ZIP archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read ZIP entry {}: {}", i, e))?;
|
||||
|
||||
let file_path = match file.enclosed_name() {
|
||||
Some(path) => path,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let out_path = plugin_dir.join(file_path);
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", out_path.display(), e))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
|
||||
}
|
||||
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
|
||||
std::io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", out_path.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plugin_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Uninstall a plugin
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_marketplace_plugin(
|
||||
project_path: String,
|
||||
plugin_id: String,
|
||||
) -> Result<(), String> {
|
||||
let project_path = Path::new(&project_path);
|
||||
let plugin_dir = project_path.join("plugins").join(&plugin_id);
|
||||
|
||||
if !plugin_dir.exists() {
|
||||
return Err(format!(
|
||||
"Plugin directory does not exist: {}",
|
||||
plugin_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&plugin_dir)
|
||||
.map_err(|e| format!("Failed to remove plugin directory: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Profiler server commands
|
||||
//!
|
||||
//! WebSocket profiler server management.
|
||||
|
||||
use std::sync::Arc;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
use crate::state::ProfilerState;
|
||||
|
||||
/// Start the profiler WebSocket server
|
||||
#[tauri::command]
|
||||
pub async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the profiler WebSocket server
|
||||
#[tauri::command]
|
||||
pub async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
if let Some(server) = server_lock.as_ref() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
/// Get profiler server status
|
||||
#[tauri::command]
|
||||
pub async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Project management commands
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::state::ProjectPaths;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_project(path: String) -> Result<String, String> {
|
||||
Ok(format!("Project opened: {}", path))
|
||||
}
|
||||
|
||||
/// Save project data
|
||||
#[tauri::command]
|
||||
pub fn save_project(path: String, data: String) -> Result<(), String> {
|
||||
fs::write(&path, data).map_err(|e| format!("Failed to save project: {}", e))
|
||||
}
|
||||
|
||||
/// Export binary data
|
||||
#[tauri::command]
|
||||
pub fn export_binary(data: Vec<u8>, output_path: String) -> Result<(), String> {
|
||||
fs::write(&output_path, data).map_err(|e| format!("Failed to export binary: {}", e))
|
||||
}
|
||||
|
||||
/// Set current project base path
|
||||
#[tauri::command]
|
||||
pub fn set_project_base_path(path: String, state: tauri::State<ProjectPaths>) -> Result<(), String> {
|
||||
let mut paths = state
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock state: {}", e))?;
|
||||
paths.insert("current".to_string(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan for behavior tree files in project
|
||||
#[tauri::command]
|
||||
pub fn scan_behavior_trees(project_path: String) -> Result<Vec<String>, String> {
|
||||
let behaviors_path = Path::new(&project_path).join(".ecs").join("behaviors");
|
||||
|
||||
if !behaviors_path.exists() {
|
||||
fs::create_dir_all(&behaviors_path)
|
||||
.map_err(|e| format!("Failed to create behaviors directory: {}", e))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut btree_files = Vec::new();
|
||||
scan_directory_recursive(&behaviors_path, &behaviors_path, &mut btree_files)?;
|
||||
|
||||
Ok(btree_files)
|
||||
}
|
||||
|
||||
fn scan_directory_recursive(
|
||||
base_path: &Path,
|
||||
current_path: &Path,
|
||||
results: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let entries =
|
||||
fs::read_dir(current_path).map_err(|e| format!("Failed to read directory: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
scan_directory_recursive(base_path, &path, results)?;
|
||||
} else if path.extension().and_then(|s| s.to_str()) == Some("btree") {
|
||||
if let Ok(relative) = path.strip_prefix(base_path) {
|
||||
let relative_str = relative
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.trim_end_matches(".btree")
|
||||
.to_string();
|
||||
results.push(relative_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! System operations
|
||||
//!
|
||||
//! OS-level operations like opening files, showing in folder, devtools, etc.
|
||||
|
||||
use std::process::Command;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Toggle developer tools (debug mode only)
|
||||
#[tauri::command]
|
||||
pub fn toggle_devtools(app: AppHandle) -> Result<(), String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Window not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("DevTools are only available in debug mode".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open file with system default application
|
||||
#[tauri::command]
|
||||
pub fn open_file_with_default_app(file_path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show file in system file explorer
|
||||
#[tauri::command]
|
||||
pub fn show_in_folder(file_path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("explorer")
|
||||
.args(["/select,", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.args(["-R", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::path::Path;
|
||||
let path = Path::new(&file_path);
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| "Failed to get parent directory".to_string())?;
|
||||
|
||||
Command::new("xdg-open")
|
||||
.arg(parent)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// ECS Editor Library
|
||||
//! ECS Editor Library
|
||||
//!
|
||||
//! Exports all public modules for the Tauri application.
|
||||
|
||||
pub mod commands;
|
||||
pub mod project;
|
||||
pub mod profiler_ws;
|
||||
pub mod state;
|
||||
|
||||
pub use commands::*;
|
||||
pub use project::*;
|
||||
pub use profiler_ws::*;
|
||||
// Re-export commonly used types
|
||||
pub use state::{ProfilerState, ProjectPaths};
|
||||
|
||||
@@ -1,631 +1,153 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
//! ECS Framework Editor - Tauri Backend
|
||||
//!
|
||||
//! Clean entry point that handles application setup and command registration.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::AppHandle;
|
||||
use std::sync::{Arc, Mutex};
|
||||
mod commands;
|
||||
mod profiler_ws;
|
||||
mod state;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use ecs_editor_lib::profiler_ws::ProfilerServer;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
// IPC Commands
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to ECS Framework Editor.", name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_project(path: String) -> Result<String, String> {
|
||||
// 项目打开逻辑
|
||||
Ok(format!("Project opened: {}", path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_project(path: String, data: String) -> Result<(), String> {
|
||||
// 项目保存逻辑
|
||||
std::fs::write(&path, data)
|
||||
.map_err(|e| format!("Failed to save project: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_binary(data: Vec<u8>, output_path: String) -> Result<(), String> {
|
||||
std::fs::write(&output_path, data)
|
||||
.map_err(|e| format!("Failed to export binary: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||
std::fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn path_exists(path: String) -> Result<bool, String> {
|
||||
use std::path::Path;
|
||||
Ok(Path::new(&path).exists())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_file_or_folder(old_path: String, new_path: String) -> Result<(), String> {
|
||||
std::fs::rename(&old_path, &new_path)
|
||||
.map_err(|e| format!("Failed to rename: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_file(path: String) -> Result<(), String> {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to delete file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_folder(path: String) -> Result<(), String> {
|
||||
std::fs::remove_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to delete folder: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_file(path: String) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
File::create(&path)
|
||||
.map_err(|e| format!("Failed to create file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_project_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let folder = app.dialog()
|
||||
.file()
|
||||
.set_title("Select Project Directory")
|
||||
.blocking_pick_folder();
|
||||
|
||||
Ok(folder.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_scene_dialog(app: AppHandle, default_name: Option<String>) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let mut dialog = app.dialog()
|
||||
.file()
|
||||
.set_title("Save ECS Scene")
|
||||
.add_filter("ECS Scene Files", &["ecs"]);
|
||||
|
||||
if let Some(name) = default_name {
|
||||
dialog = dialog.set_file_name(&name);
|
||||
}
|
||||
|
||||
let file = dialog.blocking_save_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_scene_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.set_title("Open ECS Scene")
|
||||
.add_filter("ECS Scene Files", &["ecs"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_behavior_tree_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.set_title("Select Behavior Tree")
|
||||
.add_filter("Behavior Tree Files", &["btree"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_behavior_trees(project_path: String) -> Result<Vec<String>, String> {
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
|
||||
let behaviors_path = Path::new(&project_path).join(".ecs").join("behaviors");
|
||||
|
||||
if !behaviors_path.exists() {
|
||||
fs::create_dir_all(&behaviors_path)
|
||||
.map_err(|e| format!("Failed to create behaviors directory: {}", e))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut btree_files = Vec::new();
|
||||
scan_directory_recursive(&behaviors_path, &behaviors_path, &mut btree_files)?;
|
||||
|
||||
Ok(btree_files)
|
||||
}
|
||||
|
||||
fn scan_directory_recursive(
|
||||
base_path: &std::path::Path,
|
||||
current_path: &std::path::Path,
|
||||
results: &mut Vec<String>
|
||||
) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
let entries = fs::read_dir(current_path)
|
||||
.map_err(|e| format!("Failed to read directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
scan_directory_recursive(base_path, &path, results)?;
|
||||
} else if path.extension().and_then(|s| s.to_str()) == Some("btree") {
|
||||
if let Ok(relative) = path.strip_prefix(base_path) {
|
||||
let relative_str = relative.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.trim_end_matches(".btree")
|
||||
.to_string();
|
||||
results.push(relative_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
|
||||
use glob::glob;
|
||||
use std::path::Path;
|
||||
|
||||
let base_path = Path::new(&path);
|
||||
if !base_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
let separator = if path.contains('\\') { '\\' } else { '/' };
|
||||
let glob_pattern = format!("{}{}{}", path.trim_end_matches(&['/', '\\'][..]), separator, pattern);
|
||||
let normalized_pattern = if cfg!(windows) {
|
||||
glob_pattern.replace('/', "\\")
|
||||
} else {
|
||||
glob_pattern.replace('\\', "/")
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
match glob(&normalized_pattern) {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Ok(path) => {
|
||||
if path.is_file() {
|
||||
files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading entry: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to scan directory: {}", e)),
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_file_content(path: String) -> Result<String, String> {
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", path, e))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DirectoryEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
is_dir: bool,
|
||||
size: Option<u64>,
|
||||
modified: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let dir_path = Path::new(&path);
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("Path is not a directory: {}", path));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
match fs::read_dir(dir_path) {
|
||||
Ok(read_dir) => {
|
||||
for entry in read_dir {
|
||||
match entry {
|
||||
Ok(entry) => {
|
||||
let entry_path = entry.path();
|
||||
if let Some(name) = entry_path.file_name() {
|
||||
let is_dir = entry_path.is_dir();
|
||||
|
||||
// 获取文件元数据
|
||||
let (size, modified) = match fs::metadata(&entry_path) {
|
||||
Ok(metadata) => {
|
||||
let size = if is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(metadata.len())
|
||||
};
|
||||
|
||||
let modified = metadata.modified()
|
||||
.ok()
|
||||
.and_then(|time| {
|
||||
time.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| d.as_secs())
|
||||
});
|
||||
|
||||
(size, modified)
|
||||
}
|
||||
Err(_) => (None, None),
|
||||
};
|
||||
|
||||
entries.push(DirectoryEntry {
|
||||
name: name.to_string_lossy().to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading directory entry: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to read directory: {}", e)),
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_project_base_path(
|
||||
path: String,
|
||||
state: tauri::State<Arc<Mutex<HashMap<String, String>>>>
|
||||
) -> Result<(), String> {
|
||||
let mut paths = state.lock().map_err(|e| format!("Failed to lock state: {}", e))?;
|
||||
paths.insert("current".to_string(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_devtools(app: AppHandle) -> Result<(), String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Window not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
Err("DevTools are only available in debug mode".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Profiler State
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<tokio::sync::Mutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
// 调用 stop 方法正确关闭服务器
|
||||
if let Some(server) = server_lock.as_ref() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn read_behavior_tree_file(file_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接读取文件,绕过 Tauri 的 scope 限制
|
||||
fs::read_to_string(&file_path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn write_behavior_tree_file(file_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
// 使用 Rust 标准库直接写入文件
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn write_binary_file(file_path: String, content: Vec<u8>) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
// 写入二进制文件
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write binary file {}: {}", file_path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn read_global_blackboard(project_path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let config_path = Path::new(&project_path).join(".ecs").join("global-blackboard.json");
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(String::from(r#"{"version":"1.0","variables":[]}"#));
|
||||
}
|
||||
|
||||
fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read global blackboard: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn write_global_blackboard(project_path: String, content: String) -> Result<(), String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let ecs_dir = Path::new(&project_path).join(".ecs");
|
||||
let config_path = ecs_dir.join("global-blackboard.json");
|
||||
|
||||
// 创建 .ecs 目录(如果不存在)
|
||||
if !ecs_dir.exists() {
|
||||
fs::create_dir_all(&ecs_dir)
|
||||
.map_err(|e| format!("Failed to create .ecs directory: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write global blackboard: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_file_with_default_app(file_path: String) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn show_in_folder(file_path: String) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("explorer")
|
||||
.args(["/select,", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.args(["-R", &file_path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::path::Path;
|
||||
let path = Path::new(&file_path);
|
||||
let parent = path.parent()
|
||||
.ok_or_else(|| "Failed to get parent directory".to_string())?;
|
||||
|
||||
Command::new("xdg-open")
|
||||
.arg(parent)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to show in folder: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
use state::{ProfilerState, ProjectPaths};
|
||||
|
||||
fn main() {
|
||||
let project_paths: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let project_paths_clone = Arc::clone(&project_paths);
|
||||
// Initialize shared state
|
||||
let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new()));
|
||||
let project_paths_for_protocol = Arc::clone(&project_paths);
|
||||
|
||||
let profiler_state = ProfilerState {
|
||||
server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
};
|
||||
let profiler_state = ProfilerState::new();
|
||||
|
||||
// Build and run the Tauri application
|
||||
tauri::Builder::default()
|
||||
// Register plugins
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
// Register custom URI scheme for project files
|
||||
.register_uri_scheme_protocol("project", move |_app, request| {
|
||||
let project_paths = Arc::clone(&project_paths_clone);
|
||||
|
||||
let uri = request.uri();
|
||||
let path = uri.path();
|
||||
|
||||
let file_path = {
|
||||
let paths = project_paths.lock().unwrap();
|
||||
if let Some(base_path) = paths.get("current") {
|
||||
format!("{}{}", base_path, path)
|
||||
} else {
|
||||
return tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let mime_type = if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else {
|
||||
"text/plain"
|
||||
};
|
||||
|
||||
tauri::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(content)
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read file {}: {}", file_path, e);
|
||||
tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
handle_project_protocol(request, &project_paths_for_protocol)
|
||||
})
|
||||
// Setup application state
|
||||
.setup(move |app| {
|
||||
app.manage(project_paths);
|
||||
app.manage(profiler_state);
|
||||
Ok(())
|
||||
})
|
||||
// Register all commands
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
open_project,
|
||||
save_project,
|
||||
export_binary,
|
||||
create_directory,
|
||||
write_file_content,
|
||||
path_exists,
|
||||
rename_file_or_folder,
|
||||
delete_file,
|
||||
delete_folder,
|
||||
create_file,
|
||||
open_project_dialog,
|
||||
save_scene_dialog,
|
||||
open_scene_dialog,
|
||||
open_behavior_tree_dialog,
|
||||
scan_directory,
|
||||
scan_behavior_trees,
|
||||
read_file_content,
|
||||
list_directory,
|
||||
set_project_base_path,
|
||||
toggle_devtools,
|
||||
start_profiler_server,
|
||||
stop_profiler_server,
|
||||
get_profiler_status,
|
||||
read_behavior_tree_file,
|
||||
write_behavior_tree_file,
|
||||
write_binary_file,
|
||||
read_global_blackboard,
|
||||
write_global_blackboard,
|
||||
open_file_with_default_app,
|
||||
show_in_folder
|
||||
// Project management
|
||||
commands::open_project,
|
||||
commands::save_project,
|
||||
commands::export_binary,
|
||||
commands::set_project_base_path,
|
||||
commands::scan_behavior_trees,
|
||||
// File system operations
|
||||
commands::read_file_content,
|
||||
commands::write_file_content,
|
||||
commands::write_binary_file,
|
||||
commands::path_exists,
|
||||
commands::create_directory,
|
||||
commands::create_file,
|
||||
commands::delete_file,
|
||||
commands::delete_folder,
|
||||
commands::rename_file_or_folder,
|
||||
commands::list_directory,
|
||||
commands::scan_directory,
|
||||
commands::read_file_as_base64,
|
||||
// Dialog operations
|
||||
commands::open_folder_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::save_file_dialog,
|
||||
// Profiler server
|
||||
commands::start_profiler_server,
|
||||
commands::stop_profiler_server,
|
||||
commands::get_profiler_status,
|
||||
// Plugin management
|
||||
commands::build_plugin,
|
||||
commands::install_marketplace_plugin,
|
||||
commands::uninstall_marketplace_plugin,
|
||||
// System operations
|
||||
commands::toggle_devtools,
|
||||
commands::open_file_with_default_app,
|
||||
commands::show_in_folder,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
/// Handle the custom 'project://' URI scheme protocol
|
||||
///
|
||||
/// This allows the frontend to load project files through a custom protocol,
|
||||
/// enabling features like hot-reloading plugins from the project directory.
|
||||
fn handle_project_protocol(
|
||||
request: tauri::http::Request<Vec<u8>>,
|
||||
project_paths: &ProjectPaths,
|
||||
) -> tauri::http::Response<Vec<u8>> {
|
||||
let uri = request.uri();
|
||||
let path = uri.path();
|
||||
|
||||
let file_path = {
|
||||
let paths = match project_paths.lock() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return tauri::http::Response::builder()
|
||||
.status(500)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
match paths.get("current") {
|
||||
Some(base_path) => format!("{}{}", base_path, path),
|
||||
None => {
|
||||
return tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let mime_type = get_mime_type(&file_path);
|
||||
|
||||
tauri::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(content)
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read file {}: {}", file_path, e);
|
||||
tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get MIME type based on file extension
|
||||
fn get_mime_type(file_path: &str) -> &'static str {
|
||||
if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if file_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file_path.ends_with(".html") {
|
||||
"text/html"
|
||||
} else {
|
||||
"text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ impl ProfilerServer {
|
||||
println!("[ProfilerServer] Server stopped");
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn broadcast(&self, message: String) {
|
||||
let _ = self.tx.send(message);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub scenes: Vec<String>,
|
||||
pub assets: Vec<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: String, path: PathBuf) -> Self {
|
||||
Self {
|
||||
name,
|
||||
path,
|
||||
scenes: Vec::new(),
|
||||
assets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &PathBuf) -> Result<Self, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read project file: {}", e))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse project file: {}", e))
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let mut project_file = self.path.clone();
|
||||
project_file.push("project.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| format!("Failed to serialize project: {}", e))?;
|
||||
|
||||
std::fs::write(&project_file, content)
|
||||
.map_err(|e| format!("Failed to write project file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Application state definitions
|
||||
//!
|
||||
//! Centralized state management for the Tauri application.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
|
||||
/// Project paths state
|
||||
///
|
||||
/// Stores the current project path and other path-related information.
|
||||
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Profiler server state
|
||||
///
|
||||
/// Manages the lifecycle of the WebSocket profiler server.
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<TokioMutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
impl ProfilerState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
server: Arc::new(TokioMutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProfilerState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -78,18 +78,32 @@
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-exists"
|
||||
"fs:allow-exists",
|
||||
"fs:allow-read",
|
||||
"fs:allow-write",
|
||||
"fs:allow-create",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-copy-file"
|
||||
],
|
||||
"scope": {
|
||||
"allow": [
|
||||
"$HOME/**",
|
||||
"$APPDATA/**",
|
||||
"$APPLOCALDATA/**",
|
||||
"$APPCACHE/**",
|
||||
"$APPLOG/**",
|
||||
"$DESKTOP/**",
|
||||
"$DOCUMENT/**",
|
||||
"$DOWNLOAD/**"
|
||||
"$DOWNLOAD/**",
|
||||
"$TEMP/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"http-capability"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+251
-102
@@ -1,12 +1,24 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Core, Scene, createLogger } from '@esengine/ecs-framework';
|
||||
import { Core, createLogger, Scene } from '@esengine/ecs-framework';
|
||||
import * as ECSFramework from '@esengine/ecs-framework';
|
||||
import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, PropertyMetadataService, LogService, SettingsRegistry, SceneManagerService, FileActionRegistry, PanelDescriptor } from '@esengine/editor-core';
|
||||
import {
|
||||
EditorPluginManager,
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
EntityStoreService,
|
||||
ComponentRegistry,
|
||||
LocaleService,
|
||||
LogService,
|
||||
SettingsRegistry,
|
||||
SceneManagerService,
|
||||
ProjectService,
|
||||
CompilerRegistry,
|
||||
InspectorRegistry,
|
||||
INotification
|
||||
} from '@esengine/editor-core';
|
||||
import type { IDialogExtended } from './services/TauriDialogService';
|
||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from './plugins/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from './plugins/EditorAppearancePlugin';
|
||||
import { BehaviorTreePlugin } from './plugins/BehaviorTreePlugin';
|
||||
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
|
||||
import { StartupPage } from './components/StartupPage';
|
||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||
import { Inspector } from './components/Inspector';
|
||||
@@ -20,13 +32,18 @@ import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { MenuBar } from './components/MenuBar';
|
||||
import { UserProfile } from './components/UserProfile';
|
||||
import { UserDashboard } from './components/UserDashboard';
|
||||
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
|
||||
import { TauriAPI } from './api/tauri';
|
||||
import { TauriFileAPI } from './adapters/TauriFileAPI';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
import { PluginLoader } from './services/PluginLoader';
|
||||
import { GitHubService } from './services/GitHubService';
|
||||
import { PluginPublishWizard } from './components/PluginPublishWizard';
|
||||
import { GitHubLoginDialog } from './components/GitHubLoginDialog';
|
||||
import { CompilerConfigDialog } from './components/CompilerConfigDialog';
|
||||
import { checkForUpdatesOnStartup } from './utils/updater';
|
||||
import { useLocale } from './hooks/useLocale';
|
||||
import { en, zh } from './locales';
|
||||
@@ -41,12 +58,15 @@ localeService.registerTranslations('zh', zh);
|
||||
Core.services.registerInstance(LocaleService, localeService);
|
||||
|
||||
Core.services.registerSingleton(GlobalBlackboardService);
|
||||
Core.services.registerSingleton(CompilerRegistry);
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const pluginLoaderRef = useRef<PluginLoader>(new PluginLoader());
|
||||
const [pluginLoader] = useState(() => new PluginLoader());
|
||||
const [githubService] = useState(() => new GitHubService());
|
||||
const { showToast, hideToast } = useToast();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -55,34 +75,48 @@ function App() {
|
||||
const [pluginManager, setPluginManager] = useState<EditorPluginManager | null>(null);
|
||||
const [entityStore, setEntityStore] = useState<EntityStoreService | null>(null);
|
||||
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
|
||||
const [inspectorRegistry, setInspectorRegistry] = useState<InspectorRegistry | null>(null);
|
||||
const [logService, setLogService] = useState<LogService | null>(null);
|
||||
const [uiRegistry, setUiRegistry] = useState<UIRegistry | null>(null);
|
||||
const [settingsRegistry, setSettingsRegistry] = useState<SettingsRegistry | null>(null);
|
||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||
const [notification, setNotification] = useState<INotification | null>(null);
|
||||
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
|
||||
// 同步 locale 到 TauriDialogService
|
||||
useEffect(() => {
|
||||
if (dialog) {
|
||||
dialog.setLocale(locale);
|
||||
}
|
||||
}, [locale, dialog]);
|
||||
const [status, setStatus] = useState(t('header.status.initializing'));
|
||||
const [panels, setPanels] = useState<FlexDockPanel[]>([]);
|
||||
const [showPluginManager, setShowPluginManager] = useState(false);
|
||||
const [showProfiler, setShowProfiler] = useState(false);
|
||||
const [showPortManager, setShowPortManager] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showPluginGenerator, setShowPluginGenerator] = useState(false);
|
||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
||||
const [errorDialog, setErrorDialog] = useState<{ title: string; message: string } | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
showPluginManager, setShowPluginManager,
|
||||
showProfiler, setShowProfiler,
|
||||
showPortManager, setShowPortManager,
|
||||
showSettings, setShowSettings,
|
||||
showAbout, setShowAbout,
|
||||
showPluginGenerator, setShowPluginGenerator,
|
||||
errorDialog, setErrorDialog,
|
||||
confirmDialog, setConfirmDialog
|
||||
} = useDialogStore();
|
||||
const [activeDynamicPanels, setActiveDynamicPanels] = useState<string[]>([]);
|
||||
const [activePanelId, setActivePanelId] = useState<string | undefined>(undefined);
|
||||
const [dynamicPanelTitles, setDynamicPanelTitles] = useState<Map<string, string>>(new Map());
|
||||
const [isEditorFullscreen, setIsEditorFullscreen] = useState(false);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const [showDashboard, setShowDashboard] = useState(false);
|
||||
const [compilerDialog, setCompilerDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
compilerId: string;
|
||||
currentFileName?: string;
|
||||
}>({ isOpen: false, compilerId: '' });
|
||||
|
||||
useEffect(() => {
|
||||
// 禁用默认右键菜单
|
||||
@@ -90,12 +124,22 @@ function App() {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// 添加快捷键监听(Ctrl+R 重新加载插件)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
handleReloadPlugins();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('contextmenu', handleContextMenu);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}, [currentProjectPath, pluginManager, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
@@ -107,41 +151,50 @@ function App() {
|
||||
setPluginUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
|
||||
const unsubscribeNotification = messageHub.subscribe('notification:show', (notification: { message: string; type: 'success' | 'error' | 'warning' | 'info'; timestamp: number }) => {
|
||||
if (notification && notification.message) {
|
||||
showToast(notification.message, notification.type);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
unsubscribeNotification();
|
||||
};
|
||||
}
|
||||
}, [messageHub]);
|
||||
}, [messageHub, showToast]);
|
||||
|
||||
// 监听远程连接状态
|
||||
useEffect(() => {
|
||||
const checkConnection = () => {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__;
|
||||
if (profilerService && profilerService.isConnected()) {
|
||||
if (!isRemoteConnected) {
|
||||
setIsRemoteConnected(true);
|
||||
setStatus(t('header.status.remoteConnected'));
|
||||
}
|
||||
} else {
|
||||
if (isRemoteConnected) {
|
||||
setIsRemoteConnected(false);
|
||||
if (projectLoaded) {
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const componentCount = componentRegistry?.getAllComponents().length || 0;
|
||||
setStatus(t('header.status.projectOpened') + (componentCount > 0 ? ` (${componentCount} components registered)` : ''));
|
||||
const connected = profilerService && profilerService.isConnected();
|
||||
|
||||
setIsRemoteConnected((prevConnected) => {
|
||||
if (connected !== prevConnected) {
|
||||
// 状态发生变化
|
||||
if (connected) {
|
||||
setStatus(t('header.status.remoteConnected'));
|
||||
} else {
|
||||
setStatus(t('header.status.ready'));
|
||||
if (projectLoaded) {
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const componentCount = componentRegistry?.getAllComponents().length || 0;
|
||||
setStatus(t('header.status.projectOpened') + (componentCount > 0 ? ` (${componentCount} components registered)` : ''));
|
||||
} else {
|
||||
setStatus(t('header.status.ready'));
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
}
|
||||
return prevConnected;
|
||||
});
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
const interval = setInterval(checkConnection, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [projectLoaded, isRemoteConnected, t]);
|
||||
}, [projectLoaded, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeEditor = async () => {
|
||||
@@ -157,53 +210,21 @@ function App() {
|
||||
const editorScene = new Scene();
|
||||
Core.setScene(editorScene);
|
||||
|
||||
const uiRegistry = new UIRegistry();
|
||||
const messageHub = new MessageHub();
|
||||
const serializerRegistry = new SerializerRegistry();
|
||||
const entityStore = new EntityStoreService(messageHub);
|
||||
const componentRegistry = new ComponentRegistry();
|
||||
const fileAPI = new TauriFileAPI();
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
const logService = new LogService();
|
||||
const settingsRegistry = new SettingsRegistry();
|
||||
const sceneManagerService = new SceneManagerService(messageHub, fileAPI, projectService);
|
||||
const fileActionRegistry = new FileActionRegistry();
|
||||
const serviceRegistry = new ServiceRegistry();
|
||||
const services = serviceRegistry.registerAllServices(coreInstance);
|
||||
|
||||
// 监听远程日志事件
|
||||
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
|
||||
const { level, message, timestamp, clientId } = event.detail;
|
||||
logService.addRemoteLog(level, message, timestamp, clientId);
|
||||
}) as EventListener);
|
||||
serviceRegistry.setupRemoteLogListener(services.logService);
|
||||
|
||||
Core.services.registerInstance(UIRegistry, uiRegistry);
|
||||
Core.services.registerInstance(MessageHub, messageHub);
|
||||
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
|
||||
Core.services.registerInstance(EntityStoreService, entityStore);
|
||||
Core.services.registerInstance(ComponentRegistry, componentRegistry);
|
||||
Core.services.registerInstance(ProjectService, projectService);
|
||||
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
|
||||
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
|
||||
Core.services.registerInstance(LogService, logService);
|
||||
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
||||
Core.services.registerInstance(SceneManagerService, sceneManagerService);
|
||||
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
|
||||
const pluginInstaller = new PluginInstaller();
|
||||
await pluginInstaller.installBuiltinPlugins(services.pluginManager);
|
||||
|
||||
const pluginMgr = new EditorPluginManager();
|
||||
pluginMgr.initialize(coreInstance, Core.services);
|
||||
Core.services.registerInstance(EditorPluginManager, pluginMgr);
|
||||
services.notification.setCallbacks(showToast, hideToast);
|
||||
(services.dialog as IDialogExtended).setConfirmCallback(setConfirmDialog);
|
||||
|
||||
await pluginMgr.installEditor(new SceneInspectorPlugin());
|
||||
await pluginMgr.installEditor(new ProfilerPlugin());
|
||||
await pluginMgr.installEditor(new EditorAppearancePlugin());
|
||||
await pluginMgr.installEditor(new BehaviorTreePlugin());
|
||||
|
||||
messageHub.subscribe('ui:openWindow', (data: any) => {
|
||||
services.messageHub.subscribe('ui:openWindow', (data: any) => {
|
||||
console.log('[App] Received ui:openWindow:', data);
|
||||
const { windowId, ...params } = data;
|
||||
const { windowId } = data;
|
||||
|
||||
// 内置窗口处理
|
||||
if (windowId === 'profiler') {
|
||||
setShowProfiler(true);
|
||||
} else if (windowId === 'pluginManager') {
|
||||
@@ -211,16 +232,17 @@ function App() {
|
||||
}
|
||||
});
|
||||
|
||||
await TauriAPI.greet('Developer');
|
||||
|
||||
setInitialized(true);
|
||||
setPluginManager(pluginMgr);
|
||||
setEntityStore(entityStore);
|
||||
setMessageHub(messageHub);
|
||||
setLogService(logService);
|
||||
setUiRegistry(uiRegistry);
|
||||
setSettingsRegistry(settingsRegistry);
|
||||
setSceneManager(sceneManagerService);
|
||||
setPluginManager(services.pluginManager);
|
||||
setEntityStore(services.entityStore);
|
||||
setMessageHub(services.messageHub);
|
||||
setInspectorRegistry(services.inspectorRegistry);
|
||||
setLogService(services.logService);
|
||||
setUiRegistry(services.uiRegistry);
|
||||
setSettingsRegistry(services.settingsRegistry);
|
||||
setSceneManager(services.sceneManager);
|
||||
setNotification(services.notification);
|
||||
setDialog(services.dialog as IDialogExtended);
|
||||
setStatus(t('header.status.ready'));
|
||||
|
||||
// Check for updates on startup (after 3 seconds)
|
||||
@@ -271,6 +293,25 @@ function App() {
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('compiler:open-dialog', (data: {
|
||||
compilerId: string;
|
||||
currentFileName?: string;
|
||||
projectPath?: string;
|
||||
}) => {
|
||||
logger.info('Opening compiler dialog:', data.compilerId);
|
||||
setCompilerDialog({
|
||||
isOpen: true,
|
||||
compilerId: data.compilerId,
|
||||
currentFileName: data.currentFileName
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub]);
|
||||
|
||||
const handleOpenRecentProject = async (projectPath: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -323,7 +364,7 @@ function App() {
|
||||
|
||||
if (pluginManager) {
|
||||
setLoadingMessage(locale === 'zh' ? '加载项目插件...' : 'Loading project plugins...');
|
||||
await pluginLoaderRef.current.loadProjectPlugins(projectPath, pluginManager);
|
||||
await pluginLoader.loadProjectPlugins(projectPath, pluginManager);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -520,7 +561,7 @@ function App() {
|
||||
|
||||
const handleCloseProject = async () => {
|
||||
if (pluginManager) {
|
||||
await pluginLoaderRef.current.unloadProjectPlugins(pluginManager);
|
||||
await pluginLoader.unloadProjectPlugins(pluginManager);
|
||||
}
|
||||
setProjectLoaded(false);
|
||||
setCurrentProjectPath(null);
|
||||
@@ -568,6 +609,48 @@ function App() {
|
||||
setShowPluginGenerator(true);
|
||||
};
|
||||
|
||||
const handleReloadPlugins = async () => {
|
||||
if (currentProjectPath && pluginManager) {
|
||||
try {
|
||||
console.log('[App] Starting plugin hot reload...');
|
||||
|
||||
// 1. 关闭所有动态面板
|
||||
console.log('[App] Closing all dynamic panels');
|
||||
setActiveDynamicPanels([]);
|
||||
|
||||
// 2. 清空当前面板列表(强制卸载插件面板组件)
|
||||
console.log('[App] Clearing plugin panels');
|
||||
setPanels((prev) => prev.filter((p) =>
|
||||
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
|
||||
));
|
||||
|
||||
// 3. 等待React完成卸载
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// 4. 卸载所有项目插件(清理UIRegistry、调用uninstall)
|
||||
console.log('[App] Unloading all project plugins');
|
||||
await pluginLoader.unloadProjectPlugins(pluginManager);
|
||||
|
||||
// 5. 等待卸载完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 6. 重新加载插件
|
||||
console.log('[App] Reloading project plugins');
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
|
||||
// 7. 触发面板重新渲染
|
||||
console.log('[App] Triggering panel re-render');
|
||||
setPluginUpdateTrigger((prev) => prev + 1);
|
||||
|
||||
showToast(locale === 'zh' ? '插件已重新加载' : 'Plugins reloaded', 'success');
|
||||
console.log('[App] Plugin hot reload completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload plugins:', error);
|
||||
showToast(locale === 'zh' ? '重新加载插件失败' : 'Failed to reload plugins', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
||||
let corePanels: FlexDockPanel[];
|
||||
@@ -583,7 +666,7 @@ function App() {
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} projectPath={currentProjectPath} />,
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
@@ -604,7 +687,7 @@ function App() {
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} projectPath={currentProjectPath} />,
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
@@ -631,7 +714,6 @@ function App() {
|
||||
if (!panelDesc.component) {
|
||||
return false;
|
||||
}
|
||||
// 过滤掉动态面板
|
||||
if (panelDesc.isDynamic) {
|
||||
return false;
|
||||
}
|
||||
@@ -649,7 +731,7 @@ function App() {
|
||||
return {
|
||||
id: panelDesc.id,
|
||||
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
|
||||
content: <Component projectPath={currentProjectPath} />,
|
||||
content: <Component key={`${panelDesc.id}-${pluginUpdateTrigger}`} projectPath={currentProjectPath} />,
|
||||
closable: panelDesc.closable ?? true
|
||||
};
|
||||
});
|
||||
@@ -725,8 +807,16 @@ function App() {
|
||||
message={confirmDialog.message}
|
||||
confirmText={confirmDialog.confirmText}
|
||||
cancelText={confirmDialog.cancelText}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
onConfirm={() => {
|
||||
confirmDialog.onConfirm();
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (confirmDialog.onCancel) {
|
||||
confirmDialog.onCancel();
|
||||
}
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -756,8 +846,15 @@ function App() {
|
||||
onToggleDevtools={handleToggleDevtools}
|
||||
onOpenAbout={handleOpenAbout}
|
||||
onCreatePlugin={handleCreatePlugin}
|
||||
onReloadPlugins={handleReloadPlugins}
|
||||
/>
|
||||
<div className="header-right">
|
||||
<UserProfile
|
||||
githubService={githubService}
|
||||
onLogin={() => setShowLoginDialog(true)}
|
||||
onOpenDashboard={() => setShowDashboard(true)}
|
||||
locale={locale}
|
||||
/>
|
||||
<button onClick={handleLocaleChange} className="toolbar-btn locale-btn" title={locale === 'en' ? '切换到中文' : 'Switch to English'}>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
@@ -766,6 +863,37 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLoginDialog && (
|
||||
<GitHubLoginDialog
|
||||
githubService={githubService}
|
||||
onClose={() => setShowLoginDialog(false)}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDashboard && (
|
||||
<UserDashboard
|
||||
githubService={githubService}
|
||||
onClose={() => setShowDashboard(false)}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CompilerConfigDialog
|
||||
isOpen={compilerDialog.isOpen}
|
||||
compilerId={compilerDialog.compilerId}
|
||||
projectPath={currentProjectPath}
|
||||
currentFileName={compilerDialog.currentFileName}
|
||||
onClose={() => setCompilerDialog({ isOpen: false, compilerId: '' })}
|
||||
onCompileComplete={(result) => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="editor-content">
|
||||
<FlexLayoutDockContainer
|
||||
panels={panels}
|
||||
@@ -783,11 +911,13 @@ function App() {
|
||||
<span>{t('footer.core')}: {t('footer.active')}</span>
|
||||
</div>
|
||||
|
||||
{showPluginManager && pluginManager && (
|
||||
{showPluginManager && pluginManager && notification && dialog && (
|
||||
<PluginManagerWindow
|
||||
pluginManager={pluginManager}
|
||||
githubService={githubService}
|
||||
onClose={() => setShowPluginManager(false)}
|
||||
locale={locale}
|
||||
projectPath={currentProjectPath}
|
||||
onOpen={() => {
|
||||
// 同步所有插件的语言状态
|
||||
const allPlugins = pluginManager.getAllEditorPlugins();
|
||||
@@ -799,7 +929,7 @@ function App() {
|
||||
}}
|
||||
onRefresh={async () => {
|
||||
if (currentProjectPath && pluginManager) {
|
||||
await pluginLoaderRef.current.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -828,7 +958,7 @@ function App() {
|
||||
locale={locale}
|
||||
onSuccess={async () => {
|
||||
if (currentProjectPath && pluginManager) {
|
||||
await pluginLoaderRef.current.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -841,6 +971,25 @@ function App() {
|
||||
onClose={() => setErrorDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
confirmText={confirmDialog.confirmText}
|
||||
cancelText={confirmDialog.cancelText}
|
||||
onConfirm={() => {
|
||||
confirmDialog.onConfirm();
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (confirmDialog.onCancel) {
|
||||
confirmDialog.onCancel();
|
||||
}
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,47 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* 文件过滤器定义
|
||||
*/
|
||||
interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri IPC 通信层
|
||||
*/
|
||||
export class TauriAPI {
|
||||
/**
|
||||
* 打招呼(测试命令)
|
||||
*/
|
||||
static async greet(name: string): Promise<string> {
|
||||
return await invoke<string>('greet', { name });
|
||||
static async openFolderDialog(title?: string): Promise<string | null> {
|
||||
return await invoke<string | null>('open_folder_dialog', { title });
|
||||
}
|
||||
|
||||
static async openFileDialog(
|
||||
title?: string,
|
||||
filters?: FileFilter[],
|
||||
multiple?: boolean
|
||||
): Promise<string[] | null> {
|
||||
return await invoke<string[] | null>('open_file_dialog', {
|
||||
title,
|
||||
filters,
|
||||
multiple
|
||||
});
|
||||
}
|
||||
|
||||
static async saveFileDialog(
|
||||
title?: string,
|
||||
defaultName?: string,
|
||||
filters?: FileFilter[]
|
||||
): Promise<string | null> {
|
||||
return await invoke<string | null>('save_file_dialog', {
|
||||
title,
|
||||
defaultName,
|
||||
filters
|
||||
});
|
||||
}
|
||||
|
||||
static async openProjectDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_project_dialog');
|
||||
return await this.openFolderDialog('Select Project Directory');
|
||||
}
|
||||
|
||||
static async openProject(path: string): Promise<string> {
|
||||
@@ -77,7 +106,11 @@ export class TauriAPI {
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async saveSceneDialog(defaultName?: string): Promise<string | null> {
|
||||
return await invoke<string | null>('save_scene_dialog', { defaultName });
|
||||
return await this.saveFileDialog(
|
||||
'Save ECS Scene',
|
||||
defaultName,
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +118,12 @@ export class TauriAPI {
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openSceneDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_scene_dialog');
|
||||
const result = await this.openFileDialog(
|
||||
'Open ECS Scene',
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
|
||||
false
|
||||
);
|
||||
return result && result[0] ? result[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,13 +173,18 @@ export class TauriAPI {
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openBehaviorTreeDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_behavior_tree_dialog');
|
||||
const result = await this.openFileDialog(
|
||||
'Select Behavior Tree',
|
||||
[{ name: 'Behavior Tree Files', extensions: ['btree'] }],
|
||||
false
|
||||
);
|
||||
return result && result[0] ? result[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描项目中的所有行为树文件
|
||||
* @param projectPath 项目路径
|
||||
* @returns 行为树资产ID列表(相对于 .ecs/behaviors 的路径,不含扩展名)
|
||||
* @returns 行为树资产ID列表(相对于 .ecs/behaviors 的路 径,不含扩展名)
|
||||
*/
|
||||
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
@@ -179,30 +222,39 @@ export class TauriAPI {
|
||||
static async createFile(path: string): Promise<void> {
|
||||
return await invoke<void>('create_file', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件并转换为base64
|
||||
* @param path 文件路径
|
||||
* @returns base64编码的文件内容
|
||||
*/
|
||||
static async readFileAsBase64(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目信息
|
||||
*/
|
||||
export interface ProjectInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
version: string;
|
||||
name: string;
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
theme: string;
|
||||
autoSave: boolean;
|
||||
recentProjects: string[];
|
||||
theme: string;
|
||||
autoSave: boolean;
|
||||
recentProjects: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ConfirmDialogData } from '../../services/TauriDialogService';
|
||||
|
||||
interface ErrorDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
showPluginManager: boolean;
|
||||
showProfiler: boolean;
|
||||
showPortManager: boolean;
|
||||
showSettings: boolean;
|
||||
showAbout: boolean;
|
||||
showPluginGenerator: boolean;
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
|
||||
setShowPluginManager: (show: boolean) => void;
|
||||
setShowProfiler: (show: boolean) => void;
|
||||
setShowPortManager: (show: boolean) => void;
|
||||
setShowSettings: (show: boolean) => void;
|
||||
setShowAbout: (show: boolean) => void;
|
||||
setShowPluginGenerator: (show: boolean) => void;
|
||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||
closeAllDialogs: () => void;
|
||||
}
|
||||
|
||||
export const useDialogStore = create<DialogState>((set) => ({
|
||||
showPluginManager: false,
|
||||
showProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
|
||||
setShowPluginManager: (show) => set({ showPluginManager: show }),
|
||||
setShowProfiler: (show) => set({ showProfiler: show }),
|
||||
setShowPortManager: (show) => set({ showPortManager: show }),
|
||||
setShowSettings: (show) => set({ showSettings: show }),
|
||||
setShowAbout: (show) => set({ showAbout: show }),
|
||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
|
||||
closeAllDialogs: () => set({
|
||||
showPluginManager: false,
|
||||
showProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null
|
||||
})
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { EditorPluginManager } from '@esengine/editor-core';
|
||||
import { SceneInspectorPlugin } from '../../plugins/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from '../../plugins/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin';
|
||||
|
||||
export class PluginInstaller {
|
||||
async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise<void> {
|
||||
console.log('[PluginInstaller] Installing builtin plugins...');
|
||||
|
||||
const plugins = [
|
||||
new SceneInspectorPlugin(),
|
||||
new ProfilerPlugin(),
|
||||
new EditorAppearancePlugin()
|
||||
];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
await pluginManager.installEditor(plugin);
|
||||
console.log(`[PluginInstaller] Installed plugin: ${plugin.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to install plugin ${plugin.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[PluginInstaller] All builtin plugins installed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import {
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
SerializerRegistry,
|
||||
EntityStoreService,
|
||||
ComponentRegistry,
|
||||
ProjectService,
|
||||
ComponentDiscoveryService,
|
||||
PropertyMetadataService,
|
||||
LogService,
|
||||
SettingsRegistry,
|
||||
SceneManagerService,
|
||||
FileActionRegistry,
|
||||
EditorPluginManager,
|
||||
InspectorRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
import { TypedEventBus } from '../../core/events/TypedEventBus';
|
||||
import { CommandRegistry } from '../../core/commands/CommandRegistry';
|
||||
import { PanelRegistry } from '../../core/commands/PanelRegistry';
|
||||
import type { EditorEventMap } from '../../core/events/EditorEventMap';
|
||||
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
|
||||
import { TauriDialogService } from '../../services/TauriDialogService';
|
||||
import { NotificationService } from '../../services/NotificationService';
|
||||
|
||||
export interface EditorServices {
|
||||
uiRegistry: UIRegistry;
|
||||
messageHub: MessageHub;
|
||||
serializerRegistry: SerializerRegistry;
|
||||
entityStore: EntityStoreService;
|
||||
componentRegistry: ComponentRegistry;
|
||||
projectService: ProjectService;
|
||||
componentDiscovery: ComponentDiscoveryService;
|
||||
propertyMetadata: PropertyMetadataService;
|
||||
logService: LogService;
|
||||
settingsRegistry: SettingsRegistry;
|
||||
sceneManager: SceneManagerService;
|
||||
fileActionRegistry: FileActionRegistry;
|
||||
pluginManager: EditorPluginManager;
|
||||
diContainer: DIContainer;
|
||||
eventBus: TypedEventBus<EditorEventMap>;
|
||||
commandRegistry: CommandRegistry;
|
||||
panelRegistry: PanelRegistry;
|
||||
fileSystem: TauriFileSystemService;
|
||||
dialog: TauriDialogService;
|
||||
notification: NotificationService;
|
||||
inspectorRegistry: InspectorRegistry;
|
||||
}
|
||||
|
||||
export class ServiceRegistry {
|
||||
registerAllServices(coreInstance: Core): EditorServices {
|
||||
const fileAPI = new TauriFileAPI();
|
||||
|
||||
const uiRegistry = new UIRegistry();
|
||||
const messageHub = new MessageHub();
|
||||
const serializerRegistry = new SerializerRegistry();
|
||||
const entityStore = new EntityStoreService(messageHub);
|
||||
const componentRegistry = new ComponentRegistry();
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
const logService = new LogService();
|
||||
const settingsRegistry = new SettingsRegistry();
|
||||
const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService);
|
||||
const fileActionRegistry = new FileActionRegistry();
|
||||
|
||||
Core.services.registerInstance(UIRegistry, uiRegistry);
|
||||
Core.services.registerInstance(MessageHub, messageHub);
|
||||
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
|
||||
Core.services.registerInstance(EntityStoreService, entityStore);
|
||||
Core.services.registerInstance(ComponentRegistry, componentRegistry);
|
||||
Core.services.registerInstance(ProjectService, projectService);
|
||||
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
|
||||
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
|
||||
Core.services.registerInstance(LogService, logService);
|
||||
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
||||
Core.services.registerInstance(SceneManagerService, sceneManager);
|
||||
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
|
||||
|
||||
const pluginManager = new EditorPluginManager();
|
||||
pluginManager.initialize(coreInstance, Core.services);
|
||||
Core.services.registerInstance(EditorPluginManager, pluginManager);
|
||||
|
||||
const diContainer = new DIContainer();
|
||||
const eventBus = new TypedEventBus<EditorEventMap>();
|
||||
const commandRegistry = new CommandRegistry();
|
||||
const panelRegistry = new PanelRegistry();
|
||||
|
||||
const fileSystem = new TauriFileSystemService();
|
||||
const dialog = new TauriDialogService();
|
||||
const notification = new NotificationService();
|
||||
const inspectorRegistry = new InspectorRegistry();
|
||||
|
||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||
|
||||
return {
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
serializerRegistry,
|
||||
entityStore,
|
||||
componentRegistry,
|
||||
projectService,
|
||||
componentDiscovery,
|
||||
propertyMetadata,
|
||||
logService,
|
||||
settingsRegistry,
|
||||
sceneManager,
|
||||
fileActionRegistry,
|
||||
pluginManager,
|
||||
diContainer,
|
||||
eventBus,
|
||||
commandRegistry,
|
||||
panelRegistry,
|
||||
fileSystem,
|
||||
dialog,
|
||||
notification,
|
||||
inspectorRegistry
|
||||
};
|
||||
}
|
||||
|
||||
setupRemoteLogListener(logService: LogService): void {
|
||||
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
|
||||
const { level, message, timestamp, clientId } = event.detail;
|
||||
logService.addRemoteLog(level, message, timestamp, clientId);
|
||||
}) as EventListener);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './ServiceRegistry';
|
||||
export * from './DialogManager';
|
||||
export * from './PluginInstaller';
|
||||
@@ -1,17 +0,0 @@
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
|
||||
/**
|
||||
* 行为树状态接口
|
||||
* 命令通过此接口操作状态
|
||||
*/
|
||||
export interface ITreeState {
|
||||
/**
|
||||
* 获取当前行为树
|
||||
*/
|
||||
getTree(): BehaviorTree;
|
||||
|
||||
/**
|
||||
* 设置行为树
|
||||
*/
|
||||
setTree(tree: BehaviorTree): void;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
export type { ICommand } from './ICommand';
|
||||
export { BaseCommand } from './BaseCommand';
|
||||
export { CommandManager } from './CommandManager';
|
||||
export type { ITreeState } from './ITreeState';
|
||||
export * from './tree';
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Connection } from '../../../domain/models/Connection';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 添加连接命令
|
||||
*/
|
||||
export class AddConnectionCommand extends BaseCommand {
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly connection: Connection
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addConnection(this.connection);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.removeConnection(
|
||||
this.connection.from,
|
||||
this.connection.to,
|
||||
this.connection.fromProperty,
|
||||
this.connection.toProperty
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `添加连接: ${this.connection.from} -> ${this.connection.to}`;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Node } from '../../../domain/models/Node';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 创建节点命令
|
||||
*/
|
||||
export class CreateNodeCommand extends BaseCommand {
|
||||
private createdNodeId: string;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly node: Node
|
||||
) {
|
||||
super();
|
||||
this.createdNodeId = node.id;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addNode(this.node);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.removeNode(this.createdNodeId);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建节点: ${this.node.template.displayName}`;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Node } from '../../../domain/models/Node';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 删除节点命令
|
||||
*/
|
||||
export class DeleteNodeCommand extends BaseCommand {
|
||||
private deletedNode: Node | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly nodeId: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
this.deletedNode = tree.getNode(this.nodeId);
|
||||
const newTree = tree.removeNode(this.nodeId);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.deletedNode) {
|
||||
throw new Error('无法撤销:未保存已删除的节点');
|
||||
}
|
||||
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addNode(this.deletedNode);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `删除节点: ${this.deletedNode?.template.displayName ?? this.nodeId}`;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Position } from '../../../domain/value-objects/Position';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
/**
|
||||
* 移动节点命令
|
||||
* 支持合并连续的移动操作
|
||||
*/
|
||||
export class MoveNodeCommand extends BaseCommand {
|
||||
private oldPosition: Position;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly nodeId: string,
|
||||
private readonly newPosition: Position
|
||||
) {
|
||||
super();
|
||||
const tree = this.state.getTree();
|
||||
const node = tree.getNode(nodeId);
|
||||
this.oldPosition = node.position;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.moveToPosition(this.newPosition)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.moveToPosition(this.oldPosition)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `移动节点: ${this.nodeId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动命令可以合并
|
||||
*/
|
||||
canMergeWith(other: ICommand): boolean {
|
||||
if (!(other instanceof MoveNodeCommand)) {
|
||||
return false;
|
||||
}
|
||||
return this.nodeId === other.nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并移动命令
|
||||
* 保留初始位置,更新最终位置
|
||||
*/
|
||||
mergeWith(other: ICommand): ICommand {
|
||||
if (!(other instanceof MoveNodeCommand)) {
|
||||
throw new Error('只能与 MoveNodeCommand 合并');
|
||||
}
|
||||
|
||||
if (this.nodeId !== other.nodeId) {
|
||||
throw new Error('只能合并同一节点的移动命令');
|
||||
}
|
||||
|
||||
const merged = new MoveNodeCommand(
|
||||
this.state,
|
||||
this.nodeId,
|
||||
other.newPosition
|
||||
);
|
||||
merged.oldPosition = this.oldPosition;
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Connection } from '../../../domain/models/Connection';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 移除连接命令
|
||||
*/
|
||||
export class RemoveConnectionCommand extends BaseCommand {
|
||||
private removedConnection: Connection | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
private readonly fromProperty?: string,
|
||||
private readonly toProperty?: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
|
||||
const connection = tree.connections.find((c) =>
|
||||
c.matches(this.from, this.to, this.fromProperty, this.toProperty)
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
throw new Error(`连接不存在: ${this.from} -> ${this.to}`);
|
||||
}
|
||||
|
||||
this.removedConnection = connection;
|
||||
const newTree = tree.removeConnection(this.from, this.to, this.fromProperty, this.toProperty);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.removedConnection) {
|
||||
throw new Error('无法撤销:未保存已删除的连接');
|
||||
}
|
||||
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addConnection(this.removedConnection);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `移除连接: ${this.from} -> ${this.to}`;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 更新节点数据命令
|
||||
*/
|
||||
export class UpdateNodeDataCommand extends BaseCommand {
|
||||
private oldData: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly nodeId: string,
|
||||
private readonly newData: Record<string, unknown>
|
||||
) {
|
||||
super();
|
||||
const tree = this.state.getTree();
|
||||
const node = tree.getNode(nodeId);
|
||||
this.oldData = node.data;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.updateData(this.newData)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.updateData(this.oldData)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `更新节点数据: ${this.nodeId}`;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { CreateNodeCommand } from './CreateNodeCommand';
|
||||
export { DeleteNodeCommand } from './DeleteNodeCommand';
|
||||
export { AddConnectionCommand } from './AddConnectionCommand';
|
||||
export { RemoveConnectionCommand } from './RemoveConnectionCommand';
|
||||
export { MoveNodeCommand } from './MoveNodeCommand';
|
||||
export { UpdateNodeDataCommand } from './UpdateNodeDataCommand';
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
nodeId: null
|
||||
});
|
||||
|
||||
const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 不允许对Root节点右键
|
||||
if (node.id === ROOT_NODE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
nodeId: node.id
|
||||
});
|
||||
};
|
||||
|
||||
const handleCanvasContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
nodeId: null
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu({ ...contextMenu, visible: false });
|
||||
};
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
setContextMenu,
|
||||
handleNodeContextMenu,
|
||||
handleCanvasContextMenu,
|
||||
closeContextMenu
|
||||
};
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { useState, RefObject } from 'react';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { useNodeOperations } from '../../presentation/hooks/useNodeOperations';
|
||||
import { useConnectionOperations } from '../../presentation/hooks/useConnectionOperations';
|
||||
|
||||
interface QuickCreateMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchText: string;
|
||||
selectedIndex: number;
|
||||
mode: 'create' | 'replace';
|
||||
replaceNodeId: string | null;
|
||||
}
|
||||
|
||||
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
||||
|
||||
interface UseQuickCreateMenuParams {
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
connectingFrom: string | null;
|
||||
connectingFromProperty: string | null;
|
||||
clearConnecting: () => void;
|
||||
nodes: BehaviorTreeNode[];
|
||||
setNodes: (nodes: BehaviorTreeNode[]) => void;
|
||||
connections: Connection[];
|
||||
executionMode: ExecutionMode;
|
||||
onStop: () => void;
|
||||
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
|
||||
const {
|
||||
nodeOperations,
|
||||
connectionOperations,
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
connectingFrom,
|
||||
connectingFromProperty,
|
||||
clearConnecting,
|
||||
nodes,
|
||||
setNodes,
|
||||
connections,
|
||||
executionMode,
|
||||
onStop,
|
||||
onNodeCreate,
|
||||
showToast
|
||||
} = params;
|
||||
|
||||
const [quickCreateMenu, setQuickCreateMenu] = useState<QuickCreateMenuState>({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
|
||||
const handleReplaceNode = (newTemplate: NodeTemplate) => {
|
||||
const nodeToReplace = nodes.find((n) => n.id === quickCreateMenu.replaceNodeId);
|
||||
if (!nodeToReplace) return;
|
||||
|
||||
// 如果行为树正在执行,先停止
|
||||
if (executionMode !== 'idle') {
|
||||
onStop();
|
||||
}
|
||||
|
||||
// 合并数据:新模板的默认配置 + 保留旧节点中同名属性的值
|
||||
const newData = { ...newTemplate.defaultConfig };
|
||||
|
||||
// 获取新模板的属性名列表
|
||||
const newPropertyNames = new Set(newTemplate.properties.map((p) => p.name));
|
||||
|
||||
// 遍历旧节点的 data,保留新模板中也存在的属性
|
||||
for (const [key, value] of Object.entries(nodeToReplace.data)) {
|
||||
// 跳过节点类型相关的字段
|
||||
if (key === 'nodeType' || key === 'compositeType' || key === 'decoratorType' ||
|
||||
key === 'actionType' || key === 'conditionType') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果新模板也有这个属性,保留旧值(包括绑定信息)
|
||||
if (newPropertyNames.has(key)) {
|
||||
newData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新节点,保留原节点的位置和连接
|
||||
const newNode = new Node(
|
||||
nodeToReplace.id,
|
||||
newTemplate,
|
||||
newData,
|
||||
nodeToReplace.position,
|
||||
Array.from(nodeToReplace.children)
|
||||
);
|
||||
|
||||
// 替换节点
|
||||
setNodes(nodes.map((n) => n.id === newNode.id ? newNode : n));
|
||||
|
||||
// 删除所有指向该节点的属性连接,让用户重新连接
|
||||
const propertyConnections = connections.filter((conn) =>
|
||||
conn.connectionType === 'property' && conn.to === newNode.id
|
||||
);
|
||||
propertyConnections.forEach((conn) => {
|
||||
connectionOperations.removeConnection(
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
);
|
||||
});
|
||||
|
||||
// 关闭快速创建菜单
|
||||
closeQuickCreateMenu();
|
||||
|
||||
// 显示提示
|
||||
showToast?.(`已将节点替换为 ${newTemplate.displayName}`, 'success');
|
||||
};
|
||||
|
||||
const handleQuickCreateNode = (template: NodeTemplate) => {
|
||||
// 如果是替换模式,直接调用替换函数
|
||||
if (quickCreateMenu.mode === 'replace') {
|
||||
handleReplaceNode(template);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posX = (quickCreateMenu.position.x - rect.left - canvasOffset.x) / canvasScale;
|
||||
const posY = (quickCreateMenu.position.y - rect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
const newNode = nodeOperations.createNode(
|
||||
template,
|
||||
new Position(posX, posY),
|
||||
template.defaultConfig
|
||||
);
|
||||
|
||||
// 如果有连接源,创建连接
|
||||
if (connectingFrom) {
|
||||
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
|
||||
if (fromNode) {
|
||||
if (connectingFromProperty) {
|
||||
// 属性连接
|
||||
connectionOperations.addConnection(
|
||||
connectingFrom,
|
||||
newNode.id,
|
||||
'property',
|
||||
connectingFromProperty,
|
||||
undefined
|
||||
);
|
||||
} else {
|
||||
// 节点连接
|
||||
connectionOperations.addConnection(connectingFrom, newNode.id, 'node');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeQuickCreateMenu();
|
||||
|
||||
onNodeCreate?.(template, { x: posX, y: posY });
|
||||
};
|
||||
|
||||
const openQuickCreateMenu = (
|
||||
position: { x: number; y: number },
|
||||
mode: 'create' | 'replace',
|
||||
replaceNodeId?: string | null
|
||||
) => {
|
||||
setQuickCreateMenu({
|
||||
visible: true,
|
||||
position,
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode,
|
||||
replaceNodeId: replaceNodeId || null
|
||||
});
|
||||
};
|
||||
|
||||
const closeQuickCreateMenu = () => {
|
||||
setQuickCreateMenu({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
clearConnecting();
|
||||
};
|
||||
|
||||
return {
|
||||
quickCreateMenu,
|
||||
setQuickCreateMenu,
|
||||
handleQuickCreateNode,
|
||||
handleReplaceNode,
|
||||
openQuickCreateMenu,
|
||||
closeQuickCreateMenu
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './commands';
|
||||
export * from './use-cases';
|
||||
export * from './state';
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { BehaviorTreeNode } from '../../stores/behaviorTreeStore';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export interface INodeRenderer {
|
||||
canRender(node: BehaviorTreeNode): boolean;
|
||||
|
||||
render(node: BehaviorTreeNode, context: NodeRenderContext): React.ReactElement;
|
||||
}
|
||||
|
||||
export interface NodeRenderContext {
|
||||
isSelected: boolean;
|
||||
isExecuting: boolean;
|
||||
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNode) => void;
|
||||
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNode) => void;
|
||||
}
|
||||
|
||||
export interface IPropertyEditor {
|
||||
canEdit(propertyType: string): boolean;
|
||||
|
||||
render(property: PropertyEditorProps): React.ReactElement;
|
||||
}
|
||||
|
||||
export type PropertyValue = string | number | boolean | object | null | undefined;
|
||||
|
||||
export interface PropertyEditorProps<T = PropertyValue> {
|
||||
propertyName: string;
|
||||
propertyType: string;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
config?: Record<string, PropertyValue>;
|
||||
}
|
||||
|
||||
export interface INodeProvider {
|
||||
getNodeTemplates(): NodeTemplate[];
|
||||
|
||||
getCategory(): string;
|
||||
|
||||
getIcon(): string | LucideIcon;
|
||||
}
|
||||
|
||||
export interface IToolbarButton {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
tooltip?: string;
|
||||
onClick: () => void;
|
||||
isVisible?: () => boolean;
|
||||
isEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
export interface IPanelProvider {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
|
||||
render(): React.ReactElement;
|
||||
|
||||
canActivate?(): boolean;
|
||||
}
|
||||
|
||||
export interface IValidator {
|
||||
name: string;
|
||||
|
||||
validate(nodes: BehaviorTreeNode[]): ValidationResult[];
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
nodeId?: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ICommandProvider {
|
||||
getCommandId(): string;
|
||||
|
||||
getCommandName(): string;
|
||||
|
||||
getShortcut?(): string;
|
||||
|
||||
canExecute?(): boolean;
|
||||
|
||||
execute(context: CommandExecutionContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface CommandExecutionContext {
|
||||
selectedNodeIds: string[];
|
||||
nodes: BehaviorTreeNode[];
|
||||
currentFile?: string;
|
||||
}
|
||||
|
||||
export class EditorExtensionRegistry {
|
||||
private nodeRenderers: Set<INodeRenderer> = new Set();
|
||||
private propertyEditors: Set<IPropertyEditor> = new Set();
|
||||
private nodeProviders: Set<INodeProvider> = new Set();
|
||||
private toolbarButtons: Set<IToolbarButton> = new Set();
|
||||
private panelProviders: Set<IPanelProvider> = new Set();
|
||||
private validators: Set<IValidator> = new Set();
|
||||
private commandProviders: Set<ICommandProvider> = new Set();
|
||||
|
||||
registerNodeRenderer(renderer: INodeRenderer): void {
|
||||
this.nodeRenderers.add(renderer);
|
||||
}
|
||||
|
||||
unregisterNodeRenderer(renderer: INodeRenderer): void {
|
||||
this.nodeRenderers.delete(renderer);
|
||||
}
|
||||
|
||||
getNodeRenderer(node: BehaviorTreeNode): INodeRenderer | undefined {
|
||||
for (const renderer of this.nodeRenderers) {
|
||||
if (renderer.canRender(node)) {
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerPropertyEditor(editor: IPropertyEditor): void {
|
||||
this.propertyEditors.add(editor);
|
||||
}
|
||||
|
||||
unregisterPropertyEditor(editor: IPropertyEditor): void {
|
||||
this.propertyEditors.delete(editor);
|
||||
}
|
||||
|
||||
getPropertyEditor(propertyType: string): IPropertyEditor | undefined {
|
||||
for (const editor of this.propertyEditors) {
|
||||
if (editor.canEdit(propertyType)) {
|
||||
return editor;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerNodeProvider(provider: INodeProvider): void {
|
||||
this.nodeProviders.add(provider);
|
||||
}
|
||||
|
||||
unregisterNodeProvider(provider: INodeProvider): void {
|
||||
this.nodeProviders.delete(provider);
|
||||
}
|
||||
|
||||
getAllNodeTemplates(): NodeTemplate[] {
|
||||
const templates: NodeTemplate[] = [];
|
||||
this.nodeProviders.forEach((provider) => {
|
||||
templates.push(...provider.getNodeTemplates());
|
||||
});
|
||||
return templates;
|
||||
}
|
||||
|
||||
registerToolbarButton(button: IToolbarButton): void {
|
||||
this.toolbarButtons.add(button);
|
||||
}
|
||||
|
||||
unregisterToolbarButton(button: IToolbarButton): void {
|
||||
this.toolbarButtons.delete(button);
|
||||
}
|
||||
|
||||
getToolbarButtons(): IToolbarButton[] {
|
||||
return Array.from(this.toolbarButtons).filter((btn) => {
|
||||
return btn.isVisible ? btn.isVisible() : true;
|
||||
});
|
||||
}
|
||||
|
||||
registerPanelProvider(provider: IPanelProvider): void {
|
||||
this.panelProviders.add(provider);
|
||||
}
|
||||
|
||||
unregisterPanelProvider(provider: IPanelProvider): void {
|
||||
this.panelProviders.delete(provider);
|
||||
}
|
||||
|
||||
getPanelProviders(): IPanelProvider[] {
|
||||
return Array.from(this.panelProviders).filter((panel) => {
|
||||
return panel.canActivate ? panel.canActivate() : true;
|
||||
});
|
||||
}
|
||||
|
||||
registerValidator(validator: IValidator): void {
|
||||
this.validators.add(validator);
|
||||
}
|
||||
|
||||
unregisterValidator(validator: IValidator): void {
|
||||
this.validators.delete(validator);
|
||||
}
|
||||
|
||||
async validateTree(nodes: BehaviorTreeNode[]): Promise<ValidationResult[]> {
|
||||
const results: ValidationResult[] = [];
|
||||
for (const validator of this.validators) {
|
||||
try {
|
||||
const validationResults = validator.validate(nodes);
|
||||
results.push(...validationResults);
|
||||
} catch (error) {
|
||||
console.error(`Error in validator ${validator.name}:`, error);
|
||||
results.push({
|
||||
severity: 'error',
|
||||
message: `Validator ${validator.name} failed: ${error}`,
|
||||
code: 'VALIDATOR_ERROR'
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
registerCommandProvider(provider: ICommandProvider): void {
|
||||
this.commandProviders.add(provider);
|
||||
}
|
||||
|
||||
unregisterCommandProvider(provider: ICommandProvider): void {
|
||||
this.commandProviders.delete(provider);
|
||||
}
|
||||
|
||||
getCommandProvider(commandId: string): ICommandProvider | undefined {
|
||||
for (const provider of this.commandProviders) {
|
||||
if (provider.getCommandId() === commandId) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAllCommandProviders(): ICommandProvider[] {
|
||||
return Array.from(this.commandProviders);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.nodeRenderers.clear();
|
||||
this.propertyEditors.clear();
|
||||
this.nodeProviders.clear();
|
||||
this.toolbarButtons.clear();
|
||||
this.panelProviders.clear();
|
||||
this.validators.clear();
|
||||
this.commandProviders.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let globalExtensionRegistry: EditorExtensionRegistry | null = null;
|
||||
|
||||
export function getGlobalExtensionRegistry(): EditorExtensionRegistry {
|
||||
if (!globalExtensionRegistry) {
|
||||
globalExtensionRegistry = new EditorExtensionRegistry();
|
||||
}
|
||||
return globalExtensionRegistry;
|
||||
}
|
||||
|
||||
export function resetGlobalExtensionRegistry(): void {
|
||||
globalExtensionRegistry = null;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
|
||||
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
||||
|
||||
export interface ExecutionContext {
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
blackboardVariables: BlackboardVariables;
|
||||
rootNodeId: string;
|
||||
tickCount: number;
|
||||
}
|
||||
|
||||
export interface NodeStatusChangeEvent {
|
||||
nodeId: string;
|
||||
status: NodeExecutionStatus;
|
||||
previousStatus?: NodeExecutionStatus;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IExecutionHooks {
|
||||
beforePlay?(context: ExecutionContext): void | Promise<void>;
|
||||
|
||||
afterPlay?(context: ExecutionContext): void | Promise<void>;
|
||||
|
||||
beforePause?(): void | Promise<void>;
|
||||
|
||||
afterPause?(): void | Promise<void>;
|
||||
|
||||
beforeResume?(): void | Promise<void>;
|
||||
|
||||
afterResume?(): void | Promise<void>;
|
||||
|
||||
beforeStop?(): void | Promise<void>;
|
||||
|
||||
afterStop?(): void | Promise<void>;
|
||||
|
||||
beforeStep?(deltaTime: number): void | Promise<void>;
|
||||
|
||||
afterStep?(deltaTime: number): void | Promise<void>;
|
||||
|
||||
onTick?(tickCount: number, deltaTime: number): void | Promise<void>;
|
||||
|
||||
onNodeStatusChange?(event: NodeStatusChangeEvent): void | Promise<void>;
|
||||
|
||||
onExecutionComplete?(logs: ExecutionLog[]): void | Promise<void>;
|
||||
|
||||
onBlackboardUpdate?(variables: BlackboardVariables): void | Promise<void>;
|
||||
|
||||
onError?(error: Error, context?: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
export class ExecutionHooksManager {
|
||||
private hooks: Set<IExecutionHooks> = new Set();
|
||||
|
||||
register(hook: IExecutionHooks): void {
|
||||
this.hooks.add(hook);
|
||||
}
|
||||
|
||||
unregister(hook: IExecutionHooks): void {
|
||||
this.hooks.delete(hook);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.hooks.clear();
|
||||
}
|
||||
|
||||
async triggerBeforePlay(context: ExecutionContext): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforePlay) {
|
||||
try {
|
||||
await hook.beforePlay(context);
|
||||
} catch (error) {
|
||||
console.error('Error in beforePlay hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterPlay(context: ExecutionContext): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterPlay) {
|
||||
try {
|
||||
await hook.afterPlay(context);
|
||||
} catch (error) {
|
||||
console.error('Error in afterPlay hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforePause(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforePause) {
|
||||
try {
|
||||
await hook.beforePause();
|
||||
} catch (error) {
|
||||
console.error('Error in beforePause hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterPause(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterPause) {
|
||||
try {
|
||||
await hook.afterPause();
|
||||
} catch (error) {
|
||||
console.error('Error in afterPause hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforeResume(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforeResume) {
|
||||
try {
|
||||
await hook.beforeResume();
|
||||
} catch (error) {
|
||||
console.error('Error in beforeResume hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterResume(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterResume) {
|
||||
try {
|
||||
await hook.afterResume();
|
||||
} catch (error) {
|
||||
console.error('Error in afterResume hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforeStop(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforeStop) {
|
||||
try {
|
||||
await hook.beforeStop();
|
||||
} catch (error) {
|
||||
console.error('Error in beforeStop hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterStop(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterStop) {
|
||||
try {
|
||||
await hook.afterStop();
|
||||
} catch (error) {
|
||||
console.error('Error in afterStop hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforeStep(deltaTime: number): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforeStep) {
|
||||
try {
|
||||
await hook.beforeStep(deltaTime);
|
||||
} catch (error) {
|
||||
console.error('Error in beforeStep hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterStep(deltaTime: number): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterStep) {
|
||||
try {
|
||||
await hook.afterStep(deltaTime);
|
||||
} catch (error) {
|
||||
console.error('Error in afterStep hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnTick(tickCount: number, deltaTime: number): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onTick) {
|
||||
try {
|
||||
await hook.onTick(tickCount, deltaTime);
|
||||
} catch (error) {
|
||||
console.error('Error in onTick hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnNodeStatusChange(event: NodeStatusChangeEvent): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onNodeStatusChange) {
|
||||
try {
|
||||
await hook.onNodeStatusChange(event);
|
||||
} catch (error) {
|
||||
console.error('Error in onNodeStatusChange hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnExecutionComplete(logs: ExecutionLog[]): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onExecutionComplete) {
|
||||
try {
|
||||
await hook.onExecutionComplete(logs);
|
||||
} catch (error) {
|
||||
console.error('Error in onExecutionComplete hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnBlackboardUpdate(variables: BlackboardVariables): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onBlackboardUpdate) {
|
||||
try {
|
||||
await hook.onBlackboardUpdate(variables);
|
||||
} catch (error) {
|
||||
console.error('Error in onBlackboardUpdate hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnError(error: Error, context?: string): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onError) {
|
||||
try {
|
||||
await hook.onError(error, context);
|
||||
} catch (err) {
|
||||
console.error('Error in onError hook:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
export class BlackboardManager {
|
||||
private initialVariables: BlackboardVariables = {};
|
||||
private currentVariables: BlackboardVariables = {};
|
||||
|
||||
setInitialVariables(variables: BlackboardVariables): void {
|
||||
this.initialVariables = JSON.parse(JSON.stringify(variables)) as BlackboardVariables;
|
||||
}
|
||||
|
||||
getInitialVariables(): BlackboardVariables {
|
||||
return { ...this.initialVariables };
|
||||
}
|
||||
|
||||
setCurrentVariables(variables: BlackboardVariables): void {
|
||||
this.currentVariables = { ...variables };
|
||||
}
|
||||
|
||||
getCurrentVariables(): BlackboardVariables {
|
||||
return { ...this.currentVariables };
|
||||
}
|
||||
|
||||
updateVariable(key: string, value: BlackboardValue): void {
|
||||
this.currentVariables[key] = value;
|
||||
}
|
||||
|
||||
restoreInitialVariables(): BlackboardVariables {
|
||||
this.currentVariables = { ...this.initialVariables };
|
||||
return this.getInitialVariables();
|
||||
}
|
||||
|
||||
hasChanges(): boolean {
|
||||
return JSON.stringify(this.currentVariables) !== JSON.stringify(this.initialVariables);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.initialVariables = {};
|
||||
this.currentVariables = {};
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||
import { BehaviorTreeNode, Connection, NodeExecutionStatus } from '../../stores/behaviorTreeStore';
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
import { DOMCache } from '../../presentation/utils/DOMCache';
|
||||
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
|
||||
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
|
||||
|
||||
export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
interface ExecutionControllerConfig {
|
||||
rootNodeId: string;
|
||||
projectPath: string | null;
|
||||
onLogsUpdate: (logs: ExecutionLog[]) => void;
|
||||
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
||||
onTickCountUpdate: (count: number) => void;
|
||||
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
|
||||
eventBus?: EditorEventBus;
|
||||
hooksManager?: ExecutionHooksManager;
|
||||
}
|
||||
|
||||
export class ExecutionController {
|
||||
private executor: BehaviorTreeExecutor | null = null;
|
||||
private mode: ExecutionMode = 'idle';
|
||||
private animationFrameId: number | null = null;
|
||||
private lastTickTime: number = 0;
|
||||
private speed: number = 1.0;
|
||||
private tickCount: number = 0;
|
||||
|
||||
private domCache: DOMCache = new DOMCache();
|
||||
private eventBus?: EditorEventBus;
|
||||
private hooksManager?: ExecutionHooksManager;
|
||||
|
||||
private config: ExecutionControllerConfig;
|
||||
private currentNodes: BehaviorTreeNode[] = [];
|
||||
private currentConnections: Connection[] = [];
|
||||
private currentBlackboard: BlackboardVariables = {};
|
||||
|
||||
private stepByStepMode: boolean = true;
|
||||
private pendingStatusUpdates: ExecutionStatus[] = [];
|
||||
private currentlyDisplayedIndex: number = 0;
|
||||
private lastStepTime: number = 0;
|
||||
private stepInterval: number = 200;
|
||||
|
||||
constructor(config: ExecutionControllerConfig) {
|
||||
this.config = config;
|
||||
this.executor = new BehaviorTreeExecutor();
|
||||
this.eventBus = config.eventBus;
|
||||
this.hooksManager = config.hooksManager;
|
||||
}
|
||||
|
||||
getMode(): ExecutionMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
getTickCount(): number {
|
||||
return this.tickCount;
|
||||
}
|
||||
|
||||
getSpeed(): number {
|
||||
return this.speed;
|
||||
}
|
||||
|
||||
setSpeed(speed: number): void {
|
||||
this.speed = speed;
|
||||
this.lastTickTime = 0;
|
||||
}
|
||||
|
||||
async play(
|
||||
nodes: BehaviorTreeNode[],
|
||||
blackboardVariables: BlackboardVariables,
|
||||
connections: Connection[]
|
||||
): Promise<void> {
|
||||
if (this.mode === 'running') return;
|
||||
|
||||
this.currentNodes = nodes;
|
||||
this.currentConnections = connections;
|
||||
this.currentBlackboard = blackboardVariables;
|
||||
|
||||
const context = {
|
||||
nodes,
|
||||
connections,
|
||||
blackboardVariables,
|
||||
rootNodeId: this.config.rootNodeId,
|
||||
tickCount: 0
|
||||
};
|
||||
|
||||
try {
|
||||
await this.hooksManager?.triggerBeforePlay(context);
|
||||
|
||||
this.mode = 'running';
|
||||
this.tickCount = 0;
|
||||
this.lastTickTime = 0;
|
||||
|
||||
if (!this.executor) {
|
||||
this.executor = new BehaviorTreeExecutor();
|
||||
}
|
||||
|
||||
this.executor.buildTree(
|
||||
nodes,
|
||||
this.config.rootNodeId,
|
||||
blackboardVariables,
|
||||
connections,
|
||||
this.handleExecutionStatusUpdate.bind(this)
|
||||
);
|
||||
|
||||
this.executor.start();
|
||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_STARTED, context);
|
||||
await this.hooksManager?.triggerAfterPlay(context);
|
||||
} catch (error) {
|
||||
console.error('Error in play:', error);
|
||||
await this.hooksManager?.triggerOnError(error as Error, 'play');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
try {
|
||||
if (this.mode === 'running') {
|
||||
await this.hooksManager?.triggerBeforePause();
|
||||
|
||||
this.mode = 'paused';
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.pause();
|
||||
}
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_PAUSED);
|
||||
await this.hooksManager?.triggerAfterPause();
|
||||
} else if (this.mode === 'paused') {
|
||||
await this.hooksManager?.triggerBeforeResume();
|
||||
|
||||
this.mode = 'running';
|
||||
this.lastTickTime = 0;
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.resume();
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_RESUMED);
|
||||
await this.hooksManager?.triggerAfterResume();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in pause/resume:', error);
|
||||
await this.hooksManager?.triggerOnError(error as Error, 'pause');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
await this.hooksManager?.triggerBeforeStop();
|
||||
|
||||
this.mode = 'idle';
|
||||
this.tickCount = 0;
|
||||
this.lastTickTime = 0;
|
||||
this.lastStepTime = 0;
|
||||
this.pendingStatusUpdates = [];
|
||||
this.currentlyDisplayedIndex = 0;
|
||||
|
||||
this.domCache.clearAllStatusTimers();
|
||||
this.domCache.clearStatusCache();
|
||||
|
||||
this.config.onExecutionStatusUpdate(new Map(), new Map());
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.stop();
|
||||
}
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_STOPPED);
|
||||
await this.hooksManager?.triggerAfterStop();
|
||||
} catch (error) {
|
||||
console.error('Error in stop:', error);
|
||||
await this.hooksManager?.triggerOnError(error as Error, 'stop');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this.stop();
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
step(): void {
|
||||
// 单步执行功能预留
|
||||
}
|
||||
|
||||
updateBlackboardVariable(key: string, value: BlackboardValue): void {
|
||||
if (this.executor && this.mode !== 'idle') {
|
||||
this.executor.updateBlackboardVariable(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
getBlackboardVariables(): BlackboardVariables {
|
||||
if (this.executor) {
|
||||
return this.executor.getBlackboardVariables();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
updateNodes(nodes: BehaviorTreeNode[]): void {
|
||||
if (this.mode === 'idle' || !this.executor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentNodes = nodes;
|
||||
|
||||
this.executor.buildTree(
|
||||
nodes,
|
||||
this.config.rootNodeId,
|
||||
this.currentBlackboard,
|
||||
this.currentConnections,
|
||||
this.handleExecutionStatusUpdate.bind(this)
|
||||
);
|
||||
|
||||
this.executor.start();
|
||||
}
|
||||
|
||||
clearDOMCache(): void {
|
||||
this.domCache.clearAll();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.destroy();
|
||||
this.executor = null;
|
||||
}
|
||||
}
|
||||
|
||||
private tickLoop(currentTime: number): void {
|
||||
if (this.mode !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.executor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stepByStepMode) {
|
||||
this.handleStepByStepExecution(currentTime);
|
||||
} else {
|
||||
this.handleNormalExecution(currentTime);
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||
}
|
||||
|
||||
private handleNormalExecution(currentTime: number): void {
|
||||
const baseTickInterval = 16.67;
|
||||
const scaledTickInterval = baseTickInterval / this.speed;
|
||||
|
||||
if (this.lastTickTime === 0) {
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
|
||||
const elapsed = currentTime - this.lastTickTime;
|
||||
|
||||
if (elapsed >= scaledTickInterval) {
|
||||
const deltaTime = baseTickInterval / 1000;
|
||||
|
||||
this.executor!.tick(deltaTime);
|
||||
|
||||
this.tickCount = this.executor!.getTickCount();
|
||||
this.config.onTickCountUpdate(this.tickCount);
|
||||
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
private handleStepByStepExecution(currentTime: number): void {
|
||||
if (this.lastStepTime === 0) {
|
||||
this.lastStepTime = currentTime;
|
||||
}
|
||||
|
||||
const stepElapsed = currentTime - this.lastStepTime;
|
||||
const actualStepInterval = this.stepInterval / this.speed;
|
||||
|
||||
if (stepElapsed >= actualStepInterval) {
|
||||
if (this.currentlyDisplayedIndex < this.pendingStatusUpdates.length) {
|
||||
this.displayNextNode();
|
||||
this.lastStepTime = currentTime;
|
||||
} else {
|
||||
if (this.lastTickTime === 0) {
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
|
||||
const tickElapsed = currentTime - this.lastTickTime;
|
||||
const baseTickInterval = 16.67;
|
||||
const scaledTickInterval = baseTickInterval / this.speed;
|
||||
|
||||
if (tickElapsed >= scaledTickInterval) {
|
||||
const deltaTime = baseTickInterval / 1000;
|
||||
this.executor!.tick(deltaTime);
|
||||
this.tickCount = this.executor!.getTickCount();
|
||||
this.config.onTickCountUpdate(this.tickCount);
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private displayNextNode(): void {
|
||||
if (this.currentlyDisplayedIndex >= this.pendingStatusUpdates.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusesToDisplay = this.pendingStatusUpdates.slice(0, this.currentlyDisplayedIndex + 1);
|
||||
const currentNode = this.pendingStatusUpdates[this.currentlyDisplayedIndex];
|
||||
|
||||
if (!currentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMap = new Map<string, NodeExecutionStatus>();
|
||||
const orderMap = new Map<string, number>();
|
||||
|
||||
statusesToDisplay.forEach((s) => {
|
||||
statusMap.set(s.nodeId, s.status);
|
||||
if (s.executionOrder !== undefined) {
|
||||
orderMap.set(s.nodeId, s.executionOrder);
|
||||
}
|
||||
});
|
||||
|
||||
const nodeName = this.currentNodes.find(n => n.id === currentNode.nodeId)?.template.displayName || 'Unknown';
|
||||
console.log(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
|
||||
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
||||
|
||||
this.currentlyDisplayedIndex++;
|
||||
}
|
||||
|
||||
private handleExecutionStatusUpdate(
|
||||
statuses: ExecutionStatus[],
|
||||
logs: ExecutionLog[],
|
||||
runtimeBlackboardVars?: BlackboardVariables
|
||||
): void {
|
||||
this.config.onLogsUpdate([...logs]);
|
||||
|
||||
if (runtimeBlackboardVars) {
|
||||
this.config.onBlackboardUpdate(runtimeBlackboardVars);
|
||||
}
|
||||
|
||||
if (this.stepByStepMode) {
|
||||
const statusesWithOrder = statuses.filter(s => s.executionOrder !== undefined);
|
||||
|
||||
if (statusesWithOrder.length > 0) {
|
||||
const minOrder = Math.min(...statusesWithOrder.map(s => s.executionOrder!));
|
||||
|
||||
if (minOrder === 1 || this.pendingStatusUpdates.length === 0) {
|
||||
this.pendingStatusUpdates = statusesWithOrder.sort((a, b) =>
|
||||
(a.executionOrder || 0) - (b.executionOrder || 0)
|
||||
);
|
||||
this.currentlyDisplayedIndex = 0;
|
||||
this.lastStepTime = 0;
|
||||
} else {
|
||||
const maxExistingOrder = this.pendingStatusUpdates.length > 0
|
||||
? Math.max(...this.pendingStatusUpdates.map(s => s.executionOrder || 0))
|
||||
: 0;
|
||||
|
||||
const newStatuses = statusesWithOrder.filter(s =>
|
||||
(s.executionOrder || 0) > maxExistingOrder
|
||||
);
|
||||
|
||||
if (newStatuses.length > 0) {
|
||||
console.log(`[StepByStep] Appending ${newStatuses.length} new nodes, orders:`, newStatuses.map(s => s.executionOrder));
|
||||
this.pendingStatusUpdates = [
|
||||
...this.pendingStatusUpdates,
|
||||
...newStatuses
|
||||
].sort((a, b) => (a.executionOrder || 0) - (b.executionOrder || 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const statusMap = new Map<string, NodeExecutionStatus>();
|
||||
const orderMap = new Map<string, number>();
|
||||
|
||||
statuses.forEach((s) => {
|
||||
statusMap.set(s.nodeId, s.status);
|
||||
if (s.executionOrder !== undefined) {
|
||||
orderMap.set(s.nodeId, s.executionOrder);
|
||||
}
|
||||
});
|
||||
|
||||
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionStyles(
|
||||
statusMap: Record<string, NodeExecutionStatus>,
|
||||
connections?: Connection[]
|
||||
): void {
|
||||
if (!connections) return;
|
||||
|
||||
connections.forEach((conn) => {
|
||||
const connKey = `${conn.from}-${conn.to}`;
|
||||
|
||||
const pathElement = this.domCache.getConnection(connKey);
|
||||
if (!pathElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromStatus = statusMap[conn.from];
|
||||
const toStatus = statusMap[conn.to];
|
||||
const isActive = fromStatus === 'running' || toStatus === 'running';
|
||||
|
||||
if (conn.connectionType === 'property') {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#9c27b0');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
|
||||
} else if (isActive) {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#ffa726');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '3');
|
||||
} else {
|
||||
const isExecuted = this.domCache.hasNodeClass(conn.from, 'executed') &&
|
||||
this.domCache.hasNodeClass(conn.to, 'executed');
|
||||
|
||||
if (isExecuted) {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#4caf50');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2.5');
|
||||
} else {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#0e639c');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConnections(connections: Connection[]): void {
|
||||
if (this.mode !== 'idle') {
|
||||
const currentStatuses: Record<string, NodeExecutionStatus> = {};
|
||||
connections.forEach((conn) => {
|
||||
const fromStatus = this.domCache.getLastStatus(conn.from);
|
||||
const toStatus = this.domCache.getLastStatus(conn.to);
|
||||
if (fromStatus) currentStatuses[conn.from] = fromStatus;
|
||||
if (toStatus) currentStatuses[conn.to] = toStatus;
|
||||
});
|
||||
this.updateConnectionStyles(currentStatuses, connections);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
import { useBehaviorTreeStore } from '../../stores/behaviorTreeStore';
|
||||
import { Blackboard } from '../../domain/models/Blackboard';
|
||||
import { createRootNode, ROOT_NODE_ID } from '../../domain/constants/RootNode';
|
||||
|
||||
const createInitialTree = (): BehaviorTree => {
|
||||
const rootNode = createRootNode();
|
||||
return new BehaviorTree([rootNode], [], Blackboard.empty(), ROOT_NODE_ID);
|
||||
};
|
||||
|
||||
/**
|
||||
* 行为树数据状态
|
||||
* 管理核心业务数据
|
||||
*/
|
||||
interface BehaviorTreeDataState {
|
||||
/**
|
||||
* 当前行为树
|
||||
*/
|
||||
tree: BehaviorTree;
|
||||
|
||||
/**
|
||||
* 设置行为树
|
||||
*/
|
||||
setTree: (tree: BehaviorTree) => void;
|
||||
|
||||
/**
|
||||
* 重置为空树
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树数据 Store
|
||||
* 实现 ITreeState 接口,供命令使用
|
||||
*/
|
||||
export const useBehaviorTreeDataStore = create<BehaviorTreeDataState>((set) => ({
|
||||
tree: createInitialTree(),
|
||||
|
||||
setTree: (tree: BehaviorTree) => set({ tree }),
|
||||
|
||||
reset: () => set({ tree: createInitialTree() })
|
||||
}));
|
||||
|
||||
/**
|
||||
* TreeState 适配器
|
||||
* 将 Zustand Store 适配为 ITreeState 接口
|
||||
* 同步更新领域层和表现层的状态
|
||||
*/
|
||||
export class TreeStateAdapter implements ITreeState {
|
||||
getTree(): BehaviorTree {
|
||||
return useBehaviorTreeDataStore.getState().tree;
|
||||
}
|
||||
|
||||
setTree(tree: BehaviorTree): void {
|
||||
useBehaviorTreeDataStore.getState().setTree(tree);
|
||||
|
||||
const nodes = Array.from(tree.nodes);
|
||||
const connections = Array.from(tree.connections);
|
||||
|
||||
useBehaviorTreeStore.getState().setNodes(nodes);
|
||||
useBehaviorTreeStore.getState().setConnections(connections);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useBehaviorTreeDataStore, TreeStateAdapter } from './BehaviorTreeDataStore';
|
||||
export { useUIStore } from './UIStore';
|
||||
export { useEditorStore } from './EditorStore';
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Connection, ConnectionType } from '../../domain/models/Connection';
|
||||
import { CommandManager } from '../commands/CommandManager';
|
||||
import { AddConnectionCommand } from '../commands/tree/AddConnectionCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
import { IValidator } from '../../domain/interfaces/IValidator';
|
||||
|
||||
/**
|
||||
* 添加连接用例
|
||||
*/
|
||||
export class AddConnectionUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState,
|
||||
private readonly validator: IValidator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行添加连接操作
|
||||
*/
|
||||
execute(
|
||||
from: string,
|
||||
to: string,
|
||||
connectionType: ConnectionType = 'node',
|
||||
fromProperty?: string,
|
||||
toProperty?: string
|
||||
): Connection {
|
||||
const connection = new Connection(from, to, connectionType, fromProperty, toProperty);
|
||||
|
||||
const tree = this.treeState.getTree();
|
||||
const validationResult = this.validator.validateConnection(connection, tree);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
const errorMessages = validationResult.errors.map((e) => e.message).join(', ');
|
||||
throw new Error(`连接验证失败: ${errorMessages}`);
|
||||
}
|
||||
|
||||
const command = new AddConnectionCommand(this.treeState, connection);
|
||||
this.commandManager.execute(command);
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
||||
import { CommandManager } from '../commands/CommandManager';
|
||||
import { CreateNodeCommand } from '../commands/tree/CreateNodeCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 创建节点用例
|
||||
*/
|
||||
export class CreateNodeUseCase {
|
||||
constructor(
|
||||
private readonly nodeFactory: INodeFactory,
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行创建节点操作
|
||||
*/
|
||||
execute(template: NodeTemplate, position: Position, data?: Record<string, unknown>): Node {
|
||||
const node = this.nodeFactory.createNode(template, position, data);
|
||||
|
||||
const command = new CreateNodeCommand(this.treeState, node);
|
||||
this.commandManager.execute(command);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型创建节点
|
||||
*/
|
||||
executeByType(nodeType: string, position: Position, data?: Record<string, unknown>): Node {
|
||||
const node = this.nodeFactory.createNodeByType(nodeType, position, data);
|
||||
|
||||
const command = new CreateNodeCommand(this.treeState, node);
|
||||
this.commandManager.execute(command);
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { CommandManager } from '../commands/CommandManager';
|
||||
import { DeleteNodeCommand } from '../commands/tree/DeleteNodeCommand';
|
||||
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
import { ICommand } from '../commands/ICommand';
|
||||
|
||||
/**
|
||||
* 删除节点用例
|
||||
* 删除节点时会自动删除相关连接
|
||||
*/
|
||||
export class DeleteNodeUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 删除单个节点
|
||||
*/
|
||||
execute(nodeId: string): void {
|
||||
const tree = this.treeState.getTree();
|
||||
|
||||
const relatedConnections = tree.connections.filter(
|
||||
(conn) => conn.from === nodeId || conn.to === nodeId
|
||||
);
|
||||
|
||||
const commands: ICommand[] = [];
|
||||
|
||||
relatedConnections.forEach((conn) => {
|
||||
commands.push(
|
||||
new RemoveConnectionCommand(
|
||||
this.treeState,
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
|
||||
|
||||
this.commandManager.executeBatch(commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除节点
|
||||
*/
|
||||
executeBatch(nodeIds: string[]): void {
|
||||
const tree = this.treeState.getTree();
|
||||
const commands: ICommand[] = [];
|
||||
|
||||
const nodeIdSet = new Set(nodeIds);
|
||||
|
||||
const relatedConnections = tree.connections.filter(
|
||||
(conn) => nodeIdSet.has(conn.from) || nodeIdSet.has(conn.to)
|
||||
);
|
||||
|
||||
relatedConnections.forEach((conn) => {
|
||||
commands.push(
|
||||
new RemoveConnectionCommand(
|
||||
this.treeState,
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
|
||||
});
|
||||
|
||||
this.commandManager.executeBatch(commands);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { CommandManager } from '../commands/CommandManager';
|
||||
import { MoveNodeCommand } from '../commands/tree/MoveNodeCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 移动节点用例
|
||||
*/
|
||||
export class MoveNodeUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 移动单个节点
|
||||
*/
|
||||
execute(nodeId: string, newPosition: Position): void {
|
||||
const command = new MoveNodeCommand(this.treeState, nodeId, newPosition);
|
||||
this.commandManager.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移动节点
|
||||
*/
|
||||
executeBatch(moves: Array<{ nodeId: string; position: Position }>): void {
|
||||
const commands = moves.map(
|
||||
({ nodeId, position }) => new MoveNodeCommand(this.treeState, nodeId, position)
|
||||
);
|
||||
this.commandManager.executeBatch(commands);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { CommandManager } from '../commands/CommandManager';
|
||||
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 移除连接用例
|
||||
*/
|
||||
export class RemoveConnectionUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行移除连接操作
|
||||
*/
|
||||
execute(from: string, to: string, fromProperty?: string, toProperty?: string): void {
|
||||
const command = new RemoveConnectionCommand(
|
||||
this.treeState,
|
||||
from,
|
||||
to,
|
||||
fromProperty,
|
||||
toProperty
|
||||
);
|
||||
this.commandManager.execute(command);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { CommandManager } from '../commands/CommandManager';
|
||||
import { UpdateNodeDataCommand } from '../commands/tree/UpdateNodeDataCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 更新节点数据用例
|
||||
*/
|
||||
export class UpdateNodeDataUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 更新节点数据
|
||||
*/
|
||||
execute(nodeId: string, data: Record<string, unknown>): void {
|
||||
const command = new UpdateNodeDataCommand(this.treeState, nodeId, data);
|
||||
this.commandManager.execute(command);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { IValidator, ValidationResult } from '../../domain/interfaces/IValidator';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 验证行为树用例
|
||||
*/
|
||||
export class ValidateTreeUseCase {
|
||||
constructor(
|
||||
private readonly validator: IValidator,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 验证当前行为树
|
||||
*/
|
||||
execute(): ValidationResult {
|
||||
const tree = this.treeState.getTree();
|
||||
return this.validator.validateTree(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并抛出错误(如果验证失败)
|
||||
*/
|
||||
executeAndThrow(): void {
|
||||
const result = this.execute();
|
||||
|
||||
if (!result.isValid) {
|
||||
const errorMessages = result.errors.map((e) => e.message).join('\n');
|
||||
throw new Error(`行为树验证失败:\n${errorMessages}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { CreateNodeUseCase } from './CreateNodeUseCase';
|
||||
export { DeleteNodeUseCase } from './DeleteNodeUseCase';
|
||||
export { AddConnectionUseCase } from './AddConnectionUseCase';
|
||||
export { RemoveConnectionUseCase } from './RemoveConnectionUseCase';
|
||||
export { MoveNodeUseCase } from './MoveNodeUseCase';
|
||||
export { UpdateNodeDataUseCase } from './UpdateNodeDataUseCase';
|
||||
export { ValidateTreeUseCase } from './ValidateTreeUseCase';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp } from 'lucide-react';
|
||||
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
@@ -43,6 +43,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
position: { x: number; y: number };
|
||||
asset: AssetItem;
|
||||
} | null>(null);
|
||||
const [renameDialog, setRenameDialog] = useState<{
|
||||
asset: AssetItem;
|
||||
newName: string;
|
||||
} | null>(null);
|
||||
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<AssetItem | null>(null);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
@@ -251,6 +256,61 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (asset: AssetItem, newName: string) => {
|
||||
if (!newName.trim() || newName === asset.name) {
|
||||
setRenameDialog(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\'));
|
||||
const parentPath = asset.path.substring(0, lastSlash);
|
||||
const newPath = `${parentPath}/${newName}`;
|
||||
|
||||
await TauriAPI.renameFileOrFolder(asset.path, newPath);
|
||||
|
||||
// 刷新当前目录
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
// 更新选中路径
|
||||
if (selectedPath === asset.path) {
|
||||
setSelectedPath(newPath);
|
||||
}
|
||||
|
||||
setRenameDialog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to rename:', error);
|
||||
alert(`重命名失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (asset: AssetItem) => {
|
||||
try {
|
||||
if (asset.type === 'folder') {
|
||||
await TauriAPI.deleteFolder(asset.path);
|
||||
} else {
|
||||
await TauriAPI.deleteFile(asset.path);
|
||||
}
|
||||
|
||||
// 刷新当前目录
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
// 清除选中状态
|
||||
if (selectedPath === asset.path) {
|
||||
setSelectedPath(null);
|
||||
}
|
||||
|
||||
setDeleteConfirmDialog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error);
|
||||
alert(`删除失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
@@ -262,7 +322,6 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
// 打开
|
||||
if (asset.type === 'file') {
|
||||
items.push({
|
||||
label: locale === 'zh' ? '打开' : 'Open',
|
||||
@@ -292,6 +351,28 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
}
|
||||
|
||||
if (asset.type === 'folder' && fileActionRegistry) {
|
||||
const templates = fileActionRegistry.getCreationTemplates();
|
||||
if (templates.length > 0) {
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
for (const template of templates) {
|
||||
items.push({
|
||||
label: `${locale === 'zh' ? '新建' : 'New'} ${template.label}`,
|
||||
icon: template.icon,
|
||||
onClick: async () => {
|
||||
const fileName = `${template.defaultFileName}.${template.extension}`;
|
||||
const filePath = `${asset.path}/${fileName}`;
|
||||
const content = await template.createContent(fileName);
|
||||
await TauriAPI.writeFileContent(filePath, content);
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在文件管理器中显示
|
||||
items.push({
|
||||
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
|
||||
@@ -323,10 +404,13 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现重命名功能
|
||||
console.log('Rename:', asset.path);
|
||||
setRenameDialog({
|
||||
asset,
|
||||
newName: asset.name
|
||||
});
|
||||
setContextMenu(null);
|
||||
},
|
||||
disabled: true
|
||||
disabled: false
|
||||
});
|
||||
|
||||
// 删除
|
||||
@@ -334,10 +418,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现删除功能
|
||||
console.log('Delete:', asset.path);
|
||||
setDeleteConfirmDialog(asset);
|
||||
setContextMenu(null);
|
||||
},
|
||||
disabled: true
|
||||
disabled: false
|
||||
});
|
||||
|
||||
return items;
|
||||
@@ -482,6 +566,39 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
>
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPath) {
|
||||
loadAssets(currentPath);
|
||||
}
|
||||
if (showDetailView) {
|
||||
detailViewFileTreeRef.current?.refresh();
|
||||
} else {
|
||||
treeOnlyViewFileTreeRef.current?.refresh();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #3e3e3e',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '3px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2d2e';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
title="刷新资产列表"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
@@ -605,6 +722,117 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
{renameDialog && (
|
||||
<div className="dialog-overlay" onClick={() => setRenameDialog(null)}>
|
||||
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>{locale === 'zh' ? '重命名' : 'Rename'}</h3>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<input
|
||||
type="text"
|
||||
value={renameDialog.newName}
|
||||
onChange={(e) => setRenameDialog({ ...renameDialog, newName: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename(renameDialog.asset, renameDialog.newName);
|
||||
} else if (e.key === 'Escape') {
|
||||
setRenameDialog(null);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3e3e3e',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button
|
||||
onClick={() => setRenameDialog(null)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#3e3e3e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
marginRight: '8px'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRename(renameDialog.asset, renameDialog.newName)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '确定' : 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{deleteConfirmDialog && (
|
||||
<div className="dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
|
||||
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>{locale === 'zh' ? '确认删除' : 'Confirm Delete'}</h3>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<p style={{ margin: 0, color: '#cccccc' }}>
|
||||
{locale === 'zh'
|
||||
? `确定要删除 "${deleteConfirmDialog.name}" 吗?此操作不可撤销。`
|
||||
: `Are you sure you want to delete "${deleteConfirmDialog.name}"? This action cannot be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmDialog(null)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#3e3e3e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
marginRight: '8px'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmDialog)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#c53030',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '删除' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,831 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, Save, Folder, FileCode } from 'lucide-react';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type { BlackboardValueType } from '@esengine/behavior-tree';
|
||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('BehaviorTreeBlackboard');
|
||||
|
||||
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
|
||||
|
||||
interface BlackboardVariable {
|
||||
key: string;
|
||||
value: any;
|
||||
type: SimpleBlackboardType;
|
||||
}
|
||||
|
||||
interface BehaviorTreeBlackboardProps {
|
||||
variables: Record<string, any>;
|
||||
initialVariables?: Record<string, any>;
|
||||
globalVariables?: Record<string, any>;
|
||||
onVariableChange: (key: string, value: any) => void;
|
||||
onVariableAdd: (key: string, value: any, type: SimpleBlackboardType) => void;
|
||||
onVariableDelete: (key: string) => void;
|
||||
onVariableRename?: (oldKey: string, newKey: string) => void;
|
||||
onGlobalVariableChange?: (key: string, value: any) => void;
|
||||
onGlobalVariableAdd?: (key: string, value: any, type: BlackboardValueType) => void;
|
||||
onGlobalVariableDelete?: (key: string) => void;
|
||||
projectPath?: string;
|
||||
hasUnsavedGlobalChanges?: boolean;
|
||||
onSaveGlobal?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树黑板变量面板
|
||||
*
|
||||
* 用于管理和调试行为树运行时的黑板变量
|
||||
*/
|
||||
export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
|
||||
variables,
|
||||
initialVariables,
|
||||
globalVariables,
|
||||
onVariableChange,
|
||||
onVariableAdd,
|
||||
onVariableDelete,
|
||||
onVariableRename,
|
||||
onGlobalVariableChange,
|
||||
onGlobalVariableAdd,
|
||||
onGlobalVariableDelete,
|
||||
projectPath,
|
||||
hasUnsavedGlobalChanges,
|
||||
onSaveGlobal
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'local' | 'global'>('local');
|
||||
|
||||
const isModified = (key: string): boolean => {
|
||||
if (!initialVariables) return false;
|
||||
return JSON.stringify(variables[key]) !== JSON.stringify(initialVariables[key]);
|
||||
};
|
||||
|
||||
const handleExportTypeScript = async () => {
|
||||
try {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
const config = globalBlackboard.exportConfig();
|
||||
|
||||
const tsCode = GlobalBlackboardTypeGenerator.generate(config);
|
||||
|
||||
const outputPath = await save({
|
||||
filters: [{
|
||||
name: 'TypeScript',
|
||||
extensions: ['ts']
|
||||
}],
|
||||
defaultPath: 'GlobalBlackboard.ts'
|
||||
});
|
||||
|
||||
if (outputPath) {
|
||||
await invoke('write_file_content', {
|
||||
path: outputPath,
|
||||
content: tsCode
|
||||
});
|
||||
logger.info('TypeScript 类型定义已导出', outputPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('导出 TypeScript 失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newType, setNewType] = useState<BlackboardVariable['type']>('string');
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingNewKey, setEditingNewKey] = useState('');
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [editType, setEditType] = useState<BlackboardVariable['type']>('string');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleAddVariable = () => {
|
||||
if (!newKey.trim()) return;
|
||||
|
||||
let parsedValue: any = newValue;
|
||||
if (newType === 'number') {
|
||||
parsedValue = parseFloat(newValue) || 0;
|
||||
} else if (newType === 'boolean') {
|
||||
parsedValue = newValue === 'true';
|
||||
} else if (newType === 'object') {
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch {
|
||||
parsedValue = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'global' && onGlobalVariableAdd) {
|
||||
const globalType = newType as BlackboardValueType;
|
||||
onGlobalVariableAdd(newKey, parsedValue, globalType);
|
||||
} else {
|
||||
onVariableAdd(newKey, parsedValue, newType);
|
||||
}
|
||||
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleStartEdit = (key: string, value: any) => {
|
||||
setEditingKey(key);
|
||||
setEditingNewKey(key);
|
||||
const currentType = getVariableType(value);
|
||||
setEditType(currentType);
|
||||
setEditValue(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
|
||||
};
|
||||
|
||||
const handleSaveEdit = (key: string) => {
|
||||
const newKey = editingNewKey.trim();
|
||||
if (!newKey) return;
|
||||
|
||||
let parsedValue: any = editValue;
|
||||
if (editType === 'number') {
|
||||
parsedValue = parseFloat(editValue) || 0;
|
||||
} else if (editType === 'boolean') {
|
||||
parsedValue = editValue === 'true' || editValue === '1';
|
||||
} else if (editType === 'object') {
|
||||
try {
|
||||
parsedValue = JSON.parse(editValue);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'global' && onGlobalVariableChange) {
|
||||
if (newKey !== key && onGlobalVariableDelete) {
|
||||
onGlobalVariableDelete(key);
|
||||
}
|
||||
onGlobalVariableChange(newKey, parsedValue);
|
||||
} else {
|
||||
if (newKey !== key && onVariableRename) {
|
||||
onVariableRename(key, newKey);
|
||||
}
|
||||
onVariableChange(newKey, parsedValue);
|
||||
}
|
||||
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupName)) {
|
||||
newSet.delete(groupName);
|
||||
} else {
|
||||
newSet.add(groupName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getVariableType = (value: any): BlackboardVariable['type'] => {
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'object') return 'object';
|
||||
return 'string';
|
||||
};
|
||||
|
||||
const currentVariables = viewMode === 'global' ? (globalVariables || {}) : variables;
|
||||
const variableEntries = Object.entries(currentVariables);
|
||||
|
||||
const currentOnDelete = viewMode === 'global' ? onGlobalVariableDelete : onVariableDelete;
|
||||
|
||||
const groupedVariables: Record<string, Array<{ fullKey: string; varName: string; value: any }>> = variableEntries.reduce((groups, [key, value]) => {
|
||||
const parts = key.split('.');
|
||||
const groupName = (parts.length > 1 && parts[0]) ? parts[0] : 'default';
|
||||
const varName = parts.length > 1 ? parts.slice(1).join('.') : key;
|
||||
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
const group = groups[groupName];
|
||||
if (group) {
|
||||
group.push({ fullKey: key, varName, value });
|
||||
}
|
||||
return groups;
|
||||
}, {} as Record<string, Array<{ fullKey: string; varName: string; value: any }>>);
|
||||
|
||||
const groupNames = Object.keys(groupedVariables).sort((a, b) => {
|
||||
if (a === 'default') return 1;
|
||||
if (b === 'default') return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#cccccc'
|
||||
}}>
|
||||
<style>{`
|
||||
.blackboard-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.blackboard-list::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.blackboard-list::-webkit-scrollbar-thumb {
|
||||
background: #3c3c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.blackboard-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 标题栏 */}
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderBottom: '1px solid #333'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
color: '#ccc'
|
||||
}}>
|
||||
<Clipboard size={14} />
|
||||
<span>Blackboard</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setViewMode('local')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: viewMode === 'local' ? '#0e639c' : 'transparent',
|
||||
border: 'none',
|
||||
color: viewMode === 'local' ? '#fff' : '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px'
|
||||
}}
|
||||
>
|
||||
<Clipboard size={11} />
|
||||
Local
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('global')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: viewMode === 'global' ? '#0e639c' : 'transparent',
|
||||
border: 'none',
|
||||
color: viewMode === 'global' ? '#fff' : '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px'
|
||||
}}
|
||||
>
|
||||
<Globe size={11} />
|
||||
Global
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#252525',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
fontSize: '10px',
|
||||
color: '#888',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{viewMode === 'global' && projectPath ? (
|
||||
<>
|
||||
<Folder size={10} style={{ flexShrink: 0 }} />
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>.ecs/global-blackboard.json</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{viewMode === 'local' ? '当前行为树的本地变量' : '所有行为树共享的全局变量'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{viewMode === 'global' && onSaveGlobal && (
|
||||
<>
|
||||
<button
|
||||
onClick={hasUnsavedGlobalChanges ? onSaveGlobal : undefined}
|
||||
disabled={!hasUnsavedGlobalChanges}
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
backgroundColor: hasUnsavedGlobalChanges ? '#ff9800' : '#4caf50',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: hasUnsavedGlobalChanges ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: hasUnsavedGlobalChanges ? 1 : 0.7
|
||||
}}
|
||||
title={hasUnsavedGlobalChanges ? '点击保存全局配置' : '全局配置已保存'}
|
||||
>
|
||||
<Save size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportTypeScript}
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
backgroundColor: '#9c27b0',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
title="导出为 TypeScript 类型定义"
|
||||
>
|
||||
<FileCode size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
title="添加变量"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 变量列表 */}
|
||||
<div className="blackboard-list" style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '10px'
|
||||
}}>
|
||||
{variableEntries.length === 0 && !isAdding && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
padding: '20px'
|
||||
}}>
|
||||
No variables yet. Click "Add" to create one.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupNames.map((groupName) => {
|
||||
const isCollapsed = collapsedGroups.has(groupName);
|
||||
const groupVars = groupedVariables[groupName];
|
||||
|
||||
if (!groupVars) return null;
|
||||
|
||||
return (
|
||||
<div key={groupName} style={{ marginBottom: '8px' }}>
|
||||
{groupName !== 'default' && (
|
||||
<div
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 6px',
|
||||
backgroundColor: '#252525',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '4px',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: '#888'
|
||||
}}>
|
||||
{groupName} ({groupVars.length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
|
||||
const type = getVariableType(value);
|
||||
const isEditing = editingKey === key;
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
const variableData = {
|
||||
variableName: key,
|
||||
variableValue: value,
|
||||
variableType: type
|
||||
};
|
||||
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const typeColor =
|
||||
type === 'number' ? '#4ec9b0' :
|
||||
type === 'boolean' ? '#569cd6' :
|
||||
type === 'object' ? '#ce9178' : '#d4d4d4';
|
||||
|
||||
const displayValue = type === 'object' ?
|
||||
JSON.stringify(value) :
|
||||
String(value);
|
||||
|
||||
const truncatedValue = displayValue.length > 30 ?
|
||||
displayValue.substring(0, 30) + '...' :
|
||||
displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
draggable={!isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
style={{
|
||||
marginBottom: '6px',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '3px',
|
||||
borderLeft: `3px solid ${typeColor}`,
|
||||
cursor: isEditing ? 'default' : 'grab'
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Name
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editingNewKey}
|
||||
onChange={(e) => setEditingNewKey(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#9cdcfe',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
placeholder="Variable name (e.g., player.health)"
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Type
|
||||
</div>
|
||||
<select
|
||||
value={editType}
|
||||
onChange={(e) => setEditType(e.target.value as BlackboardVariable['type'])}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Value
|
||||
</div>
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: editType === 'object' ? '60px' : '24px',
|
||||
padding: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #0e639c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
marginBottom: '4px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(key)}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingKey(null)}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#9cdcfe',
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{varName} <span style={{
|
||||
color: '#666',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '10px'
|
||||
}}>({type})</span>
|
||||
{viewMode === 'local' && isModified(key) && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ffbb00',
|
||||
backgroundColor: 'rgba(255, 187, 0, 0.15)',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '2px'
|
||||
}} title="运行时修改的值,停止后会恢复">
|
||||
运行时
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'monospace',
|
||||
color: typeColor,
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
backgroundColor: (viewMode === 'local' && isModified(key)) ? 'rgba(255, 187, 0, 0.1)' : 'transparent',
|
||||
padding: '1px 3px',
|
||||
borderRadius: '2px'
|
||||
}} title={(viewMode === 'local' && isModified(key)) ? `初始值: ${JSON.stringify(initialVariables?.[key])}\n当前值: ${displayValue}` : displayValue}>
|
||||
{truncatedValue}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '2px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleStartEdit(key, value)}
|
||||
style={{
|
||||
padding: '2px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => currentOnDelete && currentOnDelete(key)}
|
||||
style={{
|
||||
padding: '2px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#f44336',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 添加新变量表单 */}
|
||||
{isAdding && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '4px',
|
||||
borderLeft: '3px solid #0e639c'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '10px',
|
||||
color: '#9cdcfe'
|
||||
}}>
|
||||
New Variable
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Variable name"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as BlackboardVariable['type'])}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
|
||||
<textarea
|
||||
placeholder={
|
||||
newType === 'object' ? '{"key": "value"}' :
|
||||
newType === 'boolean' ? 'true or false' :
|
||||
newType === 'number' ? '0' : 'value'
|
||||
}
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: newType === 'object' ? '80px' : '30px',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<button
|
||||
onClick={handleAddVariable}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div style={{
|
||||
padding: '8px 15px',
|
||||
borderTop: '1px solid #333',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
backgroundColor: '#2d2d2d',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span>
|
||||
{viewMode === 'local' ? 'Local' : 'Global'}: {variableEntries.length} variable{variableEntries.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,669 +0,0 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useBehaviorTreeStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores/behaviorTreeStore';
|
||||
import { useUIStore } from '../application/state/UIStore';
|
||||
import { useToast } from './Toast';
|
||||
import { BlackboardValue } from '../domain/models/Blackboard';
|
||||
import { BehaviorTreeCanvas } from '../presentation/components/behavior-tree/canvas/BehaviorTreeCanvas';
|
||||
import { ConnectionLayer } from '../presentation/components/behavior-tree/connections/ConnectionLayer';
|
||||
import { NodeFactory } from '../infrastructure/factories/NodeFactory';
|
||||
import { BehaviorTreeValidator } from '../infrastructure/validation/BehaviorTreeValidator';
|
||||
import { useNodeOperations } from '../presentation/hooks/useNodeOperations';
|
||||
import { useConnectionOperations } from '../presentation/hooks/useConnectionOperations';
|
||||
import { useCommandHistory } from '../presentation/hooks/useCommandHistory';
|
||||
import { useNodeDrag } from '../presentation/hooks/useNodeDrag';
|
||||
import { usePortConnection } from '../presentation/hooks/usePortConnection';
|
||||
import { useKeyboardShortcuts } from '../presentation/hooks/useKeyboardShortcuts';
|
||||
import { useDropHandler } from '../presentation/hooks/useDropHandler';
|
||||
import { useCanvasMouseEvents } from '../presentation/hooks/useCanvasMouseEvents';
|
||||
import { useContextMenu } from '../application/hooks/useContextMenu';
|
||||
import { useQuickCreateMenu } from '../application/hooks/useQuickCreateMenu';
|
||||
import { EditorToolbar } from '../presentation/components/toolbar/EditorToolbar';
|
||||
import { QuickCreateMenu } from '../presentation/components/menu/QuickCreateMenu';
|
||||
import { NodeContextMenu } from '../presentation/components/menu/NodeContextMenu';
|
||||
import { BehaviorTreeNode as BehaviorTreeNodeComponent } from '../presentation/components/behavior-tree/nodes/BehaviorTreeNode';
|
||||
import { getPortPosition as getPortPositionUtil } from '../presentation/utils/portUtils';
|
||||
import { useExecutionController } from '../presentation/hooks/useExecutionController';
|
||||
import { useNodeTracking } from '../presentation/hooks/useNodeTracking';
|
||||
import { useEditorState } from '../presentation/hooks/useEditorState';
|
||||
import { useEditorHandlers } from '../presentation/hooks/useEditorHandlers';
|
||||
import { ICON_MAP, ROOT_NODE_TEMPLATE, DEFAULT_EDITOR_CONFIG } from '../presentation/config/editorConstants';
|
||||
import '../styles/BehaviorTreeNode.css';
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
interface BehaviorTreeEditorProps {
|
||||
onNodeSelect?: (node: BehaviorTreeNode) => void;
|
||||
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||||
blackboardVariables?: BlackboardVariables;
|
||||
projectPath?: string | null;
|
||||
showToolbar?: boolean;
|
||||
}
|
||||
|
||||
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
||||
onNodeSelect,
|
||||
onNodeCreate,
|
||||
blackboardVariables = {},
|
||||
projectPath = null,
|
||||
showToolbar = true
|
||||
}) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 数据 store(行为树数据)
|
||||
const {
|
||||
nodes,
|
||||
connections,
|
||||
connectingFrom,
|
||||
connectingFromProperty,
|
||||
connectingToPos,
|
||||
isBoxSelecting,
|
||||
boxSelectStart,
|
||||
boxSelectEnd,
|
||||
setNodes,
|
||||
setConnections,
|
||||
setConnectingFrom,
|
||||
setConnectingFromProperty,
|
||||
setConnectingToPos,
|
||||
clearConnecting,
|
||||
setIsBoxSelecting,
|
||||
setBoxSelectStart,
|
||||
setBoxSelectEnd,
|
||||
clearBoxSelect,
|
||||
triggerForceUpdate,
|
||||
sortChildrenByPosition,
|
||||
setBlackboardVariables,
|
||||
setInitialBlackboardVariables,
|
||||
setIsExecuting,
|
||||
initialBlackboardVariables,
|
||||
isExecuting,
|
||||
saveNodesDataSnapshot,
|
||||
restoreNodesData,
|
||||
nodeExecutionStatuses,
|
||||
nodeExecutionOrders
|
||||
} = useBehaviorTreeStore();
|
||||
|
||||
// UI store(选中、拖拽、画布状态)
|
||||
const {
|
||||
selectedNodeIds,
|
||||
draggingNodeId,
|
||||
dragStartPositions,
|
||||
isDraggingNode,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
dragDelta,
|
||||
setSelectedNodeIds,
|
||||
startDragging,
|
||||
stopDragging,
|
||||
setIsDraggingNode,
|
||||
resetView,
|
||||
setDragDelta
|
||||
} = useUIStore();
|
||||
|
||||
// 依赖注入 - 基础设施
|
||||
const nodeFactory = useMemo(() => new NodeFactory(), []);
|
||||
const validator = useMemo(() => new BehaviorTreeValidator(), []);
|
||||
|
||||
// 命令历史管理(创建 CommandManager)
|
||||
const { commandManager, canUndo, canRedo, undo, redo } = useCommandHistory();
|
||||
|
||||
// 应用层 hooks(使用统一的 commandManager)
|
||||
const nodeOperations = useNodeOperations(nodeFactory, validator, commandManager);
|
||||
const connectionOperations = useConnectionOperations(validator, commandManager);
|
||||
|
||||
// 右键菜单
|
||||
const { contextMenu, setContextMenu, handleNodeContextMenu, handleCanvasContextMenu, closeContextMenu } = useContextMenu();
|
||||
|
||||
// 组件挂载和连线变化时强制更新,确保连线能正确渲染
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0 || connections.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
triggerForceUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [nodes.length, connections.length]);
|
||||
|
||||
// 点击其他地方关闭右键菜单
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
if (contextMenu.visible) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (contextMenu.visible) {
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}
|
||||
}, [contextMenu.visible, closeContextMenu]);
|
||||
|
||||
const {
|
||||
canvasRef,
|
||||
stopExecutionRef,
|
||||
executorRef,
|
||||
selectedConnection,
|
||||
setSelectedConnection
|
||||
} = useEditorState();
|
||||
|
||||
const {
|
||||
executionMode,
|
||||
executionLogs,
|
||||
executionSpeed,
|
||||
tickCount,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleStop,
|
||||
handleStep,
|
||||
handleReset,
|
||||
handleSpeedChange,
|
||||
setExecutionLogs,
|
||||
controller
|
||||
} = useExecutionController({
|
||||
rootNodeId: ROOT_NODE_ID,
|
||||
projectPath,
|
||||
blackboardVariables,
|
||||
nodes,
|
||||
connections,
|
||||
initialBlackboardVariables,
|
||||
onBlackboardUpdate: setBlackboardVariables,
|
||||
onInitialBlackboardSave: setInitialBlackboardVariables,
|
||||
onExecutingChange: setIsExecuting,
|
||||
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
|
||||
onRestoreNodesData: restoreNodesData
|
||||
});
|
||||
|
||||
executorRef.current = controller['executor'] || null;
|
||||
|
||||
const { uncommittedNodeIds } = useNodeTracking({
|
||||
nodes,
|
||||
executionMode
|
||||
});
|
||||
|
||||
// 快速创建菜单
|
||||
const {
|
||||
quickCreateMenu,
|
||||
setQuickCreateMenu,
|
||||
handleQuickCreateNode
|
||||
} = useQuickCreateMenu({
|
||||
nodeOperations,
|
||||
connectionOperations,
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
connectingFrom,
|
||||
connectingFromProperty,
|
||||
clearConnecting,
|
||||
nodes,
|
||||
setNodes,
|
||||
connections,
|
||||
executionMode,
|
||||
onStop: () => stopExecutionRef.current?.(),
|
||||
onNodeCreate,
|
||||
showToast
|
||||
});
|
||||
|
||||
// 节点拖拽
|
||||
const {
|
||||
handleNodeMouseDown,
|
||||
handleNodeMouseMove,
|
||||
handleNodeMouseUp
|
||||
} = useNodeDrag({
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
draggingNodeId,
|
||||
dragStartPositions,
|
||||
isDraggingNode,
|
||||
dragDelta,
|
||||
nodeOperations,
|
||||
setSelectedNodeIds,
|
||||
startDragging,
|
||||
stopDragging,
|
||||
setIsDraggingNode,
|
||||
setDragDelta,
|
||||
setIsBoxSelecting,
|
||||
setBoxSelectStart,
|
||||
setBoxSelectEnd,
|
||||
sortChildrenByPosition
|
||||
});
|
||||
|
||||
// 端口连接
|
||||
const {
|
||||
handlePortMouseDown,
|
||||
handlePortMouseUp,
|
||||
handleNodeMouseUpForConnection
|
||||
} = usePortConnection({
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
nodes,
|
||||
connections,
|
||||
connectingFrom,
|
||||
connectingFromProperty,
|
||||
connectionOperations,
|
||||
setConnectingFrom,
|
||||
setConnectingFromProperty,
|
||||
clearConnecting,
|
||||
sortChildrenByPosition,
|
||||
showToast
|
||||
});
|
||||
|
||||
// 键盘快捷键
|
||||
useKeyboardShortcuts({
|
||||
selectedNodeIds,
|
||||
selectedConnection,
|
||||
connections,
|
||||
nodeOperations,
|
||||
connectionOperations,
|
||||
setSelectedNodeIds,
|
||||
setSelectedConnection
|
||||
});
|
||||
|
||||
// 拖放处理
|
||||
const {
|
||||
isDragging,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDragEnter
|
||||
} = useDropHandler({
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
nodeOperations,
|
||||
onNodeCreate
|
||||
});
|
||||
|
||||
// 画布鼠标事件
|
||||
const {
|
||||
handleCanvasMouseMove,
|
||||
handleCanvasMouseUp,
|
||||
handleCanvasMouseDown
|
||||
} = useCanvasMouseEvents({
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
connectingFrom,
|
||||
connectingToPos,
|
||||
isBoxSelecting,
|
||||
boxSelectStart,
|
||||
boxSelectEnd,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
quickCreateMenu,
|
||||
setConnectingToPos,
|
||||
setIsBoxSelecting,
|
||||
setBoxSelectStart,
|
||||
setBoxSelectEnd,
|
||||
setSelectedNodeIds,
|
||||
setSelectedConnection,
|
||||
setQuickCreateMenu,
|
||||
clearConnecting,
|
||||
clearBoxSelect,
|
||||
showToast
|
||||
});
|
||||
|
||||
|
||||
const {
|
||||
handleNodeClick,
|
||||
handleResetView,
|
||||
handleClearCanvas
|
||||
} = useEditorHandlers({
|
||||
isDraggingNode,
|
||||
selectedNodeIds,
|
||||
setSelectedNodeIds,
|
||||
setNodes,
|
||||
setConnections,
|
||||
resetView,
|
||||
triggerForceUpdate,
|
||||
onNodeSelect,
|
||||
rootNodeId: ROOT_NODE_ID,
|
||||
rootNodeTemplate: ROOT_NODE_TEMPLATE
|
||||
});
|
||||
|
||||
const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
|
||||
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType);
|
||||
|
||||
stopExecutionRef.current = handleStop;
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
backgroundColor: '#1e1e1e',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.02);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 画布区域容器 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 画布 */}
|
||||
<BehaviorTreeCanvas
|
||||
ref={canvasRef}
|
||||
config={DEFAULT_EDITOR_CONFIG}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={(e) => {
|
||||
handleNodeMouseMove(e);
|
||||
handleCanvasMouseMove(e);
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
handleNodeMouseUp();
|
||||
handleCanvasMouseUp(e);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
handleNodeMouseUp();
|
||||
handleCanvasMouseUp(e);
|
||||
}}
|
||||
onContextMenu={handleCanvasContextMenu}
|
||||
>
|
||||
{/* 连接线层 */}
|
||||
<ConnectionLayer
|
||||
connections={connections}
|
||||
nodes={nodes}
|
||||
selectedConnection={selectedConnection}
|
||||
getPortPosition={getPortPosition}
|
||||
onConnectionClick={(e, fromId, toId) => {
|
||||
setSelectedConnection({ from: fromId, to: toId });
|
||||
setSelectedNodeIds([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 正在拖拽的连接线预览 */}
|
||||
<svg style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '10000px',
|
||||
height: '10000px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
overflow: 'visible'
|
||||
}}>
|
||||
{/* 正在拖拽的连接线 */}
|
||||
{connectingFrom && connectingToPos && (() => {
|
||||
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
|
||||
if (!fromNode) return null;
|
||||
|
||||
let x1, y1;
|
||||
let pathD: string;
|
||||
const x2 = connectingToPos.x;
|
||||
const y2 = connectingToPos.y;
|
||||
|
||||
// 判断是否是属性连接
|
||||
const isPropertyConnection = !!connectingFromProperty;
|
||||
const fromIsBlackboard = fromNode.data.nodeType === 'blackboard-variable';
|
||||
const color = isPropertyConnection ? '#9c27b0' : '#0e639c';
|
||||
|
||||
if (isPropertyConnection && fromIsBlackboard) {
|
||||
// 黑板变量节点的右侧输出引脚
|
||||
x1 = fromNode.position.x + 75;
|
||||
y1 = fromNode.position.y;
|
||||
|
||||
// 使用水平贝塞尔曲线
|
||||
const controlX1 = x1 + (x2 - x1) * 0.5;
|
||||
const controlX2 = x1 + (x2 - x1) * 0.5;
|
||||
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
|
||||
} else {
|
||||
// 节点连接:从底部输出端口
|
||||
x1 = fromNode.position.x;
|
||||
y1 = fromNode.position.y + 30;
|
||||
|
||||
const controlY = y1 + (y2 - y1) * 0.5;
|
||||
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<path
|
||||
d={pathD}
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeDasharray="5,5"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
|
||||
{/* 框选矩形 */}
|
||||
{isBoxSelecting && boxSelectStart && boxSelectEnd && (() => {
|
||||
const minX = Math.min(boxSelectStart.x, boxSelectEnd.x);
|
||||
const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x);
|
||||
const minY = Math.min(boxSelectStart.y, boxSelectEnd.y);
|
||||
const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y);
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `${minX}px`,
|
||||
top: `${minY}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: 'rgba(14, 99, 156, 0.15)',
|
||||
border: '2px solid rgba(14, 99, 156, 0.6)',
|
||||
borderRadius: '4px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 999
|
||||
}} />
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 节点列表 */}
|
||||
{nodes.map((node: BehaviorTreeNode) => {
|
||||
const isSelected = selectedNodeIds.includes(node.id);
|
||||
const isBeingDragged = dragStartPositions.has(node.id);
|
||||
const executionStatus = nodeExecutionStatuses.get(node.id);
|
||||
const executionOrder = nodeExecutionOrders.get(node.id);
|
||||
|
||||
return (
|
||||
<BehaviorTreeNodeComponent
|
||||
key={node.id}
|
||||
node={node}
|
||||
isSelected={isSelected}
|
||||
isBeingDragged={isBeingDragged}
|
||||
dragDelta={dragDelta}
|
||||
uncommittedNodeIds={uncommittedNodeIds}
|
||||
blackboardVariables={blackboardVariables}
|
||||
initialBlackboardVariables={initialBlackboardVariables}
|
||||
isExecuting={isExecuting}
|
||||
executionStatus={executionStatus}
|
||||
executionOrder={executionOrder}
|
||||
connections={connections}
|
||||
nodes={nodes}
|
||||
executorRef={executorRef}
|
||||
iconMap={ICON_MAP}
|
||||
draggingNodeId={draggingNodeId}
|
||||
onNodeClick={handleNodeClick}
|
||||
onContextMenu={handleNodeContextMenu}
|
||||
onNodeMouseDown={handleNodeMouseDown}
|
||||
onNodeMouseUpForConnection={handleNodeMouseUpForConnection}
|
||||
onPortMouseDown={handlePortMouseDown}
|
||||
onPortMouseUp={handlePortMouseUp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 拖拽提示 - 相对于画布视口 */}
|
||||
{isDragging && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
padding: '20px 40px',
|
||||
backgroundColor: 'rgba(14, 99, 156, 0.2)',
|
||||
border: '2px dashed #0e639c',
|
||||
borderRadius: '8px',
|
||||
color: '#0e639c',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
释放以创建节点
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态提示 - 相对于画布视口 */}
|
||||
{nodes.length === 1 && !isDragging && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '20px' }}>👇</div>
|
||||
<div style={{ marginBottom: '10px' }}>从左侧拖拽节点到 Root 下方开始创建行为树</div>
|
||||
<div style={{ fontSize: '12px', color: '#555' }}>
|
||||
先连接 Root 节点与第一个节点
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BehaviorTreeCanvas>
|
||||
|
||||
{/* 运行控制工具栏 */}
|
||||
{showToolbar && (
|
||||
<EditorToolbar
|
||||
executionMode={executionMode}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onStep={handleStep}
|
||||
onReset={handleReset}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onResetView={handleResetView}
|
||||
onClearCanvas={handleClearCanvas}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 快速创建菜单 */}
|
||||
<QuickCreateMenu
|
||||
visible={quickCreateMenu.visible}
|
||||
position={quickCreateMenu.position}
|
||||
searchText={quickCreateMenu.searchText}
|
||||
selectedIndex={quickCreateMenu.selectedIndex}
|
||||
mode={quickCreateMenu.mode}
|
||||
iconMap={ICON_MAP}
|
||||
onSearchChange={(text) => setQuickCreateMenu(prev => ({
|
||||
...prev,
|
||||
searchText: text
|
||||
}))}
|
||||
onIndexChange={(index) => setQuickCreateMenu(prev => ({
|
||||
...prev,
|
||||
selectedIndex: index
|
||||
}))}
|
||||
onNodeSelect={handleQuickCreateNode}
|
||||
onClose={() => {
|
||||
setQuickCreateMenu({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
clearConnecting();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 状态栏 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
padding: '8px 15px',
|
||||
backgroundColor: 'rgba(45, 45, 45, 0.95)',
|
||||
borderTop: '1px solid #333',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div>节点数: {nodes.length}</div>
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'center' }}>
|
||||
{executionMode === 'running' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<RotateCcw size={14} />
|
||||
Tick: {tickCount}
|
||||
</div>
|
||||
)}
|
||||
<div>{selectedNodeIds.length > 0 ? `已选择 ${selectedNodeIds.length} 个节点` : '未选择节点'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
<NodeContextMenu
|
||||
visible={contextMenu.visible}
|
||||
position={contextMenu.position}
|
||||
nodeId={contextMenu.nodeId}
|
||||
onReplaceNode={() => {
|
||||
setQuickCreateMenu({
|
||||
visible: true,
|
||||
position: contextMenu.position,
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'replace',
|
||||
replaceNodeId: contextMenu.nodeId
|
||||
});
|
||||
setContextMenu({ ...contextMenu, visible: false });
|
||||
}}
|
||||
onDeleteNode={() => {
|
||||
if (contextMenu.nodeId) {
|
||||
nodeOperations.deleteNode(contextMenu.nodeId);
|
||||
setContextMenu({ ...contextMenu, visible: false });
|
||||
}
|
||||
}}
|
||||
onCreateNode={() => {
|
||||
setQuickCreateMenu({
|
||||
visible: true,
|
||||
position: contextMenu.position,
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
setContextMenu({ ...contextMenu, visible: false });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,336 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Trash2, Copy } from 'lucide-react';
|
||||
|
||||
interface ExecutionLog {
|
||||
timestamp: number;
|
||||
message: string;
|
||||
level: 'info' | 'success' | 'error' | 'warning';
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
interface BehaviorTreeExecutionPanelProps {
|
||||
logs: ExecutionLog[];
|
||||
onClearLogs: () => void;
|
||||
isRunning: boolean;
|
||||
tickCount: number;
|
||||
executionSpeed: number;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
export const BehaviorTreeExecutionPanel: React.FC<BehaviorTreeExecutionPanelProps> = ({
|
||||
logs,
|
||||
onClearLogs,
|
||||
isRunning,
|
||||
tickCount,
|
||||
executionSpeed,
|
||||
onSpeedChange
|
||||
}) => {
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'success': return '#4caf50';
|
||||
case 'error': return '#f44336';
|
||||
case 'warning': return '#ff9800';
|
||||
default: return '#2196f3';
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'success': return '✓';
|
||||
case 'error': return '✗';
|
||||
case 'warning': return '⚠';
|
||||
default: return 'ℹ';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}.${date.getMilliseconds().toString().padStart(3, '0')}`;
|
||||
};
|
||||
|
||||
const handleCopyLogs = () => {
|
||||
const logsText = logs.map((log) =>
|
||||
`${formatTime(log.timestamp)} ${getLevelIcon(log.level)} ${log.message}`
|
||||
).join('\n');
|
||||
|
||||
navigator.clipboard.writeText(logsText).then(() => {
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
}).catch((err) => {
|
||||
console.error('复制失败:', err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
fontFamily: 'Consolas, monospace',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{/* 标题栏 */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #333',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#252526'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontWeight: 'bold' }}>执行控制台</span>
|
||||
{isRunning && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '2px 8px',
|
||||
backgroundColor: '#4caf50',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#fff',
|
||||
animation: 'pulse 1s infinite'
|
||||
}} />
|
||||
运行中
|
||||
</div>
|
||||
)}
|
||||
<span style={{ color: '#888', fontSize: '11px' }}>
|
||||
Tick: {tickCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{/* 速度控制 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: '#888', fontSize: '11px', minWidth: '60px' }}>
|
||||
速度: {executionSpeed.toFixed(2)}x
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => onSpeedChange(0.05)}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: executionSpeed === 0.05 ? '#0e639c' : 'transparent',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '2px',
|
||||
color: '#d4d4d4',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="超慢速 (每秒3次)"
|
||||
>
|
||||
0.05x
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSpeedChange(0.2)}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: executionSpeed === 0.2 ? '#0e639c' : 'transparent',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '2px',
|
||||
color: '#d4d4d4',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="慢速 (每秒12次)"
|
||||
>
|
||||
0.2x
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSpeedChange(1.0)}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: executionSpeed === 1.0 ? '#0e639c' : 'transparent',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '2px',
|
||||
color: '#d4d4d4',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="正常速度 (每秒60次)"
|
||||
>
|
||||
1.0x
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.01"
|
||||
max="2"
|
||||
step="0.01"
|
||||
value={executionSpeed}
|
||||
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
||||
style={{
|
||||
width: '80px',
|
||||
accentColor: '#0e639c'
|
||||
}}
|
||||
title="调整执行速度"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopyLogs}
|
||||
style={{
|
||||
padding: '6px',
|
||||
backgroundColor: copySuccess ? '#4caf50' : 'transparent',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: logs.length === 0 ? '#666' : '#d4d4d4',
|
||||
cursor: logs.length === 0 ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
opacity: logs.length === 0 ? 0.5 : 1,
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
title={copySuccess ? '已复制!' : '复制日志'}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClearLogs}
|
||||
style={{
|
||||
padding: '6px',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: '#d4d4d4',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
title="清空日志"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志内容 */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="execution-panel-logs"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '8px',
|
||||
backgroundColor: '#1e1e1e'
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#666',
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
点击 Play 按钮开始执行行为树
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
padding: '4px 0',
|
||||
borderBottom: index < logs.length - 1 ? '1px solid #2a2a2a' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
minWidth: '80px'
|
||||
}}>
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
<span style={{
|
||||
color: getLevelColor(log.level),
|
||||
fontWeight: 'bold',
|
||||
minWidth: '16px'
|
||||
}}>
|
||||
{getLevelIcon(log.level)}
|
||||
</span>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
color: log.level === 'error' ? '#f44336' : '#d4d4d4'
|
||||
}}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
borderTop: '1px solid #333',
|
||||
backgroundColor: '#252526',
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span>{logs.length} 条日志</span>
|
||||
<span>{isRunning ? '正在运行' : '已停止'}</span>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.execution-panel-logs::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.execution-panel-logs::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.execution-panel-logs::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.execution-panel-logs::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
.execution-panel-logs {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #424242 #1e1e1e;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import '../styles/BehaviorTreeNameDialog.css';
|
||||
|
||||
interface BehaviorTreeNameDialogProps {
|
||||
isOpen: boolean;
|
||||
onConfirm: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
defaultName?: string;
|
||||
}
|
||||
|
||||
export const BehaviorTreeNameDialog: React.FC<BehaviorTreeNameDialogProps> = ({
|
||||
isOpen,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
defaultName = ''
|
||||
}) => {
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName(defaultName);
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, defaultName]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const validateName = (value: string): boolean => {
|
||||
if (!value.trim()) {
|
||||
setError('行为树名称不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidChars = /[<>:"/\\|?*]/;
|
||||
if (invalidChars.test(value)) {
|
||||
setError('名称包含非法字符(不能包含 < > : " / \\ | ? *)');
|
||||
return false;
|
||||
}
|
||||
|
||||
setError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (validateName(name)) {
|
||||
onConfirm(name.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleConfirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (error) {
|
||||
validateName(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog-content">
|
||||
<div className="dialog-header">
|
||||
<h3>保存行为树</h3>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<label htmlFor="btree-name">行为树名称:</label>
|
||||
<input
|
||||
id="btree-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="请输入行为树名称"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <div className="dialog-error">{error}</div>}
|
||||
<div className="dialog-hint">
|
||||
将保存到项目目录: .ecs/behaviors/{name || '名称'}.btree
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button onClick={onCancel} className="dialog-button dialog-button-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleConfirm} className="dialog-button dialog-button-primary">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,281 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { EditorPluginManager, MessageHub } from '@esengine/editor-core';
|
||||
import { NodeIcon } from './NodeIcon';
|
||||
|
||||
interface BehaviorTreeNodePaletteProps {
|
||||
onNodeSelect?: (template: NodeTemplate) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点类型对应的颜色
|
||||
*/
|
||||
const getTypeColor = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'composite': return '#1976d2';
|
||||
case 'action': return '#388e3c';
|
||||
case 'condition': return '#d32f2f';
|
||||
case 'decorator': return '#fb8c00';
|
||||
case 'blackboard': return '#8e24aa';
|
||||
default: return '#7b1fa2';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 行为树节点面板
|
||||
*
|
||||
* 显示所有可用的行为树节点模板,支持拖拽创建
|
||||
*/
|
||||
export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = ({
|
||||
onNodeSelect
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [allTemplates, setAllTemplates] = useState<NodeTemplate[]>([]);
|
||||
|
||||
// 获取所有节点模板(包括插件提供的)
|
||||
const loadAllTemplates = () => {
|
||||
console.log('[BehaviorTreeNodePalette] 开始加载节点模板');
|
||||
try {
|
||||
const pluginManager = Core.services.resolve(EditorPluginManager);
|
||||
const allPlugins = pluginManager.getAllEditorPlugins();
|
||||
console.log('[BehaviorTreeNodePalette] 找到插件数量:', allPlugins.length);
|
||||
|
||||
// 合并所有插件的节点模板
|
||||
const templates: NodeTemplate[] = [];
|
||||
for (const plugin of allPlugins) {
|
||||
if (plugin.getNodeTemplates) {
|
||||
console.log('[BehaviorTreeNodePalette] 从插件获取模板:', plugin.name);
|
||||
const pluginTemplates = plugin.getNodeTemplates();
|
||||
console.log('[BehaviorTreeNodePalette] 插件提供的模板数量:', pluginTemplates.length);
|
||||
if (pluginTemplates.length > 0) {
|
||||
console.log('[BehaviorTreeNodePalette] 第一个模板:', pluginTemplates[0].displayName);
|
||||
}
|
||||
templates.push(...pluginTemplates);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有插件提供模板,回退到装饰器注册的模板
|
||||
if (templates.length === 0) {
|
||||
console.log('[BehaviorTreeNodePalette] 没有插件提供模板,使用默认模板');
|
||||
templates.push(...NodeTemplates.getAllTemplates());
|
||||
}
|
||||
|
||||
console.log('[BehaviorTreeNodePalette] 总共加载了', templates.length, '个模板');
|
||||
setAllTemplates(templates);
|
||||
} catch (error) {
|
||||
console.error('[BehaviorTreeNodePalette] 加载模板失败:', error);
|
||||
// 如果无法访问插件管理器,使用默认模板
|
||||
setAllTemplates(NodeTemplates.getAllTemplates());
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadAllTemplates();
|
||||
}, []);
|
||||
|
||||
// 监听语言变化事件
|
||||
useEffect(() => {
|
||||
try {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
console.log('[BehaviorTreeNodePalette] 订阅 locale:changed 事件');
|
||||
const unsubscribe = messageHub.subscribe('locale:changed', (data: any) => {
|
||||
console.log('[BehaviorTreeNodePalette] 收到 locale:changed 事件:', data);
|
||||
// 语言变化时重新加载模板
|
||||
loadAllTemplates();
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('[BehaviorTreeNodePalette] 取消订阅 locale:changed 事件');
|
||||
unsubscribe();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BehaviorTreeNodePalette] 订阅事件失败:', error);
|
||||
// 如果无法访问 MessageHub,忽略
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 按类别分组(排除根节点类别)
|
||||
const categories = useMemo(() =>
|
||||
['all', ...new Set(allTemplates
|
||||
.filter((t) => t.category !== '根节点')
|
||||
.map((t) => t.category))]
|
||||
, [allTemplates]);
|
||||
|
||||
const filteredTemplates = useMemo(() =>
|
||||
(selectedCategory === 'all'
|
||||
? allTemplates
|
||||
: allTemplates.filter((t) => t.category === selectedCategory))
|
||||
.filter((t) => t.category !== '根节点')
|
||||
, [allTemplates, selectedCategory]);
|
||||
|
||||
const handleNodeClick = (template: NodeTemplate) => {
|
||||
onNodeSelect?.(template);
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, template: NodeTemplate) => {
|
||||
const templateJson = JSON.stringify(template);
|
||||
e.dataTransfer.setData('application/behavior-tree-node', templateJson);
|
||||
e.dataTransfer.setData('text/plain', templateJson);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
const dragImage = e.currentTarget as HTMLElement;
|
||||
if (dragImage) {
|
||||
e.dataTransfer.setDragImage(dragImage, 50, 25);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#cccccc',
|
||||
fontFamily: 'sans-serif'
|
||||
}}>
|
||||
<style>{`
|
||||
.node-palette-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.node-palette-list::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.node-palette-list::-webkit-scrollbar-thumb {
|
||||
background: #3c3c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.node-palette-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
`}</style>
|
||||
{/* 类别选择器 */}
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid #333',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '5px'
|
||||
}}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
backgroundColor: selectedCategory === category ? '#0e639c' : '#3c3c3c',
|
||||
color: '#cccccc',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 节点列表 */}
|
||||
<div className="node-palette-list" style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '10px'
|
||||
}}>
|
||||
{filteredTemplates.map((template, index) => {
|
||||
const className = template.className || '';
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
draggable={true}
|
||||
onDragStart={(e) => handleDragStart(e, template)}
|
||||
onClick={() => handleNodeClick(template)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderLeft: `4px solid ${getTypeColor(template.type || '')}`,
|
||||
borderRadius: '3px',
|
||||
cursor: 'grab',
|
||||
transition: 'all 0.2s',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#3d3d3d';
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#2d2d2d';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.currentTarget.style.cursor = 'grabbing';
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.currentTarget.style.cursor = 'grab';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '5px',
|
||||
pointerEvents: 'none',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{template.icon && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', paddingTop: '2px' }}>
|
||||
<NodeIcon iconName={template.icon} size={16} />
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '2px' }}>
|
||||
{template.displayName}
|
||||
</div>
|
||||
{className && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{className}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
lineHeight: '1.4',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 帮助提示 */}
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
borderTop: '1px solid #333',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
拖拽节点到编辑器或点击选择
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,400 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NodeTemplate, PropertyDefinition } from '@esengine/behavior-tree';
|
||||
import {
|
||||
List, GitBranch, Layers, Shuffle,
|
||||
RotateCcw, Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
|
||||
Clock, FileText, Edit, Calculator, Code,
|
||||
Equal, Dices, Settings, Database, FolderOpen, TreePine,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { AssetPickerDialog } from './AssetPickerDialog';
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
List, GitBranch, Layers, Shuffle,
|
||||
RotateCcw, Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
|
||||
Clock, FileText, Edit, Calculator, Code,
|
||||
Equal, Dices, Settings, Database, TreePine
|
||||
};
|
||||
|
||||
interface BehaviorTreeNodePropertiesProps {
|
||||
selectedNode?: {
|
||||
template: NodeTemplate;
|
||||
data: Record<string, any>;
|
||||
};
|
||||
onPropertyChange?: (propertyName: string, value: any) => void;
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树节点属性编辑器
|
||||
*
|
||||
* 根据节点模板动态生成属性编辑界面
|
||||
*/
|
||||
export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProps> = ({
|
||||
selectedNode,
|
||||
onPropertyChange,
|
||||
projectPath
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
||||
const [assetPickerProperty, setAssetPickerProperty] = useState<string | null>(null);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [localValues, setLocalValues] = useState<Record<string, any>>({});
|
||||
|
||||
// 当节点切换时,清空本地状态
|
||||
React.useEffect(() => {
|
||||
setLocalValues({});
|
||||
}, [selectedNode?.template.className]);
|
||||
|
||||
if (!selectedNode) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#666',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{t('behaviorTree.noNodeSelected')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { template, data } = selectedNode;
|
||||
|
||||
const handleChange = (propName: string, value: any) => {
|
||||
if (!isComposing) {
|
||||
onPropertyChange?.(propName, value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (propName: string, value: any) => {
|
||||
setLocalValues(prev => ({ ...prev, [propName]: value }));
|
||||
if (!isComposing) {
|
||||
onPropertyChange?.(propName, value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
setIsComposing(true);
|
||||
};
|
||||
|
||||
const handleCompositionEnd = (propName: string, value: any) => {
|
||||
setIsComposing(false);
|
||||
onPropertyChange?.(propName, value);
|
||||
};
|
||||
|
||||
const renderProperty = (prop: PropertyDefinition) => {
|
||||
const propName = prop.name;
|
||||
const hasLocalValue = propName in localValues;
|
||||
const value = hasLocalValue ? localValues[propName] : (data[prop.name] ?? prop.defaultValue);
|
||||
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
case 'variable':
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(propName, e.target.value)}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLInputElement).value)}
|
||||
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
|
||||
placeholder={prop.description}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => handleChange(prop.name, parseFloat(e.target.value))}
|
||||
min={prop.min}
|
||||
max={prop.max}
|
||||
step={prop.step || 1}
|
||||
placeholder={prop.description}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value || false}
|
||||
onChange={(e) => handleChange(prop.name, e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '13px' }}>{prop.description || '启用'}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(prop.name, e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{prop.options?.map((opt, idx) => (
|
||||
<option key={idx} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'code':
|
||||
return (
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(propName, e.target.value)}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
|
||||
placeholder={prop.description}
|
||||
rows={5}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'blackboard':
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(propName, e.target.value)}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLInputElement).value)}
|
||||
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
|
||||
placeholder="黑板变量名"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
选择
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'asset':
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(prop.name, e.target.value)}
|
||||
placeholder={prop.description || '资产ID'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAssetPickerProperty(prop.name);
|
||||
setAssetPickerOpen(true);
|
||||
}}
|
||||
disabled={!projectPath}
|
||||
title={!projectPath ? '请先打开项目' : '浏览资产'}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: projectPath ? '#0e639c' : '#555',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: projectPath ? 'pointer' : 'not-allowed',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
{!projectPath && (
|
||||
<div style={{
|
||||
marginTop: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#f48771',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
⚠️ 请先在编辑器中打开项目,才能使用资产浏览器
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#cccccc',
|
||||
fontFamily: 'sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* 节点信息 */}
|
||||
<div style={{
|
||||
padding: '15px',
|
||||
borderBottom: '1px solid #333'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '10px'
|
||||
}}>
|
||||
{template.icon && (() => {
|
||||
const IconComponent = iconMap[template.icon];
|
||||
return IconComponent ? (
|
||||
<IconComponent
|
||||
size={24}
|
||||
color={template.color || '#cccccc'}
|
||||
style={{ marginRight: '10px' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ marginRight: '10px', fontSize: '24px' }}>
|
||||
{template.icon}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '16px' }}>{template.displayName}</h3>
|
||||
<div style={{ fontSize: '11px', color: '#666', marginTop: '2px' }}>
|
||||
{template.category}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#999', lineHeight: '1.5' }}>
|
||||
{template.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性列表 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '15px'
|
||||
}}>
|
||||
{template.properties.length === 0 ? (
|
||||
<div style={{ color: '#666', fontSize: '13px', textAlign: 'center', paddingTop: '20px' }}>
|
||||
{t('behaviorTree.noConfigurableProperties')}
|
||||
</div>
|
||||
) : (
|
||||
template.properties.map((prop, index) => (
|
||||
<div key={index} style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
color: '#cccccc',
|
||||
cursor: prop.description ? 'help' : 'default'
|
||||
}}
|
||||
title={prop.description}
|
||||
>
|
||||
{prop.label}
|
||||
{prop.required && (
|
||||
<span style={{ color: '#f48771', marginLeft: '4px' }}>*</span>
|
||||
)}
|
||||
</label>
|
||||
{renderProperty(prop)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 资产选择器对话框 */}
|
||||
{assetPickerOpen && projectPath && assetPickerProperty && (
|
||||
<AssetPickerDialog
|
||||
projectPath={projectPath}
|
||||
fileExtension="btree"
|
||||
assetBasePath=".ecs/behaviors"
|
||||
locale={t('locale') === 'zh' ? 'zh' : 'en'}
|
||||
onSelect={(assetId) => {
|
||||
// AssetPickerDialog 返回 assetId(不含扩展名,相对于 .ecs/behaviors 的路径)
|
||||
handleChange(assetPickerProperty, assetId);
|
||||
setAssetPickerOpen(false);
|
||||
setAssetPickerProperty(null);
|
||||
}}
|
||||
onClose={() => {
|
||||
setAssetPickerOpen(false);
|
||||
setAssetPickerProperty(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Cpu } from 'lucide-react';
|
||||
import { ICompiler, CompileResult, CompilerContext } from '@esengine/editor-core';
|
||||
import '../styles/CompileDialog.css';
|
||||
|
||||
interface CompileDialogProps<TOptions = unknown> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
compiler: ICompiler<TOptions>;
|
||||
context: CompilerContext;
|
||||
initialOptions?: TOptions;
|
||||
}
|
||||
|
||||
export function CompileDialog<TOptions = unknown>({
|
||||
isOpen,
|
||||
onClose,
|
||||
compiler,
|
||||
context,
|
||||
initialOptions
|
||||
}: CompileDialogProps<TOptions>) {
|
||||
const [options, setOptions] = useState<TOptions>(initialOptions as TOptions);
|
||||
const [isCompiling, setIsCompiling] = useState(false);
|
||||
const [result, setResult] = useState<CompileResult | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialOptions) {
|
||||
setOptions(initialOptions);
|
||||
setResult(null);
|
||||
setValidationError(null);
|
||||
}
|
||||
}, [isOpen, initialOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compiler.validateOptions && options) {
|
||||
const error = compiler.validateOptions(options);
|
||||
setValidationError(error);
|
||||
}
|
||||
}, [options, compiler]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCompile = async () => {
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCompiling(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const compileResult = await compiler.compile(options, context);
|
||||
setResult(compileResult);
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: `编译失败: ${error}`,
|
||||
errors: [String(error)]
|
||||
});
|
||||
} finally {
|
||||
setIsCompiling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="compile-dialog-overlay">
|
||||
<div className="compile-dialog">
|
||||
<div className="compile-dialog-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Cpu size={20} />
|
||||
<h3>{compiler.name}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="compile-dialog-close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="compile-dialog-content">
|
||||
{compiler.description && (
|
||||
<div className="compile-dialog-description">
|
||||
{compiler.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{compiler.createConfigUI && compiler.createConfigUI(setOptions, context)}
|
||||
|
||||
{validationError && (
|
||||
<div className="compile-dialog-error">
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={`compile-dialog-result ${result.success ? 'success' : 'error'}`}>
|
||||
<div className="compile-dialog-result-message">
|
||||
{result.message}
|
||||
</div>
|
||||
{result.outputFiles && result.outputFiles.length > 0 && (
|
||||
<div className="compile-dialog-output-files">
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>输出文件:</div>
|
||||
{result.outputFiles.map((file, index) => (
|
||||
<div key={index} className="compile-dialog-output-file">
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="compile-dialog-errors">
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>错误:</div>
|
||||
{result.errors.map((error, index) => (
|
||||
<div key={index} className="compile-dialog-error-item">
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="compile-dialog-footer">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="compile-dialog-btn compile-dialog-btn-cancel"
|
||||
disabled={isCompiling}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCompile}
|
||||
className="compile-dialog-btn compile-dialog-btn-primary"
|
||||
disabled={isCompiling || !!validationError}
|
||||
>
|
||||
{isCompiling ? '编译中...' : '编译'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Core, IService, ServiceType } from '@esengine/ecs-framework';
|
||||
import { CompilerRegistry, ICompiler, CompilerContext, CompileResult, IFileSystem, IDialog, FileEntry } from '@esengine/editor-core';
|
||||
import { X, Play, Loader2 } from 'lucide-react';
|
||||
import { open as tauriOpen, save as tauriSave, message as tauriMessage, confirm as tauriConfirm } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import '../styles/CompilerConfigDialog.css';
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
}
|
||||
|
||||
interface CompilerConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
compilerId: string;
|
||||
projectPath: string | null;
|
||||
currentFileName?: string;
|
||||
onClose: () => void;
|
||||
onCompileComplete?: (result: CompileResult) => void;
|
||||
}
|
||||
|
||||
export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
isOpen,
|
||||
compilerId,
|
||||
projectPath,
|
||||
currentFileName,
|
||||
onClose,
|
||||
onCompileComplete
|
||||
}) => {
|
||||
const [compiler, setCompiler] = useState<ICompiler | null>(null);
|
||||
const [options, setOptions] = useState<unknown>(null);
|
||||
const [isCompiling, setIsCompiling] = useState(false);
|
||||
const [compileResult, setCompileResult] = useState<CompileResult | null>(null);
|
||||
const optionsRef = useRef<unknown>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && compilerId) {
|
||||
try {
|
||||
const registry = Core.services.resolve(CompilerRegistry);
|
||||
console.log('[CompilerConfigDialog] Registry resolved:', registry);
|
||||
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map(c => c.id));
|
||||
const comp = registry.get(compilerId);
|
||||
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
|
||||
setCompiler(comp || null);
|
||||
} catch (error) {
|
||||
console.error('[CompilerConfigDialog] Failed to resolve CompilerRegistry:', error);
|
||||
setCompiler(null);
|
||||
}
|
||||
}
|
||||
}, [isOpen, compilerId]);
|
||||
|
||||
const handleOptionsChange = useCallback((newOptions: unknown) => {
|
||||
optionsRef.current = newOptions;
|
||||
setOptions(newOptions);
|
||||
}, []);
|
||||
|
||||
const createFileSystem = (): IFileSystem => ({
|
||||
readFile: async (path: string) => {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
},
|
||||
writeFile: async (path: string, content: string) => {
|
||||
await invoke('write_file_content', { path, content });
|
||||
},
|
||||
writeBinary: async (path: string, data: Uint8Array) => {
|
||||
await invoke('write_binary_file', { filePath: path, content: Array.from(data) });
|
||||
},
|
||||
exists: async (path: string) => {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
},
|
||||
createDirectory: async (path: string) => {
|
||||
await invoke('create_directory', { path });
|
||||
},
|
||||
listDirectory: async (path: string): Promise<FileEntry[]> => {
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
return entries.map(e => ({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
isDirectory: e.is_dir
|
||||
}));
|
||||
},
|
||||
deleteFile: async (path: string) => {
|
||||
await invoke('delete_file', { path });
|
||||
},
|
||||
deleteDirectory: async (path: string) => {
|
||||
await invoke('delete_folder', { path });
|
||||
},
|
||||
scanFiles: async (dir: string, pattern: string) => {
|
||||
// Check if directory exists, create if not
|
||||
const dirExists = await invoke<boolean>('path_exists', { path: dir });
|
||||
if (!dirExists) {
|
||||
await invoke('create_directory', { path: dir });
|
||||
return []; // New directory has no files
|
||||
}
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
|
||||
const ext = pattern.replace(/\*/g, '');
|
||||
return entries
|
||||
.filter(e => !e.is_dir && e.name.endsWith(ext))
|
||||
.map(e => e.name.replace(ext, ''));
|
||||
}
|
||||
});
|
||||
|
||||
const createDialog = (): IDialog => ({
|
||||
openDialog: async (opts) => {
|
||||
const result = await tauriOpen({
|
||||
directory: opts.directory,
|
||||
multiple: opts.multiple,
|
||||
title: opts.title,
|
||||
defaultPath: opts.defaultPath
|
||||
});
|
||||
return result;
|
||||
},
|
||||
saveDialog: async (opts) => {
|
||||
const result = await tauriSave({
|
||||
title: opts.title,
|
||||
defaultPath: opts.defaultPath,
|
||||
filters: opts.filters
|
||||
});
|
||||
return result;
|
||||
},
|
||||
showMessage: async (title: string, message: string, type?: 'info' | 'warning' | 'error') => {
|
||||
await tauriMessage(message, { title, kind: type || 'info' });
|
||||
},
|
||||
showConfirm: async (title: string, message: string) => {
|
||||
return await tauriConfirm(message, { title });
|
||||
}
|
||||
});
|
||||
|
||||
const createContext = (): CompilerContext => ({
|
||||
projectPath,
|
||||
moduleContext: {
|
||||
fileSystem: createFileSystem(),
|
||||
dialog: createDialog()
|
||||
},
|
||||
getService: <T extends IService>(serviceClass: ServiceType<T>): T | undefined => {
|
||||
try {
|
||||
return Core.services.resolve(serviceClass);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleCompile = async () => {
|
||||
if (!compiler || !optionsRef.current) return;
|
||||
|
||||
setIsCompiling(true);
|
||||
setCompileResult(null);
|
||||
|
||||
try {
|
||||
const context = createContext();
|
||||
const result = await compiler.compile(optionsRef.current, context);
|
||||
setCompileResult(result);
|
||||
onCompileComplete?.(result);
|
||||
|
||||
if (result.success) {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
setCompileResult({
|
||||
success: false,
|
||||
message: `编译失败: ${error}`,
|
||||
errors: [String(error)]
|
||||
});
|
||||
} finally {
|
||||
setIsCompiling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const context = createContext();
|
||||
|
||||
return (
|
||||
<div className="compiler-dialog-overlay">
|
||||
<div className="compiler-dialog">
|
||||
<div className="compiler-dialog-header">
|
||||
<h3>{compiler?.name || '编译器配置'}</h3>
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="compiler-dialog-body">
|
||||
{compiler?.createConfigUI ? (
|
||||
compiler.createConfigUI(handleOptionsChange, context)
|
||||
) : (
|
||||
<div className="no-config">
|
||||
{compiler ? '该编译器没有配置界面' : '编译器未找到'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{compileResult && (
|
||||
<div className={`compile-result ${compileResult.success ? 'success' : 'error'}`}>
|
||||
<div className="result-message">{compileResult.message}</div>
|
||||
{compileResult.outputFiles && compileResult.outputFiles.length > 0 && (
|
||||
<div className="output-files">
|
||||
已生成 {compileResult.outputFiles.length} 个文件
|
||||
</div>
|
||||
)}
|
||||
{compileResult.errors && compileResult.errors.length > 0 && (
|
||||
<div className="error-list">
|
||||
{compileResult.errors.map((err, i) => (
|
||||
<div key={i} className="error-item">{err}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="compiler-dialog-footer">
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={onClose}
|
||||
disabled={isCompiling}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="compile-button"
|
||||
onClick={handleCompile}
|
||||
disabled={isCompiling || !compiler || !options}
|
||||
>
|
||||
{isCompiling ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spinning" />
|
||||
编译中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
编译
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useMemo, memo } from 'react';
|
||||
import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown, Wifi } from 'lucide-react';
|
||||
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Wifi } from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/ConsolePanel.css';
|
||||
|
||||
@@ -9,114 +9,73 @@ interface ConsolePanelProps {
|
||||
logService: LogService;
|
||||
}
|
||||
|
||||
interface ParsedLogData {
|
||||
isJSON: boolean;
|
||||
jsonStr?: string;
|
||||
extracted?: { prefix: string; json: string; suffix: string } | null;
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
// 提取JSON检测和格式化逻辑
|
||||
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
return { isJSON: true, parsed };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
|
||||
const LogEntryItem = memo(({
|
||||
log,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onOpenJsonViewer,
|
||||
parsedData
|
||||
}: {
|
||||
// 格式化时间
|
||||
function formatTime(date: Date): string {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
}
|
||||
|
||||
// 日志等级图标
|
||||
function getLevelIcon(level: LogLevel) {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 日志等级样式类
|
||||
function getLevelClass(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 单个日志条目组件
|
||||
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
|
||||
log: LogEntry;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: (id: number) => void;
|
||||
onOpenJsonViewer: (jsonStr: string) => void;
|
||||
parsedData: ParsedLogData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onOpenJsonViewer: (data: any) => void;
|
||||
}) => {
|
||||
const getLevelIcon = (level: LogLevel) => {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelClass = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
};
|
||||
|
||||
const formatMessage = (message: string, isExpanded: boolean, parsedData: ParsedLogData): JSX.Element => {
|
||||
const MAX_PREVIEW_LENGTH = 200;
|
||||
const { isJSON, jsonStr, extracted } = parsedData;
|
||||
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
|
||||
|
||||
return (
|
||||
<div className="log-message-container">
|
||||
<div className="log-message-text">
|
||||
{shouldTruncate ? (
|
||||
<>
|
||||
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
|
||||
<span className="log-message-preview">
|
||||
{message.substring(0, MAX_PREVIEW_LENGTH)}...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{message}</span>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && jsonStr && (
|
||||
<button
|
||||
className="log-open-json-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenJsonViewer(jsonStr);
|
||||
}}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowExpander = log.message.length > 200;
|
||||
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
|
||||
const shouldTruncate = log.message.length > 200;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
|
||||
>
|
||||
{shouldShowExpander && (
|
||||
<div
|
||||
className="log-entry-expander"
|
||||
onClick={() => onToggleExpand(log.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
|
||||
<div className="log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
@@ -132,7 +91,45 @@ const LogEntryItem = memo(({
|
||||
</div>
|
||||
)}
|
||||
<div className="log-entry-message">
|
||||
{formatMessage(log.message, isExpanded, parsedData)}
|
||||
<div className="log-message-container">
|
||||
<div className="log-message-text">
|
||||
{shouldTruncate && !isExpanded ? (
|
||||
<>
|
||||
<span className="log-message-preview">
|
||||
{log.message.substring(0, 200)}...
|
||||
</span>
|
||||
<button
|
||||
className="log-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{log.message}</span>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
className="log-expand-btn"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && parsed !== undefined && (
|
||||
<button
|
||||
className="log-open-json-btn"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={() => onOpenJsonViewer(parsed as any)}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -140,10 +137,9 @@ const LogEntryItem = memo(({
|
||||
|
||||
LogEntryItem.displayName = 'LogEntryItem';
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
// 状态管理
|
||||
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
|
||||
const [filter, setFilter] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
|
||||
LogLevel.Debug,
|
||||
@@ -154,37 +150,30 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
]));
|
||||
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 订阅日志更新
|
||||
useEffect(() => {
|
||||
setLogs(logService.getLogs().slice(-MAX_LOGS));
|
||||
|
||||
const unsubscribe = logService.subscribe((entry) => {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, entry];
|
||||
if (newLogs.length > MAX_LOGS) {
|
||||
return newLogs.slice(-MAX_LOGS);
|
||||
}
|
||||
return newLogs;
|
||||
return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [logService]);
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleClear = () => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
// 处理滚动
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
@@ -193,6 +182,13 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// 清空日志
|
||||
const handleClear = () => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
// 切换等级过滤
|
||||
const toggleLevelFilter = (level: LogLevel) => {
|
||||
const newFilter = new Set(levelFilter);
|
||||
if (newFilter.has(level)) {
|
||||
@@ -203,129 +199,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
setLevelFilter(newFilter);
|
||||
};
|
||||
|
||||
// 使用ref保存缓存,避免每次都重新计算
|
||||
const parsedLogsCacheRef = useRef<Map<number, ParsedLogData>>(new Map());
|
||||
|
||||
const extractJSON = useMemo(() => {
|
||||
return (message: string): { prefix: string; json: string; suffix: string } | null => {
|
||||
// 快速路径:如果消息太短,直接返回
|
||||
if (message.length < 2) return null;
|
||||
|
||||
const jsonStartChars = ['{', '['];
|
||||
let startIndex = -1;
|
||||
|
||||
for (const char of jsonStartChars) {
|
||||
const index = message.indexOf(char);
|
||||
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
|
||||
startIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === -1) return null;
|
||||
|
||||
// 使用栈匹配算法,更高效地找到JSON边界
|
||||
const startChar = message[startIndex];
|
||||
const endChar = startChar === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
|
||||
for (let i = startIndex; i < message.length; i++) {
|
||||
const char = message[i];
|
||||
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) continue;
|
||||
|
||||
if (char === startChar) {
|
||||
depth++;
|
||||
} else if (char === endChar) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
// 找到匹配的结束符
|
||||
const possibleJson = message.substring(startIndex, i + 1);
|
||||
try {
|
||||
JSON.parse(possibleJson);
|
||||
return {
|
||||
prefix: message.substring(0, startIndex).trim(),
|
||||
json: possibleJson,
|
||||
suffix: message.substring(i + 1).trim()
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const parsedLogsCache = useMemo(() => {
|
||||
const cache = parsedLogsCacheRef.current;
|
||||
|
||||
// 只处理新增的日志
|
||||
for (const log of logs) {
|
||||
// 如果已经缓存过,跳过
|
||||
if (cache.has(log.id)) continue;
|
||||
|
||||
try {
|
||||
JSON.parse(log.message);
|
||||
cache.set(log.id, {
|
||||
isJSON: true,
|
||||
jsonStr: log.message,
|
||||
extracted: null
|
||||
});
|
||||
} catch {
|
||||
const extracted = extractJSON(log.message);
|
||||
if (extracted) {
|
||||
try {
|
||||
JSON.parse(extracted.json);
|
||||
cache.set(log.id, {
|
||||
isJSON: true,
|
||||
jsonStr: extracted.json,
|
||||
extracted
|
||||
});
|
||||
} catch {
|
||||
cache.set(log.id, {
|
||||
isJSON: false,
|
||||
extracted
|
||||
});
|
||||
}
|
||||
} else {
|
||||
cache.set(log.id, {
|
||||
isJSON: false,
|
||||
extracted: null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理不再需要的缓存(日志被删除)
|
||||
const logIds = new Set(logs.map((log) => log.id));
|
||||
for (const cachedId of cache.keys()) {
|
||||
if (!logIds.has(cachedId)) {
|
||||
cache.delete(cachedId);
|
||||
}
|
||||
}
|
||||
|
||||
return cache;
|
||||
}, [logs, extractJSON]);
|
||||
|
||||
// 过滤日志
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter((log) => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
@@ -337,25 +211,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
});
|
||||
}, [logs, levelFilter, showRemoteOnly, filter]);
|
||||
|
||||
const toggleLogExpand = (logId: number) => {
|
||||
const newExpanded = new Set(expandedLogs);
|
||||
if (newExpanded.has(logId)) {
|
||||
newExpanded.delete(logId);
|
||||
} else {
|
||||
newExpanded.add(logId);
|
||||
}
|
||||
setExpandedLogs(newExpanded);
|
||||
};
|
||||
|
||||
const openJsonViewer = (jsonStr: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
setJsonViewerData(parsed);
|
||||
} catch {
|
||||
console.error('Failed to parse JSON:', jsonStr);
|
||||
}
|
||||
};
|
||||
|
||||
// 统计各等级日志数量
|
||||
const levelCounts = useMemo(() => ({
|
||||
[LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length,
|
||||
[LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length,
|
||||
@@ -446,10 +302,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
<LogEntryItem
|
||||
key={log.id}
|
||||
log={log}
|
||||
isExpanded={expandedLogs.has(log.id)}
|
||||
onToggleExpand={toggleLogExpand}
|
||||
onOpenJsonViewer={openJsonViewer}
|
||||
parsedData={parsedLogsCache.get(log.id) || { isJSON: false }}
|
||||
onOpenJsonViewer={setJsonViewerData}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface FileTreeProps {
|
||||
|
||||
export interface FileTreeHandle {
|
||||
collapseAll: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }, ref) => {
|
||||
@@ -69,7 +70,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
collapseAll
|
||||
collapseAll,
|
||||
refresh: refreshTree
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,149 +1,10 @@
|
||||
import { useCallback, ReactNode, useRef, useEffect, useState } from 'react';
|
||||
import { Layout, Model, TabNode, IJsonModel, Actions, IJsonTabSetNode, IJsonRowNode, Action, IJsonTabNode, DockLocation } from 'flexlayout-react';
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { Layout, Model, TabNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
|
||||
import 'flexlayout-react/style/light.css';
|
||||
import '../styles/FlexLayoutDock.css';
|
||||
import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
|
||||
|
||||
/**
|
||||
* 合并保存的布局和新的默认布局
|
||||
* 保留用户的布局调整(大小、位置等),同时添加新面板并移除已关闭的面板
|
||||
*/
|
||||
function mergeLayouts(savedLayout: IJsonModel, defaultLayout: IJsonModel, currentPanels: FlexDockPanel[]): IJsonModel {
|
||||
// 获取当前所有面板ID
|
||||
const currentPanelIds = new Set(currentPanels.map(p => p.id));
|
||||
|
||||
// 收集保存布局中存在的面板ID
|
||||
const savedPanelIds = new Set<string>();
|
||||
const collectPanelIds = (node: any) => {
|
||||
if (node.type === 'tab' && node.id) {
|
||||
savedPanelIds.add(node.id);
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach((child: any) => collectPanelIds(child));
|
||||
}
|
||||
};
|
||||
collectPanelIds(savedLayout.layout);
|
||||
|
||||
// 同时收集borders中的面板ID
|
||||
if (savedLayout.borders) {
|
||||
savedLayout.borders.forEach((border: any) => {
|
||||
if (border.children) {
|
||||
collectPanelIds({ children: border.children });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 找出新增的面板和已移除的面板
|
||||
const newPanelIds = Array.from(currentPanelIds).filter(id => !savedPanelIds.has(id));
|
||||
const removedPanelIds = Array.from(savedPanelIds).filter(id => !currentPanelIds.has(id));
|
||||
|
||||
// 克隆保存的布局
|
||||
const mergedLayout = JSON.parse(JSON.stringify(savedLayout));
|
||||
|
||||
// 确保borders为空(不保留最小化状态)
|
||||
if (mergedLayout.borders) {
|
||||
mergedLayout.borders = mergedLayout.borders.map((border: any) => ({
|
||||
...border,
|
||||
children: []
|
||||
}));
|
||||
}
|
||||
|
||||
// 第一步:移除已关闭的面板
|
||||
if (removedPanelIds.length > 0) {
|
||||
const removePanels = (node: any): boolean => {
|
||||
if (!node.children) return false;
|
||||
|
||||
// 过滤掉已移除的tab
|
||||
if (node.type === 'tabset' || node.type === 'row') {
|
||||
const originalLength = node.children.length;
|
||||
node.children = node.children.filter((child: any) => {
|
||||
if (child.type === 'tab') {
|
||||
return !removedPanelIds.includes(child.id);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 如果有tab被移除,调整selected索引
|
||||
if (node.type === 'tabset' && node.children.length < originalLength) {
|
||||
if (node.selected >= node.children.length) {
|
||||
node.selected = Math.max(0, node.children.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
node.children.forEach((child: any) => removePanels(child));
|
||||
|
||||
return node.children.length < originalLength;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
removePanels(mergedLayout.layout);
|
||||
}
|
||||
|
||||
// 第二步:如果没有新面板,直接返回清理后的布局
|
||||
if (newPanelIds.length === 0) {
|
||||
return mergedLayout;
|
||||
}
|
||||
|
||||
// 第三步:在默认布局中找到新面板的配置
|
||||
const newPanelTabs: IJsonTabNode[] = [];
|
||||
const findNewPanels = (node: any) => {
|
||||
if (node.type === 'tab' && node.id && newPanelIds.includes(node.id)) {
|
||||
newPanelTabs.push(node);
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach((child: any) => findNewPanels(child));
|
||||
}
|
||||
};
|
||||
findNewPanels(defaultLayout.layout);
|
||||
|
||||
// 第四步:将新面板添加到中心区域的第一个tabset
|
||||
const addNewPanelsToCenter = (node: any): boolean => {
|
||||
if (node.type === 'tabset') {
|
||||
// 检查是否是中心区域的tabset(通过检查是否包含非hierarchy/asset/inspector/console面板)
|
||||
const hasNonSidePanel = node.children?.some((child: any) => {
|
||||
const id = child.id || '';
|
||||
return !id.includes('hierarchy') &&
|
||||
!id.includes('asset') &&
|
||||
!id.includes('inspector') &&
|
||||
!id.includes('console');
|
||||
});
|
||||
|
||||
if (hasNonSidePanel && node.children) {
|
||||
// 添加新面板到这个tabset
|
||||
node.children.push(...newPanelTabs);
|
||||
// 选中最后添加的面板
|
||||
node.selected = node.children.length - 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
if (addNewPanelsToCenter(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// 尝试添加新面板到中心区域
|
||||
if (!addNewPanelsToCenter(mergedLayout.layout)) {
|
||||
// 如果没有找到合适的tabset,使用默认布局
|
||||
return defaultLayout;
|
||||
}
|
||||
|
||||
return mergedLayout;
|
||||
}
|
||||
|
||||
export interface FlexDockPanel {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
closable?: boolean;
|
||||
}
|
||||
export type { FlexDockPanel };
|
||||
|
||||
interface FlexLayoutDockContainerProps {
|
||||
panels: FlexDockPanel[];
|
||||
@@ -158,170 +19,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const createDefaultLayout = useCallback((): IJsonModel => {
|
||||
const hierarchyPanels = panels.filter((p) => p.id.includes('hierarchy'));
|
||||
const assetPanels = panels.filter((p) => p.id.includes('asset'));
|
||||
const rightPanels = panels.filter((p) => p.id.includes('inspector'));
|
||||
const bottomPanels = panels.filter((p) => p.id.includes('console'));
|
||||
const centerPanels = panels.filter((p) =>
|
||||
!hierarchyPanels.includes(p) && !assetPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
|
||||
);
|
||||
|
||||
// Build center column children
|
||||
const centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
if (centerPanels.length > 0) {
|
||||
// 找到要激活的tab的索引
|
||||
let activeTabIndex = 0;
|
||||
if (activePanelId) {
|
||||
const index = centerPanels.findIndex((p) => p.id === activePanelId);
|
||||
if (index !== -1) {
|
||||
activeTabIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
centerColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 70,
|
||||
selected: activeTabIndex,
|
||||
enableMaximize: true,
|
||||
children: centerPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (bottomPanels.length > 0) {
|
||||
centerColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 30,
|
||||
enableMaximize: true,
|
||||
children: bottomPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// Build main row children
|
||||
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
|
||||
// 左侧列:场景层级和资产面板垂直排列(五五分)
|
||||
if (hierarchyPanels.length > 0 || assetPanels.length > 0) {
|
||||
const leftColumnChildren: IJsonTabSetNode[] = [];
|
||||
|
||||
if (hierarchyPanels.length > 0) {
|
||||
leftColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 50,
|
||||
enableMaximize: true,
|
||||
children: hierarchyPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
if (assetPanels.length > 0) {
|
||||
leftColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 50,
|
||||
enableMaximize: true,
|
||||
children: assetPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: 20,
|
||||
children: leftColumnChildren
|
||||
});
|
||||
}
|
||||
if (centerColumnChildren.length > 0) {
|
||||
if (centerColumnChildren.length === 1) {
|
||||
const centerChild = centerColumnChildren[0];
|
||||
if (centerChild && centerChild.type === 'tabset') {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 60,
|
||||
enableMaximize: true,
|
||||
children: centerChild.children
|
||||
} as IJsonTabSetNode);
|
||||
} else if (centerChild) {
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: 60,
|
||||
children: centerChild.children
|
||||
} as IJsonRowNode);
|
||||
}
|
||||
} else {
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: 60,
|
||||
children: centerColumnChildren
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rightPanels.length > 0) {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 20,
|
||||
enableMaximize: true,
|
||||
children: rightPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
global: {
|
||||
tabEnableClose: true,
|
||||
tabEnableRename: false,
|
||||
tabSetEnableMaximize: true,
|
||||
tabSetEnableDrop: true,
|
||||
tabSetEnableDrag: true,
|
||||
tabSetEnableDivide: true,
|
||||
borderEnableDrop: true,
|
||||
borderAutoSelectTabWhenOpen: true,
|
||||
borderAutoSelectTabWhenClosed: true
|
||||
},
|
||||
borders: [
|
||||
{
|
||||
type: 'border',
|
||||
location: 'bottom',
|
||||
size: 200,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
type: 'border',
|
||||
location: 'right',
|
||||
size: 300,
|
||||
children: []
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
type: 'row',
|
||||
weight: 100,
|
||||
children: mainRowChildren
|
||||
}
|
||||
};
|
||||
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
|
||||
}, [panels, activePanelId]);
|
||||
|
||||
const [model, setModel] = useState<Model>(() => {
|
||||
@@ -440,7 +138,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
if (previousLayoutJsonRef.current && previousIds) {
|
||||
try {
|
||||
const savedLayout = JSON.parse(previousLayoutJsonRef.current);
|
||||
const mergedLayout = mergeLayouts(savedLayout, defaultLayout, panels);
|
||||
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
|
||||
const newModel = Model.fromJson(mergedLayout);
|
||||
setModel(newModel);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState } from 'react';
|
||||
import { Github, AlertCircle, CheckCircle, Loader, ExternalLink } from 'lucide-react';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import '../styles/GitHubAuth.css';
|
||||
|
||||
interface GitHubAuthProps {
|
||||
githubService: GitHubService;
|
||||
onSuccess: () => void;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function GitHubAuth({ githubService, onSuccess, locale }: GitHubAuthProps) {
|
||||
const [useOAuth, setUseOAuth] = useState(true);
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [verificationUri, setVerificationUri] = useState('');
|
||||
const [authStatus, setAuthStatus] = useState<'idle' | 'pending' | 'authorized' | 'error'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
githubLogin: 'GitHub 登录',
|
||||
oauthLogin: 'OAuth 登录(推荐)',
|
||||
tokenLogin: 'Token 登录',
|
||||
oauthStep1: '1. 点击"开始授权"按钮',
|
||||
oauthStep2: '2. 在浏览器中打开 GitHub 授权页面',
|
||||
oauthStep3: '3. 输入下方显示的代码并授权',
|
||||
startAuth: '开始授权',
|
||||
authorizing: '等待授权中...',
|
||||
authorized: '授权成功!',
|
||||
authFailed: '授权失败',
|
||||
userCode: '授权码',
|
||||
copyCode: '复制代码',
|
||||
openBrowser: '打开浏览器',
|
||||
tokenLabel: 'GitHub Personal Access Token',
|
||||
tokenPlaceholder: '粘贴你的 GitHub Token',
|
||||
tokenHint: '需要 repo 和 workflow 权限',
|
||||
createToken: '创建 Token',
|
||||
login: '登录',
|
||||
back: '返回'
|
||||
},
|
||||
en: {
|
||||
githubLogin: 'GitHub Login',
|
||||
oauthLogin: 'OAuth Login (Recommended)',
|
||||
tokenLogin: 'Token Login',
|
||||
oauthStep1: '1. Click "Start Authorization"',
|
||||
oauthStep2: '2. Open GitHub authorization page in browser',
|
||||
oauthStep3: '3. Enter the code shown below and authorize',
|
||||
startAuth: 'Start Authorization',
|
||||
authorizing: 'Waiting for authorization...',
|
||||
authorized: 'Authorized!',
|
||||
authFailed: 'Authorization failed',
|
||||
userCode: 'Authorization Code',
|
||||
copyCode: 'Copy Code',
|
||||
openBrowser: 'Open Browser',
|
||||
tokenLabel: 'GitHub Personal Access Token',
|
||||
tokenPlaceholder: 'Paste your GitHub Token',
|
||||
tokenHint: 'Requires repo and workflow permissions',
|
||||
createToken: 'Create Token',
|
||||
login: 'Login',
|
||||
back: 'Back'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
setAuthStatus('pending');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
console.log('[GitHubAuth] Starting OAuth login...');
|
||||
|
||||
const deviceCodeResp = await githubService.requestDeviceCode();
|
||||
console.log('[GitHubAuth] Device code received:', deviceCodeResp.user_code);
|
||||
|
||||
setUserCode(deviceCodeResp.user_code);
|
||||
setVerificationUri(deviceCodeResp.verification_uri);
|
||||
|
||||
console.log('[GitHubAuth] Opening browser...');
|
||||
await open(deviceCodeResp.verification_uri);
|
||||
|
||||
console.log('[GitHubAuth] Starting authentication polling...');
|
||||
await githubService.authenticateWithDeviceFlow(
|
||||
deviceCodeResp.device_code,
|
||||
deviceCodeResp.interval,
|
||||
(status) => {
|
||||
console.log('[GitHubAuth] Auth status changed:', status);
|
||||
setAuthStatus(status === 'pending' ? 'pending' : status === 'authorized' ? 'authorized' : 'error');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[GitHubAuth] Authorization successful!');
|
||||
setAuthStatus('authorized');
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('[GitHubAuth] OAuth failed:', err);
|
||||
setAuthStatus('error');
|
||||
const errorMessage = err instanceof Error ? err.message : 'OAuth authorization failed';
|
||||
const fullError = err instanceof Error && err.stack ? `${errorMessage}\n\nDetails: ${err.stack}` : errorMessage;
|
||||
setError(fullError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenAuth = async () => {
|
||||
if (!githubToken.trim()) {
|
||||
setError(locale === 'zh' ? '请输入 Token' : 'Please enter a token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await githubService.authenticate(githubToken);
|
||||
setError('');
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('[GitHubAuth] Token auth failed:', err);
|
||||
setError(locale === 'zh' ? '认证失败,请检查你的 Token' : 'Authentication failed. Please check your token.');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateTokenPage = async () => {
|
||||
await githubService.openAuthorizationPage();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="github-auth">
|
||||
<Github size={48} style={{ color: '#0366d6' }} />
|
||||
<p>{t('githubLogin')}</p>
|
||||
|
||||
<div className="auth-tabs">
|
||||
<button
|
||||
className={`auth-tab ${useOAuth ? 'active' : ''}`}
|
||||
onClick={() => setUseOAuth(true)}
|
||||
>
|
||||
{t('oauthLogin')}
|
||||
</button>
|
||||
<button
|
||||
className={`auth-tab ${!useOAuth ? 'active' : ''}`}
|
||||
onClick={() => setUseOAuth(false)}
|
||||
>
|
||||
{t('tokenLogin')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useOAuth ? (
|
||||
<div className="oauth-auth">
|
||||
{authStatus === 'idle' && (
|
||||
<>
|
||||
<div className="oauth-instructions">
|
||||
<p>{t('oauthStep1')}</p>
|
||||
<p>{t('oauthStep2')}</p>
|
||||
<p>{t('oauthStep3')}</p>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={handleOAuthLogin}>
|
||||
<Github size={16} />
|
||||
{t('startAuth')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authStatus === 'pending' && (
|
||||
<div className="oauth-pending">
|
||||
<Loader size={48} className="spinning" style={{ color: '#0366d6' }} />
|
||||
<h4>{t('authorizing')}</h4>
|
||||
|
||||
{userCode && (
|
||||
<div className="user-code-display">
|
||||
<label>{t('userCode')}</label>
|
||||
<div className="code-box">
|
||||
<span className="code-text">{userCode}</span>
|
||||
<button
|
||||
className="btn-copy"
|
||||
onClick={() => copyToClipboard(userCode)}
|
||||
title={t('copyCode')}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(verificationUri)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('openBrowser')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authStatus === 'authorized' && (
|
||||
<div className="oauth-success">
|
||||
<CheckCircle size={48} style={{ color: '#34c759' }} />
|
||||
<h4>{t('authorized')}</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authStatus === 'error' && (
|
||||
<div className="oauth-error">
|
||||
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
|
||||
<h4>{t('authFailed')}</h4>
|
||||
{error && (
|
||||
<div className="error-details">
|
||||
<pre>{error}</pre>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-secondary" onClick={() => setAuthStatus('idle')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="token-auth">
|
||||
<div className="form-group">
|
||||
<label>{t('tokenLabel')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={githubToken}
|
||||
onChange={(e) => setGithubToken(e.target.value)}
|
||||
placeholder={t('tokenPlaceholder')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleTokenAuth();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('tokenHint')}</small>
|
||||
</div>
|
||||
|
||||
<button className="btn-link" onClick={openCreateTokenPage}>
|
||||
<ExternalLink size={14} />
|
||||
{t('createToken')}
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={handleTokenAuth}>
|
||||
{t('login')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !useOAuth && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import { GitHubAuth } from './GitHubAuth';
|
||||
import '../styles/GitHubLoginDialog.css';
|
||||
|
||||
interface GitHubLoginDialogProps {
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function GitHubLoginDialog({ githubService, onClose, locale }: GitHubLoginDialogProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: 'GitHub 登录'
|
||||
},
|
||||
en: {
|
||||
title: 'GitHub Login'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="github-login-overlay" onClick={onClose}>
|
||||
<div className="github-login-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="github-login-header">
|
||||
<h2>{t('title')}</h2>
|
||||
<button className="github-login-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="github-login-content">
|
||||
<GitHubAuth
|
||||
githubService={githubService}
|
||||
onSuccess={onClose}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ interface MenuBarProps {
|
||||
onToggleDevtools?: () => void;
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
}
|
||||
|
||||
export function MenuBar({
|
||||
@@ -53,7 +54,8 @@ export function MenuBar({
|
||||
onOpenSettings,
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
}: MenuBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -147,6 +149,7 @@ export function MenuBar({
|
||||
pluginManager: 'Plugin Manager',
|
||||
tools: 'Tools',
|
||||
createPlugin: 'Create Plugin',
|
||||
reloadPlugins: 'Reload Plugins',
|
||||
portManager: 'Port Manager',
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
@@ -181,6 +184,7 @@ export function MenuBar({
|
||||
pluginManager: '插件管理器',
|
||||
tools: '工具',
|
||||
createPlugin: '创建插件',
|
||||
reloadPlugins: '重新加载插件',
|
||||
portManager: '端口管理器',
|
||||
settings: '设置',
|
||||
help: '帮助',
|
||||
@@ -231,6 +235,7 @@ export function MenuBar({
|
||||
],
|
||||
tools: [
|
||||
{ label: t('createPlugin'), onClick: onCreatePlugin },
|
||||
{ label: t('reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
||||
{ separator: true },
|
||||
{ label: t('portManager'), onClick: onOpenPortManager },
|
||||
{ separator: true },
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight, X, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Search,
|
||||
Grid,
|
||||
List,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
X,
|
||||
RefreshCw,
|
||||
ShoppingCart
|
||||
} from 'lucide-react';
|
||||
import { PluginMarketPanel } from './PluginMarketPanel';
|
||||
import { PluginMarketService } from '../services/PluginMarketService';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import '../styles/PluginManagerWindow.css';
|
||||
|
||||
interface PluginManagerWindowProps {
|
||||
pluginManager: EditorPluginManager;
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
onOpen?: () => void;
|
||||
locale: string;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
const categoryIcons: Record<EditorPluginCategory, string> = {
|
||||
@@ -20,7 +37,7 @@ const categoryIcons: Record<EditorPluginCategory, string> = {
|
||||
[EditorPluginCategory.ImportExport]: 'Package'
|
||||
};
|
||||
|
||||
export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen, locale }: PluginManagerWindowProps) {
|
||||
export function PluginManagerWindow({ pluginManager, githubService, onClose, onRefresh, onOpen, locale, projectPath }: PluginManagerWindowProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
@@ -43,7 +60,9 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
categoryWindows: '窗口',
|
||||
categoryInspectors: '检查器',
|
||||
categorySystem: '系统',
|
||||
categoryImportExport: '导入/导出'
|
||||
categoryImportExport: '导入/导出',
|
||||
tabInstalled: '已安装',
|
||||
tabMarketplace: '插件市场'
|
||||
},
|
||||
en: {
|
||||
title: 'Plugin Manager',
|
||||
@@ -65,7 +84,9 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
categoryWindows: 'Windows',
|
||||
categoryInspectors: 'Inspectors',
|
||||
categorySystem: 'System',
|
||||
categoryImportExport: 'Import/Export'
|
||||
categoryImportExport: 'Import/Export',
|
||||
tabInstalled: 'Installed',
|
||||
tabMarketplace: 'Marketplace'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
@@ -81,6 +102,7 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
};
|
||||
return t(categoryKeys[category]);
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState<'installed' | 'marketplace'>('installed');
|
||||
const [plugins, setPlugins] = useState<IEditorPluginMetadata[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
||||
@@ -89,6 +111,13 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const marketService = useMemo(() => new PluginMarketService(pluginManager), [pluginManager]);
|
||||
|
||||
// 设置项目路径到 marketService
|
||||
useEffect(() => {
|
||||
marketService.setProjectPath(projectPath);
|
||||
}, [projectPath, marketService]);
|
||||
|
||||
const updatePluginList = () => {
|
||||
const allPlugins = pluginManager.getAllPluginMetadata();
|
||||
setPlugins(allPlugins);
|
||||
@@ -154,13 +183,16 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
);
|
||||
});
|
||||
|
||||
const pluginsByCategory = filteredPlugins.reduce((acc, plugin) => {
|
||||
if (!acc[plugin.category]) {
|
||||
acc[plugin.category] = [];
|
||||
}
|
||||
acc[plugin.category].push(plugin);
|
||||
return acc;
|
||||
}, {} as Record<EditorPluginCategory, IEditorPluginMetadata[]>);
|
||||
const pluginsByCategory = filteredPlugins.reduce(
|
||||
(acc, plugin) => {
|
||||
if (!acc[plugin.category]) {
|
||||
acc[plugin.category] = [];
|
||||
}
|
||||
acc[plugin.category].push(plugin);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<EditorPluginCategory, IEditorPluginMetadata[]>
|
||||
);
|
||||
|
||||
const enabledCount = plugins.filter((p) => p.enabled).length;
|
||||
const disabledCount = plugins.filter((p) => !p.enabled).length;
|
||||
@@ -185,9 +217,7 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<div className="plugin-card-description">{plugin.description}</div>
|
||||
)}
|
||||
{plugin.description && <div className="plugin-card-description">{plugin.description}</div>}
|
||||
<div className="plugin-card-footer">
|
||||
<span className="plugin-card-category">
|
||||
{(() => {
|
||||
@@ -218,9 +248,7 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
{plugin.displayName}
|
||||
<span className="plugin-list-version">v{plugin.version}</span>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<div className="plugin-list-description">{plugin.description}</div>
|
||||
)}
|
||||
{plugin.description && <div className="plugin-list-description">{plugin.description}</div>}
|
||||
</div>
|
||||
<div className="plugin-list-status">
|
||||
{plugin.enabled ? (
|
||||
@@ -253,118 +281,157 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plugin-toolbar">
|
||||
<div className="plugin-toolbar-left">
|
||||
<div className="plugin-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-toolbar-right">
|
||||
<div className="plugin-stats">
|
||||
<span className="stat-item enabled">
|
||||
<CheckCircle size={14} />
|
||||
{enabledCount} {t('enabled')}
|
||||
</span>
|
||||
<span className="stat-item disabled">
|
||||
<XCircle size={14} />
|
||||
{disabledCount} {t('disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
className="plugin-refresh-btn"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
title={t('refreshPluginList')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: isRefreshing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
opacity: isRefreshing ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} className={isRefreshing ? 'spinning' : ''} />
|
||||
{t('refresh')}
|
||||
</button>
|
||||
)}
|
||||
<div className="plugin-view-mode">
|
||||
<button
|
||||
className={viewMode === 'list' ? 'active' : ''}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('listView')}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'grid' ? 'active' : ''}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title={t('gridView')}
|
||||
>
|
||||
<Grid size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-manager-tabs">
|
||||
<button
|
||||
className={`plugin-manager-tab ${activeTab === 'installed' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('installed')}
|
||||
>
|
||||
<Package size={16} />
|
||||
{t('tabInstalled')}
|
||||
</button>
|
||||
<button
|
||||
className={`plugin-manager-tab ${activeTab === 'marketplace' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('marketplace')}
|
||||
>
|
||||
<ShoppingCart size={16} />
|
||||
{t('tabMarketplace')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plugin-content">
|
||||
{plugins.length === 0 ? (
|
||||
<div className="plugin-empty">
|
||||
<Package size={48} />
|
||||
<p>{t('noPlugins')}</p>
|
||||
{activeTab === 'installed' && (
|
||||
<>
|
||||
<div className="plugin-toolbar">
|
||||
<div className="plugin-toolbar-left">
|
||||
<div className="plugin-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-toolbar-right">
|
||||
<div className="plugin-stats">
|
||||
<span className="stat-item enabled">
|
||||
<CheckCircle size={14} />
|
||||
{enabledCount} {t('enabled')}
|
||||
</span>
|
||||
<span className="stat-item disabled">
|
||||
<XCircle size={14} />
|
||||
{disabledCount} {t('disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
className="plugin-refresh-btn"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
title={t('refreshPluginList')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: isRefreshing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
opacity: isRefreshing ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} className={isRefreshing ? 'spinning' : ''} />
|
||||
{t('refresh')}
|
||||
</button>
|
||||
)}
|
||||
<div className="plugin-view-mode">
|
||||
<button
|
||||
className={viewMode === 'list' ? 'active' : ''}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('listView')}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'grid' ? 'active' : ''}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title={t('gridView')}
|
||||
>
|
||||
<Grid size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-categories">
|
||||
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
|
||||
const cat = category as EditorPluginCategory;
|
||||
const isExpanded = expandedCategories.has(cat);
|
||||
|
||||
return (
|
||||
<div key={category} className="plugin-category">
|
||||
<div
|
||||
className="plugin-category-header"
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
<button className="plugin-category-toggle">
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<span className="plugin-category-icon">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
|
||||
return CategoryIcon ? <CategoryIcon size={16} /> : null;
|
||||
})()}
|
||||
</span>
|
||||
<span className="plugin-category-name">{getCategoryName(cat)}</span>
|
||||
<span className="plugin-category-count">
|
||||
{categoryPlugins.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="plugin-content"
|
||||
style={{ display: activeTab === 'installed' ? 'block' : 'none' }}
|
||||
>
|
||||
{plugins.length === 0 ? (
|
||||
<div className="plugin-empty">
|
||||
<Package size={48} />
|
||||
<p>{t('noPlugins')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-categories">
|
||||
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
|
||||
const cat = category as EditorPluginCategory;
|
||||
const isExpanded = expandedCategories.has(cat);
|
||||
|
||||
{isExpanded && (
|
||||
<div className={`plugin-category-content ${viewMode}`}>
|
||||
{viewMode === 'grid'
|
||||
? categoryPlugins.map(renderPluginCard)
|
||||
: categoryPlugins.map(renderPluginList)}
|
||||
return (
|
||||
<div key={category} className="plugin-category">
|
||||
<div
|
||||
className="plugin-category-header"
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
<button className="plugin-category-toggle">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</button>
|
||||
<span className="plugin-category-icon">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[
|
||||
categoryIcons[cat]
|
||||
];
|
||||
return CategoryIcon ? <CategoryIcon size={16} /> : null;
|
||||
})()}
|
||||
</span>
|
||||
<span className="plugin-category-name">{getCategoryName(cat)}</span>
|
||||
<span className="plugin-category-count">
|
||||
{categoryPlugins.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={`plugin-category-content ${viewMode}`}>
|
||||
{viewMode === 'grid'
|
||||
? categoryPlugins.map(renderPluginCard)
|
||||
: categoryPlugins.map(renderPluginList)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'marketplace' && (
|
||||
<PluginMarketPanel
|
||||
marketService={marketService}
|
||||
locale={locale}
|
||||
projectPath={projectPath}
|
||||
onReloadPlugins={onRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Package,
|
||||
Search,
|
||||
Download,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Star,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import type { PluginMarketService, PluginMarketMetadata } from '../services/PluginMarketService';
|
||||
import '../styles/PluginMarketPanel.css';
|
||||
|
||||
interface PluginMarketPanelProps {
|
||||
marketService: PluginMarketService;
|
||||
locale: string;
|
||||
projectPath: string | null;
|
||||
onReloadPlugins?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function PluginMarketPanel({ marketService, locale, projectPath, onReloadPlugins }: PluginMarketPanelProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '插件市场',
|
||||
searchPlaceholder: '搜索插件...',
|
||||
loading: '加载中...',
|
||||
loadError: '无法连接到插件市场',
|
||||
loadErrorDesc: '可能是网络连接问题,请检查您的网络设置后重试',
|
||||
retry: '重试',
|
||||
noPlugins: '没有找到插件',
|
||||
install: '安装',
|
||||
installed: '已安装',
|
||||
update: '更新',
|
||||
uninstall: '卸载',
|
||||
viewSource: '查看源码',
|
||||
official: '官方',
|
||||
verified: '认证',
|
||||
community: '社区',
|
||||
filterAll: '全部',
|
||||
filterOfficial: '官方插件',
|
||||
filterCommunity: '社区插件',
|
||||
categoryAll: '全部分类',
|
||||
installing: '安装中...',
|
||||
uninstalling: '卸载中...',
|
||||
useDirectSource: '使用直连源',
|
||||
useDirectSourceTip: '启用后直接从GitHub获取数据,绕过CDN缓存(适合测试)',
|
||||
latest: '最新',
|
||||
releaseNotes: '更新日志',
|
||||
selectVersion: '选择版本',
|
||||
noProjectOpen: '请先打开一个项目'
|
||||
},
|
||||
en: {
|
||||
title: 'Plugin Marketplace',
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
loading: 'Loading...',
|
||||
loadError: 'Unable to connect to plugin marketplace',
|
||||
loadErrorDesc: 'This might be a network connection issue. Please check your network settings and try again',
|
||||
retry: 'Retry',
|
||||
noPlugins: 'No plugins found',
|
||||
install: 'Install',
|
||||
installed: 'Installed',
|
||||
update: 'Update',
|
||||
uninstall: 'Uninstall',
|
||||
viewSource: 'View Source',
|
||||
official: 'Official',
|
||||
verified: 'Verified',
|
||||
community: 'Community',
|
||||
filterAll: 'All',
|
||||
filterOfficial: 'Official',
|
||||
filterCommunity: 'Community',
|
||||
categoryAll: 'All Categories',
|
||||
installing: 'Installing...',
|
||||
uninstalling: 'Uninstalling...',
|
||||
useDirectSource: 'Direct Source',
|
||||
useDirectSourceTip: 'Fetch data directly from GitHub, bypassing CDN cache (for testing)',
|
||||
latest: 'Latest',
|
||||
releaseNotes: 'Release Notes',
|
||||
selectVersion: 'Select Version',
|
||||
noProjectOpen: 'Please open a project first'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const [plugins, setPlugins] = useState<PluginMarketMetadata[]>([]);
|
||||
const [filteredPlugins, setFilteredPlugins] = useState<PluginMarketMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'official' | 'community'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set());
|
||||
const [useDirectSource, setUseDirectSource] = useState(marketService.isUsingDirectSource());
|
||||
|
||||
useEffect(() => {
|
||||
loadPlugins();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterPlugins();
|
||||
}, [plugins, searchQuery, typeFilter, categoryFilter]);
|
||||
|
||||
const loadPlugins = async (bypassCache: boolean = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const pluginList = await marketService.fetchPluginList(bypassCache);
|
||||
setPlugins(pluginList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterPlugins = () => {
|
||||
let filtered = plugins;
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.description.toLowerCase().includes(query) ||
|
||||
p.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter((p) => p.category_type === typeFilter);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
filtered = filtered.filter((p) => p.category === categoryFilter);
|
||||
}
|
||||
|
||||
setFilteredPlugins(filtered);
|
||||
};
|
||||
|
||||
const handleToggleDirectSource = () => {
|
||||
const newValue = !useDirectSource;
|
||||
setUseDirectSource(newValue);
|
||||
marketService.setUseDirectSource(newValue);
|
||||
loadPlugins(true);
|
||||
};
|
||||
|
||||
const handleInstall = async (plugin: PluginMarketMetadata, version?: string) => {
|
||||
if (!projectPath) {
|
||||
alert(t('noProjectOpen') || 'Please open a project first');
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
|
||||
|
||||
try {
|
||||
await marketService.installPlugin(plugin, version, onReloadPlugins);
|
||||
setPlugins([...plugins]);
|
||||
} catch (error) {
|
||||
console.error('Failed to install plugin:', error);
|
||||
alert(`Failed to install ${plugin.name}: ${error}`);
|
||||
} finally {
|
||||
setInstallingPlugins((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(plugin.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (plugin: PluginMarketMetadata) => {
|
||||
if (!confirm(`Are you sure you want to uninstall ${plugin.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
|
||||
|
||||
try {
|
||||
await marketService.uninstallPlugin(plugin.id, onReloadPlugins);
|
||||
setPlugins([...plugins]);
|
||||
} catch (error) {
|
||||
console.error('Failed to uninstall plugin:', error);
|
||||
alert(`Failed to uninstall ${plugin.name}: ${error}`);
|
||||
} finally {
|
||||
setInstallingPlugins((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(plugin.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(plugins.map((p) => p.category)))];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="plugin-market-loading">
|
||||
<RefreshCw size={32} className="spinning" />
|
||||
<p>{t('loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="plugin-market-error">
|
||||
<AlertCircle size={64} className="error-icon" />
|
||||
<h3>{t('loadError')}</h3>
|
||||
<p className="error-description">{t('loadErrorDesc')}</p>
|
||||
<div className="error-details">
|
||||
<p className="error-message">{error}</p>
|
||||
</div>
|
||||
<button className="retry-button" onClick={() => loadPlugins(true)}>
|
||||
<RefreshCw size={16} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plugin-market-panel">
|
||||
<div className="plugin-market-toolbar">
|
||||
<div className="plugin-market-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="plugin-market-filters">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as any)}
|
||||
className="plugin-market-filter-select"
|
||||
>
|
||||
<option value="all">{t('filterAll')}</option>
|
||||
<option value="official">{t('filterOfficial')}</option>
|
||||
<option value="community">{t('filterCommunity')}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="plugin-market-filter-select"
|
||||
>
|
||||
<option value="all">{t('categoryAll')}</option>
|
||||
{categories
|
||||
.filter((c) => c !== 'all')
|
||||
.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="plugin-market-direct-source-toggle" title={t('useDirectSourceTip')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useDirectSource}
|
||||
onChange={handleToggleDirectSource}
|
||||
/>
|
||||
<span className="toggle-label">{t('useDirectSource')}</span>
|
||||
</label>
|
||||
|
||||
<button className="plugin-market-refresh" onClick={() => loadPlugins(true)} title={t('retry')}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-market-content">
|
||||
{filteredPlugins.length === 0 ? (
|
||||
<div className="plugin-market-empty">
|
||||
<Package size={48} />
|
||||
<p>{t('noPlugins')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-market-grid">
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<PluginMarketCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
isInstalled={marketService.isInstalled(plugin.id)}
|
||||
hasUpdate={marketService.hasUpdate(plugin)}
|
||||
isInstalling={installingPlugins.has(plugin.id)}
|
||||
onInstall={(version) => handleInstall(plugin, version)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginMarketCardProps {
|
||||
plugin: PluginMarketMetadata;
|
||||
isInstalled: boolean;
|
||||
hasUpdate: boolean;
|
||||
isInstalling: boolean;
|
||||
onInstall: (version?: string) => void;
|
||||
onUninstall: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function PluginMarketCard({
|
||||
plugin,
|
||||
isInstalled,
|
||||
hasUpdate,
|
||||
isInstalling,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
t
|
||||
}: PluginMarketCardProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState(plugin.latestVersion);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : Package;
|
||||
|
||||
const selectedVersionData = plugin.versions.find((v) => v.version === selectedVersion);
|
||||
const multipleVersions = plugin.versions.length > 1;
|
||||
|
||||
return (
|
||||
<div className="plugin-market-card">
|
||||
<div className="plugin-market-card-header">
|
||||
<div className="plugin-market-card-icon">
|
||||
<IconComponent size={32} />
|
||||
</div>
|
||||
<div className="plugin-market-card-info">
|
||||
<div className="plugin-market-card-title">
|
||||
<span>{plugin.name}</span>
|
||||
{plugin.verified && (
|
||||
<span className="plugin-market-badge official" title={t('official')}>
|
||||
<CheckCircle size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="plugin-market-card-meta">
|
||||
<span className="plugin-market-card-author">
|
||||
<Github size={12} />
|
||||
{plugin.author.name}
|
||||
</span>
|
||||
{multipleVersions ? (
|
||||
<select
|
||||
className="plugin-market-version-select"
|
||||
value={selectedVersion}
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t('selectVersion')}
|
||||
>
|
||||
{plugin.versions.map((v) => (
|
||||
<option key={v.version} value={v.version}>
|
||||
v{v.version} {v.version === plugin.latestVersion ? `(${t('latest')})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="plugin-market-card-version">v{plugin.latestVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-market-card-description">{plugin.description}</div>
|
||||
|
||||
{selectedVersionData && selectedVersionData.changes && (
|
||||
<details className="plugin-market-version-changes">
|
||||
<summary>{t('releaseNotes')}</summary>
|
||||
<p>{selectedVersionData.changes}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{plugin.tags && plugin.tags.length > 0 && (
|
||||
<div className="plugin-market-card-tags">
|
||||
{plugin.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="plugin-market-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="plugin-market-card-footer">
|
||||
<button
|
||||
className="plugin-market-card-link"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await open(plugin.repository.url);
|
||||
} catch (error) {
|
||||
console.error('Failed to open URL:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('viewSource')}
|
||||
</button>
|
||||
|
||||
<div className="plugin-market-card-actions">
|
||||
{isInstalling ? (
|
||||
<button className="plugin-market-btn installing" disabled>
|
||||
<RefreshCw size={14} className="spinning" />
|
||||
{isInstalled ? t('uninstalling') : t('installing')}
|
||||
</button>
|
||||
) : isInstalled ? (
|
||||
<>
|
||||
{hasUpdate && (
|
||||
<button className="plugin-market-btn update" onClick={() => onInstall(plugin.latestVersion)}>
|
||||
<Download size={14} />
|
||||
{t('update')}
|
||||
</button>
|
||||
)}
|
||||
<button className="plugin-market-btn installed" onClick={onUninstall}>
|
||||
<CheckCircle size={14} />
|
||||
{t('uninstall')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="plugin-market-btn install" onClick={() => onInstall(selectedVersion)}>
|
||||
<Download size={14} />
|
||||
{t('install')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,948 @@
|
||||
import { useState } from 'react';
|
||||
import { X, AlertCircle, CheckCircle, Loader, ExternalLink, FolderOpen, FileArchive } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import { GitHubAuth } from './GitHubAuth';
|
||||
import { PluginPublishService, type PluginPublishInfo, type PublishProgress } from '../services/PluginPublishService';
|
||||
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
|
||||
import { PluginSourceParser, type ParsedPluginInfo } from '../services/PluginSourceParser';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { EditorPluginCategory, type IEditorPluginMetadata } from '@esengine/editor-core';
|
||||
import '../styles/PluginPublishWizard.css';
|
||||
|
||||
interface PluginPublishWizardProps {
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
locale: string;
|
||||
inline?: boolean; // 是否内联显示(在 tab 中)而不是弹窗
|
||||
}
|
||||
|
||||
type Step = 'auth' | 'selectSource' | 'info' | 'building' | 'confirm' | 'publishing' | 'success' | 'error';
|
||||
|
||||
type SourceType = 'folder' | 'zip';
|
||||
|
||||
function calculateNextVersion(currentVersion: string): string {
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
|
||||
|
||||
const [major, minor, patch] = parts;
|
||||
return `${major}.${minor}.${(patch ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export function PluginPublishWizard({ githubService, onClose, locale, inline = false }: PluginPublishWizardProps) {
|
||||
const [publishService] = useState(() => new PluginPublishService(githubService));
|
||||
const [buildService] = useState(() => new PluginBuildService());
|
||||
const [sourceParser] = useState(() => new PluginSourceParser());
|
||||
|
||||
const [step, setStep] = useState<Step>(githubService.isAuthenticated() ? 'selectSource' : 'auth');
|
||||
const [sourceType, setSourceType] = useState<SourceType | null>(null);
|
||||
const [parsedPluginInfo, setParsedPluginInfo] = useState<ParsedPluginInfo | null>(null);
|
||||
const [publishInfo, setPublishInfo] = useState<Partial<PluginPublishInfo>>({
|
||||
category: 'community',
|
||||
tags: []
|
||||
});
|
||||
const [prUrl, setPrUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [buildLog, setBuildLog] = useState<string[]>([]);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
|
||||
const [builtZipPath, setBuiltZipPath] = useState<string>('');
|
||||
const [existingPR, setExistingPR] = useState<{ number: number; url: string } | null>(null);
|
||||
const [existingVersions, setExistingVersions] = useState<string[]>([]);
|
||||
const [suggestedVersion, setSuggestedVersion] = useState<string>('');
|
||||
const [existingManifest, setExistingManifest] = useState<any>(null);
|
||||
const [isUpdate, setIsUpdate] = useState(false);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '发布插件到市场',
|
||||
updateTitle: '更新插件版本',
|
||||
stepAuth: '步骤 1: GitHub 登录',
|
||||
stepSelectSource: '步骤 2: 选择插件源',
|
||||
stepInfo: '步骤 3: 插件信息',
|
||||
stepInfoUpdate: '步骤 3: 版本更新',
|
||||
stepBuilding: '步骤 4: 构建打包',
|
||||
stepConfirm: '步骤 5: 确认发布',
|
||||
stepConfirmNoBuilding: '步骤 4: 确认发布',
|
||||
githubLogin: 'GitHub 登录',
|
||||
oauthLogin: 'OAuth 登录(推荐)',
|
||||
tokenLogin: 'Token 登录',
|
||||
oauthInstructions: '点击下方按钮开始授权:',
|
||||
oauthStep1: '1. 点击"开始授权"按钮',
|
||||
oauthStep2: '2. 在浏览器中打开 GitHub 授权页面',
|
||||
oauthStep3: '3. 输入下方显示的代码并授权',
|
||||
oauthStep4: '4. 授权完成后会自动跳转',
|
||||
startAuth: '开始授权',
|
||||
authorizing: '等待授权中...',
|
||||
authorized: '授权成功!',
|
||||
authFailed: '授权失败',
|
||||
userCode: '授权码',
|
||||
copyCode: '复制代码',
|
||||
openBrowser: '打开浏览器',
|
||||
tokenLabel: 'GitHub Personal Access Token',
|
||||
tokenPlaceholder: '粘贴你的 GitHub Token',
|
||||
tokenHint: '需要 repo 和 workflow 权限',
|
||||
createToken: '创建 Token',
|
||||
login: '登录',
|
||||
switchToToken: '使用 Token 登录',
|
||||
switchToOAuth: '使用 OAuth 登录',
|
||||
selectSource: '选择插件源',
|
||||
selectSourceDesc: '选择插件的来源类型',
|
||||
selectFolder: '选择源代码文件夹',
|
||||
selectFolderDesc: '选择包含你的插件源代码的文件夹(需要有 package.json,系统将自动构建)',
|
||||
selectZip: '选择 ZIP 文件',
|
||||
selectZipDesc: '选择已构建好的插件 ZIP 包(必须包含 package.json 和 dist 目录)',
|
||||
zipRequirements: 'ZIP 文件要求',
|
||||
zipStructure: 'ZIP 结构',
|
||||
zipStructureDetails: 'ZIP 文件必须包含以下内容:',
|
||||
zipFile1: 'package.json - 插件元数据',
|
||||
zipFile2: 'dist/ - 构建后的代码目录(包含 index.esm.js)',
|
||||
zipExample: '示例结构',
|
||||
zipBuildScript: '打包脚本',
|
||||
zipBuildScriptDesc: '可以使用以下命令打包:',
|
||||
recommendFolder: '💡 建议使用"源代码文件夹"方式,系统会自动构建',
|
||||
browseFolder: '浏览文件夹',
|
||||
browseZip: '浏览 ZIP 文件',
|
||||
selectedFolder: '已选择文件夹',
|
||||
selectedZip: '已选择 ZIP',
|
||||
sourceTypeFolder: '源代码文件夹',
|
||||
sourceTypeZip: 'ZIP 文件',
|
||||
pluginInfo: '插件信息',
|
||||
version: '版本号',
|
||||
currentVersion: '当前版本',
|
||||
suggestedVersion: '建议版本',
|
||||
versionHistory: '版本历史',
|
||||
updatePlugin: '更新插件',
|
||||
newPlugin: '新插件',
|
||||
category: '分类',
|
||||
official: '官方',
|
||||
community: '社区',
|
||||
repositoryUrl: '仓库地址',
|
||||
repositoryPlaceholder: 'https://github.com/username/repo',
|
||||
releaseNotes: '更新说明',
|
||||
releaseNotesPlaceholder: '描述这个版本的变更...',
|
||||
tags: '标签(逗号分隔)',
|
||||
tagsPlaceholder: 'ui, tool, editor',
|
||||
homepage: '主页 URL(可选)',
|
||||
next: '下一步',
|
||||
back: '上一步',
|
||||
build: '构建并打包',
|
||||
building: '构建中...',
|
||||
confirm: '确认发布',
|
||||
publishing: '发布中...',
|
||||
publishSuccess: '发布成功!',
|
||||
publishError: '发布失败',
|
||||
buildError: '构建失败',
|
||||
prCreated: 'Pull Request 已创建',
|
||||
viewPR: '查看 PR',
|
||||
close: '关闭',
|
||||
buildingStep1: '正在安装依赖...',
|
||||
buildingStep2: '正在构建项目...',
|
||||
buildingStep3: '正在打包 ZIP...',
|
||||
publishingStep1: '正在 Fork 仓库...',
|
||||
publishingStep2: '正在创建分支...',
|
||||
publishingStep3: '正在上传文件...',
|
||||
publishingStep4: '正在创建 Pull Request...',
|
||||
confirmMessage: '确认要发布以下插件?',
|
||||
reviewMessage: '你的插件提交已创建 PR,维护者将进行审核。审核通过后,插件将自动发布到市场。',
|
||||
existingPRDetected: '检测到现有 PR',
|
||||
existingPRMessage: '该插件已有待审核的 PR #{{number}}。点击"确认"将更新现有 PR(不会创建新的 PR)。',
|
||||
viewExistingPR: '查看现有 PR'
|
||||
},
|
||||
en: {
|
||||
title: 'Publish Plugin to Marketplace',
|
||||
updateTitle: 'Update Plugin Version',
|
||||
stepAuth: 'Step 1: GitHub Authentication',
|
||||
stepSelectSource: 'Step 2: Select Plugin Source',
|
||||
stepInfo: 'Step 3: Plugin Information',
|
||||
stepInfoUpdate: 'Step 3: Version Update',
|
||||
stepBuilding: 'Step 4: Build & Package',
|
||||
stepConfirm: 'Step 5: Confirm Publication',
|
||||
stepConfirmNoBuilding: 'Step 4: Confirm Publication',
|
||||
githubLogin: 'GitHub Login',
|
||||
oauthLogin: 'OAuth Login (Recommended)',
|
||||
tokenLogin: 'Token Login',
|
||||
oauthInstructions: 'Click the button below to start authorization:',
|
||||
oauthStep1: '1. Click "Start Authorization"',
|
||||
oauthStep2: '2. Open GitHub authorization page in browser',
|
||||
oauthStep3: '3. Enter the code shown below and authorize',
|
||||
oauthStep4: '4. Authorization will complete automatically',
|
||||
startAuth: 'Start Authorization',
|
||||
authorizing: 'Waiting for authorization...',
|
||||
authorized: 'Authorized!',
|
||||
authFailed: 'Authorization failed',
|
||||
userCode: 'Authorization Code',
|
||||
copyCode: 'Copy Code',
|
||||
openBrowser: 'Open Browser',
|
||||
tokenLabel: 'GitHub Personal Access Token',
|
||||
tokenPlaceholder: 'Paste your GitHub Token',
|
||||
tokenHint: 'Requires repo and workflow permissions',
|
||||
createToken: 'Create Token',
|
||||
login: 'Login',
|
||||
switchToToken: 'Use Token Login',
|
||||
switchToOAuth: 'Use OAuth Login',
|
||||
selectSource: 'Select Plugin Source',
|
||||
selectSourceDesc: 'Choose the plugin source type',
|
||||
selectFolder: 'Select Source Folder',
|
||||
selectFolderDesc: 'Select the folder containing your plugin source code (must have package.json, will be built automatically)',
|
||||
selectZip: 'Select ZIP File',
|
||||
selectZipDesc: 'Select a pre-built plugin ZIP package (must contain package.json and dist directory)',
|
||||
zipRequirements: 'ZIP File Requirements',
|
||||
zipStructure: 'ZIP Structure',
|
||||
zipStructureDetails: 'The ZIP file must contain:',
|
||||
zipFile1: 'package.json - Plugin metadata',
|
||||
zipFile2: 'dist/ - Built code directory (with index.esm.js)',
|
||||
zipExample: 'Example Structure',
|
||||
zipBuildScript: 'Build Script',
|
||||
zipBuildScriptDesc: 'You can use the following commands to package:',
|
||||
recommendFolder: '💡 Recommended: Use "Source Folder" mode for automatic build',
|
||||
browseFolder: 'Browse Folder',
|
||||
browseZip: 'Browse ZIP File',
|
||||
selectedFolder: 'Selected Folder',
|
||||
selectedZip: 'Selected ZIP',
|
||||
sourceTypeFolder: 'Source Folder',
|
||||
sourceTypeZip: 'ZIP File',
|
||||
pluginInfo: 'Plugin Information',
|
||||
version: 'Version',
|
||||
currentVersion: 'Current Version',
|
||||
suggestedVersion: 'Suggested Version',
|
||||
versionHistory: 'Version History',
|
||||
updatePlugin: 'Update Plugin',
|
||||
newPlugin: 'New Plugin',
|
||||
category: 'Category',
|
||||
official: 'Official',
|
||||
community: 'Community',
|
||||
repositoryUrl: 'Repository URL',
|
||||
repositoryPlaceholder: 'https://github.com/username/repo',
|
||||
releaseNotes: 'Release Notes',
|
||||
releaseNotesPlaceholder: 'Describe the changes in this version...',
|
||||
tags: 'Tags (comma separated)',
|
||||
tagsPlaceholder: 'ui, tool, editor',
|
||||
homepage: 'Homepage URL (optional)',
|
||||
next: 'Next',
|
||||
back: 'Back',
|
||||
build: 'Build & Package',
|
||||
building: 'Building...',
|
||||
confirm: 'Confirm & Publish',
|
||||
publishing: 'Publishing...',
|
||||
publishSuccess: 'Published Successfully!',
|
||||
publishError: 'Publication Failed',
|
||||
buildError: 'Build Failed',
|
||||
prCreated: 'Pull Request Created',
|
||||
viewPR: 'View PR',
|
||||
close: 'Close',
|
||||
buildingStep1: 'Installing dependencies...',
|
||||
buildingStep2: 'Building project...',
|
||||
buildingStep3: 'Packaging ZIP...',
|
||||
publishingStep1: 'Forking repository...',
|
||||
publishingStep2: 'Creating branch...',
|
||||
publishingStep3: 'Uploading files...',
|
||||
publishingStep4: 'Creating Pull Request...',
|
||||
confirmMessage: 'Confirm publishing this plugin?',
|
||||
reviewMessage:
|
||||
'Your plugin submission has been created as a PR. Maintainers will review it. Once approved, the plugin will be published to the marketplace.',
|
||||
existingPRDetected: 'Existing PR Detected',
|
||||
existingPRMessage: 'This plugin already has a pending PR #{{number}}. Clicking "Confirm" will update the existing PR (no new PR will be created).',
|
||||
viewExistingPR: 'View Existing PR'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
setStep('selectSource');
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择并解析插件源(文件夹或 ZIP)
|
||||
* 统一处理逻辑,避免代码重复
|
||||
*/
|
||||
const handleSelectSource = async (type: SourceType) => {
|
||||
setError('');
|
||||
setSourceType(type);
|
||||
|
||||
try {
|
||||
let parsedInfo: ParsedPluginInfo;
|
||||
|
||||
if (type === 'folder') {
|
||||
// 选择文件夹
|
||||
const selected = await openDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('selectFolder')
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
// 使用 PluginSourceParser 解析文件夹
|
||||
parsedInfo = await sourceParser.parseFromFolder(selected as string);
|
||||
} else {
|
||||
// 选择 ZIP 文件
|
||||
const selected = await openDialog({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
title: t('selectZip'),
|
||||
filters: [
|
||||
{
|
||||
name: 'ZIP Files',
|
||||
extensions: ['zip']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
// 使用 PluginSourceParser 解析 ZIP
|
||||
parsedInfo = await sourceParser.parseFromZip(selected as string);
|
||||
}
|
||||
|
||||
// 验证 package.json
|
||||
sourceParser.validatePackageJson(parsedInfo.packageJson);
|
||||
|
||||
setParsedPluginInfo(parsedInfo);
|
||||
|
||||
// 检测已发布的版本
|
||||
await checkExistingVersions(parsedInfo.packageJson);
|
||||
|
||||
// 检测是否已有待审核的 PR
|
||||
await checkExistingPR(parsedInfo.packageJson);
|
||||
|
||||
// 进入下一步
|
||||
setStep('info');
|
||||
} catch (err) {
|
||||
console.error('[PluginPublishWizard] Failed to parse plugin source:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to parse plugin source');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测插件是否已发布,获取版本信息
|
||||
*/
|
||||
const checkExistingVersions = async (packageJson: { name: string; version: string }) => {
|
||||
try {
|
||||
const pluginId = sourceParser.generatePluginId(packageJson.name);
|
||||
const manifestContent = await githubService.getFileContent(
|
||||
'esengine',
|
||||
'ecs-editor-plugins',
|
||||
`plugins/community/${pluginId}/manifest.json`,
|
||||
'main'
|
||||
);
|
||||
const manifest = JSON.parse(manifestContent);
|
||||
|
||||
if (Array.isArray(manifest.versions)) {
|
||||
const versions = manifest.versions.map((v: any) => v.version);
|
||||
setExistingVersions(versions);
|
||||
setExistingManifest(manifest);
|
||||
setIsUpdate(true);
|
||||
|
||||
// 计算建议版本号
|
||||
const latestVersion = manifest.latestVersion || versions[0];
|
||||
const suggested = calculateNextVersion(latestVersion);
|
||||
setSuggestedVersion(suggested);
|
||||
|
||||
// 更新模式:自动填充现有信息
|
||||
setPublishInfo((prev) => ({
|
||||
...prev,
|
||||
version: suggested,
|
||||
repositoryUrl: manifest.repository?.url || '',
|
||||
category: manifest.category_type || 'community',
|
||||
tags: manifest.tags || [],
|
||||
homepage: manifest.homepage
|
||||
}));
|
||||
} else {
|
||||
// 首次发布
|
||||
resetToNewPlugin(packageJson.version);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[PluginPublishWizard] No existing versions found, this is a new plugin');
|
||||
resetToNewPlugin(packageJson.version);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置为新插件状态
|
||||
*/
|
||||
const resetToNewPlugin = (version: string) => {
|
||||
setExistingVersions([]);
|
||||
setExistingManifest(null);
|
||||
setIsUpdate(false);
|
||||
setPublishInfo((prev) => ({
|
||||
...prev,
|
||||
version
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测是否已有待审核的 PR
|
||||
*/
|
||||
const checkExistingPR = async (packageJson: { name: string; version: string }) => {
|
||||
try {
|
||||
const user = githubService.getUser();
|
||||
if (user) {
|
||||
const branchName = `add-plugin-${packageJson.name}-v${packageJson.version}`;
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
const pr = await githubService.findPullRequestByBranch('esengine', 'ecs-editor-plugins', headBranch);
|
||||
if (pr) {
|
||||
setExistingPR({ number: pr.number, url: pr.html_url });
|
||||
} else {
|
||||
setExistingPR(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[PluginPublishWizard] Failed to check existing PR:', err);
|
||||
setExistingPR(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从信息填写步骤进入下一步
|
||||
* - 如果是 ZIP,直接跳到确认发布
|
||||
* - 如果是文件夹,需要先构建
|
||||
*/
|
||||
const handleNext = () => {
|
||||
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsedPluginInfo) {
|
||||
setError('Plugin source not selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// ZIP 文件已经构建好,直接跳到确认步骤
|
||||
if (parsedPluginInfo.sourceType === 'zip' && parsedPluginInfo.zipPath) {
|
||||
setBuiltZipPath(parsedPluginInfo.zipPath);
|
||||
setStep('confirm');
|
||||
} else {
|
||||
// 文件夹需要构建
|
||||
setStep('building');
|
||||
handleBuild();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建插件(仅对文件夹源有效)
|
||||
*/
|
||||
const handleBuild = async () => {
|
||||
if (!parsedPluginInfo || parsedPluginInfo.sourceType !== 'folder') {
|
||||
setError('Cannot build: plugin source is not a folder');
|
||||
setStep('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setBuildLog([]);
|
||||
setBuildProgress(null);
|
||||
setError('');
|
||||
|
||||
buildService.setProgressCallback((progress) => {
|
||||
console.log('[PluginPublishWizard] Build progress:', progress);
|
||||
setBuildProgress(progress);
|
||||
|
||||
if (progress.step === 'install') {
|
||||
setBuildLog((prev) => {
|
||||
if (prev[prev.length - 1] !== t('buildingStep1')) {
|
||||
return [...prev, t('buildingStep1')];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (progress.step === 'build') {
|
||||
setBuildLog((prev) => {
|
||||
if (prev[prev.length - 1] !== t('buildingStep2')) {
|
||||
return [...prev, t('buildingStep2')];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (progress.step === 'package') {
|
||||
setBuildLog((prev) => {
|
||||
if (prev[prev.length - 1] !== t('buildingStep3')) {
|
||||
return [...prev, t('buildingStep3')];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (progress.step === 'complete') {
|
||||
setBuildLog((prev) => [...prev, t('buildComplete')]);
|
||||
}
|
||||
|
||||
if (progress.output) {
|
||||
console.log('[Build output]', progress.output);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const zipPath = await buildService.buildPlugin(parsedPluginInfo.sourcePath);
|
||||
console.log('[PluginPublishWizard] Build completed, ZIP at:', zipPath);
|
||||
setBuiltZipPath(zipPath);
|
||||
setStep('confirm');
|
||||
} catch (err) {
|
||||
console.error('[PluginPublishWizard] Build failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发布插件到市场
|
||||
*/
|
||||
const handlePublish = async () => {
|
||||
setStep('publishing');
|
||||
setError('');
|
||||
setPublishProgress(null);
|
||||
|
||||
// 设置进度回调
|
||||
publishService.setProgressCallback((progress) => {
|
||||
setPublishProgress(progress);
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证必填字段
|
||||
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
|
||||
throw new Error('Missing required fields');
|
||||
}
|
||||
|
||||
// 验证插件源
|
||||
if (!parsedPluginInfo) {
|
||||
throw new Error('Plugin source not selected');
|
||||
}
|
||||
|
||||
// 验证 ZIP 路径
|
||||
if (!builtZipPath) {
|
||||
throw new Error('Plugin ZIP file not available');
|
||||
}
|
||||
|
||||
const { packageJson } = parsedPluginInfo;
|
||||
|
||||
const pluginMetadata: IEditorPluginMetadata = {
|
||||
name: packageJson.name,
|
||||
displayName: packageJson.description || packageJson.name,
|
||||
description: packageJson.description || '',
|
||||
version: packageJson.version,
|
||||
category: EditorPluginCategory.Tool,
|
||||
icon: 'Package',
|
||||
enabled: true,
|
||||
installedAt: Date.now()
|
||||
};
|
||||
|
||||
const fullPublishInfo: PluginPublishInfo = {
|
||||
pluginMetadata,
|
||||
version: publishInfo.version || packageJson.version,
|
||||
releaseNotes: publishInfo.releaseNotes || '',
|
||||
repositoryUrl: publishInfo.repositoryUrl || '',
|
||||
category: publishInfo.category || 'community',
|
||||
tags: publishInfo.tags,
|
||||
homepage: publishInfo.homepage,
|
||||
screenshots: publishInfo.screenshots
|
||||
};
|
||||
|
||||
console.log('[PluginPublishWizard] Publishing with info:', fullPublishInfo);
|
||||
console.log('[PluginPublishWizard] Built ZIP path:', builtZipPath);
|
||||
|
||||
const prUrl = await publishService.publishPlugin(fullPublishInfo, builtZipPath);
|
||||
setPrUrl(prUrl);
|
||||
setStep('success');
|
||||
} catch (err) {
|
||||
console.error('[PluginPublishWizard] Publish failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
const openPR = async () => {
|
||||
if (prUrl) {
|
||||
await open(prUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const wizardContent = (
|
||||
<div className={inline ? "plugin-publish-wizard inline" : "plugin-publish-wizard"} onClick={(e) => inline ? undefined : e.stopPropagation()}>
|
||||
<div className="plugin-publish-header">
|
||||
<h2>{t('title')}</h2>
|
||||
{!inline && (
|
||||
<button className="plugin-publish-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="plugin-publish-content">
|
||||
{step === 'auth' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepAuth')}</h3>
|
||||
<GitHubAuth
|
||||
githubService={githubService}
|
||||
onSuccess={handleAuthSuccess}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'selectSource' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepSelectSource')}</h3>
|
||||
<p>{t('selectSourceDesc')}</p>
|
||||
|
||||
<div className="source-type-selection">
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('folder')}
|
||||
>
|
||||
<FolderOpen size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeFolder')}</strong>
|
||||
<p>{t('selectFolderDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('zip')}
|
||||
>
|
||||
<FileArchive size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeZip')}</strong>
|
||||
<p>{t('selectZipDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ZIP 文件要求说明 */}
|
||||
<details className="zip-requirements-details">
|
||||
<summary>
|
||||
<AlertCircle size={16} />
|
||||
{t('zipRequirements')}
|
||||
</summary>
|
||||
<div className="zip-requirements-content">
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipStructure')}</h4>
|
||||
<p>{t('zipStructureDetails')}</p>
|
||||
<ul>
|
||||
<li><code>package.json</code> - {t('zipFile1')}</li>
|
||||
<li><code>dist/</code> - {t('zipFile2')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipBuildScript')}</h4>
|
||||
<p>{t('zipBuildScriptDesc')}</p>
|
||||
<pre className="build-script-example">
|
||||
{`npm install
|
||||
npm run build
|
||||
# 然后将 package.json 和 dist/ 目录一起压缩为 ZIP
|
||||
# ZIP 结构:
|
||||
# plugin.zip
|
||||
# ├── package.json
|
||||
# └── dist/
|
||||
# └── index.esm.js`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="recommendation-notice">
|
||||
{t('recommendFolder')}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{parsedPluginInfo && (
|
||||
<div className="selected-source">
|
||||
{parsedPluginInfo.sourceType === 'folder' ? (
|
||||
<FolderOpen size={20} />
|
||||
) : (
|
||||
<FileArchive size={20} />
|
||||
)}
|
||||
<div className="source-details">
|
||||
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
|
||||
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedPluginInfo && (
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('auth')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'info' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepInfo')}</h3>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('version')} *</label>
|
||||
{isUpdate && (
|
||||
<div className="version-info">
|
||||
<div className="version-notice">
|
||||
<CheckCircle size={16} />
|
||||
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
|
||||
</div>
|
||||
{suggestedVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-version-suggest"
|
||||
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
|
||||
>
|
||||
{t('suggestedVersion')}: {suggestedVersion}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.version || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
{isUpdate && (
|
||||
<details className="version-history">
|
||||
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
|
||||
<ul>
|
||||
{existingVersions.map((v) => (
|
||||
<li key={v}>v{v}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('releaseNotes')} *</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={publishInfo.releaseNotes || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
|
||||
placeholder={t('releaseNotesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isUpdate && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>{t('category')} *</label>
|
||||
<select
|
||||
value={publishInfo.category}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
|
||||
}
|
||||
>
|
||||
<option value="community">{t('community')}</option>
|
||||
<option value="official">{t('official')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('repositoryUrl')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.repositoryUrl || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
|
||||
placeholder={t('repositoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('tags')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.tags?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({
|
||||
...publishInfo,
|
||||
tags: e.target.value
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
placeholder={t('tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleNext}>
|
||||
{sourceType === 'zip' ? t('next') : t('build')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'building' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('building')}</h3>
|
||||
<div className="build-log">
|
||||
{buildLog.map((log, i) => (
|
||||
<div key={i} className="log-line">
|
||||
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
|
||||
<span>{log}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepConfirm')}</h3>
|
||||
|
||||
<p>{t('confirmMessage')}</p>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="confirm-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('selectSource')}:</span>
|
||||
<span className="detail-value">
|
||||
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('version')}:</span>
|
||||
<span className="detail-value">{publishInfo.version}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('category')}:</span>
|
||||
<span className="detail-value">{t(publishInfo.category!)}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('repositoryUrl')}:</span>
|
||||
<span className="detail-value">{publishInfo.repositoryUrl}</span>
|
||||
</div>
|
||||
{builtZipPath && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Package Path:</span>
|
||||
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{builtZipPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handlePublish}>
|
||||
{t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'publishing' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('publishing')}</h3>
|
||||
{publishProgress && (
|
||||
<div className="publish-progress">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${publishProgress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-message">{publishProgress.message}</p>
|
||||
<p className="progress-percent">{publishProgress.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="publish-step success">
|
||||
<CheckCircle size={48} style={{ color: '#34c759' }} />
|
||||
<h3>{t('publishSuccess')}</h3>
|
||||
<p>{t('prCreated')}</p>
|
||||
<p className="review-message">{t('reviewMessage')}</p>
|
||||
|
||||
<button className="btn-link" onClick={openPR}>
|
||||
<ExternalLink size={14} />
|
||||
{t('viewPR')}
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'error' && (
|
||||
<div className="publish-step error">
|
||||
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
|
||||
<h3>{t('publishError')}</h3>
|
||||
<p>{error}</p>
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return inline ? wizardContent : (
|
||||
<div className="plugin-publish-overlay" onClick={onClose}>
|
||||
{wizardContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState } from 'react';
|
||||
import { X, FolderOpen, Loader, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import type { GitHubService, PublishedPlugin } from '../services/GitHubService';
|
||||
import { PluginPublishService, type PublishProgress } from '../services/PluginPublishService';
|
||||
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { EditorPluginCategory } from '@esengine/editor-core';
|
||||
import type { IEditorPluginMetadata } from '@esengine/editor-core';
|
||||
import '../styles/PluginUpdateDialog.css';
|
||||
|
||||
interface PluginUpdateDialogProps {
|
||||
plugin: PublishedPlugin;
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
type Step = 'selectFolder' | 'info' | 'building' | 'publishing' | 'success' | 'error';
|
||||
|
||||
function calculateNextVersion(currentVersion: string): string {
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
|
||||
|
||||
const [major, minor, patch] = parts;
|
||||
return `${major}.${minor}.${(patch ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export function PluginUpdateDialog({ plugin, githubService, onClose, onSuccess, locale }: PluginUpdateDialogProps) {
|
||||
const [publishService] = useState(() => new PluginPublishService(githubService));
|
||||
const [buildService] = useState(() => new PluginBuildService());
|
||||
|
||||
const [step, setStep] = useState<Step>('selectFolder');
|
||||
const [pluginFolder, setPluginFolder] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
const [releaseNotes, setReleaseNotes] = useState('');
|
||||
const [suggestedVersion] = useState(() => calculateNextVersion(plugin.latestVersion));
|
||||
const [error, setError] = useState('');
|
||||
const [buildLog, setBuildLog] = useState<string[]>([]);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
|
||||
const [prUrl, setPrUrl] = useState('');
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '更新插件',
|
||||
currentVersion: '当前版本',
|
||||
newVersion: '新版本号',
|
||||
useSuggested: '使用建议版本',
|
||||
releaseNotes: '更新说明',
|
||||
releaseNotesPlaceholder: '描述这个版本的变更...',
|
||||
selectFolder: '选择插件文件夹',
|
||||
selectFolderDesc: '选择包含更新后插件源代码的文件夹',
|
||||
browseFolder: '浏览文件夹',
|
||||
selectedFolder: '已选择文件夹',
|
||||
next: '下一步',
|
||||
back: '上一步',
|
||||
buildAndPublish: '构建并发布',
|
||||
building: '构建中...',
|
||||
publishing: '发布中...',
|
||||
success: '更新成功!',
|
||||
error: '更新失败',
|
||||
viewPR: '查看 PR',
|
||||
close: '关闭',
|
||||
buildError: '构建失败',
|
||||
publishError: '发布失败',
|
||||
buildingStep1: '正在安装依赖...',
|
||||
buildingStep2: '正在构建项目...',
|
||||
buildingStep3: '正在打包 ZIP...',
|
||||
publishingStep1: '正在 Fork 仓库...',
|
||||
publishingStep2: '正在创建分支...',
|
||||
publishingStep3: '正在上传文件...',
|
||||
publishingStep4: '正在创建 Pull Request...',
|
||||
reviewMessage: '你的插件更新已创建 PR,维护者将进行审核。审核通过后,新版本将自动发布到市场。'
|
||||
},
|
||||
en: {
|
||||
title: 'Update Plugin',
|
||||
currentVersion: 'Current Version',
|
||||
newVersion: 'New Version',
|
||||
useSuggested: 'Use Suggested',
|
||||
releaseNotes: 'Release Notes',
|
||||
releaseNotesPlaceholder: 'Describe the changes in this version...',
|
||||
selectFolder: 'Select Plugin Folder',
|
||||
selectFolderDesc: 'Select the folder containing your updated plugin source code',
|
||||
browseFolder: 'Browse Folder',
|
||||
selectedFolder: 'Selected Folder',
|
||||
next: 'Next',
|
||||
back: 'Back',
|
||||
buildAndPublish: 'Build & Publish',
|
||||
building: 'Building...',
|
||||
publishing: 'Publishing...',
|
||||
success: 'Update Successful!',
|
||||
error: 'Update Failed',
|
||||
viewPR: 'View PR',
|
||||
close: 'Close',
|
||||
buildError: 'Build Failed',
|
||||
publishError: 'Publish Failed',
|
||||
buildingStep1: 'Installing dependencies...',
|
||||
buildingStep2: 'Building project...',
|
||||
buildingStep3: 'Packaging ZIP...',
|
||||
publishingStep1: 'Forking repository...',
|
||||
publishingStep2: 'Creating branch...',
|
||||
publishingStep3: 'Uploading files...',
|
||||
publishingStep4: 'Creating Pull Request...',
|
||||
reviewMessage: 'Your plugin update has been created as a PR. Maintainers will review it. Once approved, the new version will be published to the marketplace.'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('selectFolder')
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
setPluginFolder(selected as string);
|
||||
setStep('info');
|
||||
} catch (err) {
|
||||
console.error('[PluginUpdateDialog] Failed to select folder:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to select folder');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildAndPublish = async () => {
|
||||
if (!version || !releaseNotes) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('building');
|
||||
setBuildLog([]);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
buildService.setProgressCallback((progress) => {
|
||||
setBuildProgress(progress);
|
||||
if (progress.output) {
|
||||
setBuildLog((prev) => [...prev, progress.output!]);
|
||||
}
|
||||
});
|
||||
|
||||
const zipPath = await buildService.buildPlugin(pluginFolder);
|
||||
console.log('[PluginUpdateDialog] Build completed:', zipPath);
|
||||
|
||||
setStep('publishing');
|
||||
publishService.setProgressCallback((progress) => {
|
||||
setPublishProgress(progress);
|
||||
});
|
||||
|
||||
const { readTextFile } = await import('@tauri-apps/plugin-fs');
|
||||
const packageJsonPath = `${pluginFolder}/package.json`;
|
||||
const packageJsonContent = await readTextFile(packageJsonPath);
|
||||
const pkgJson = JSON.parse(packageJsonContent);
|
||||
|
||||
const pluginMetadata: IEditorPluginMetadata = {
|
||||
name: pkgJson.name,
|
||||
displayName: pkgJson.description || pkgJson.name,
|
||||
description: pkgJson.description || '',
|
||||
version: pkgJson.version,
|
||||
category: EditorPluginCategory.Tool,
|
||||
icon: 'Package',
|
||||
enabled: true,
|
||||
installedAt: Date.now()
|
||||
};
|
||||
|
||||
const publishInfo = {
|
||||
pluginMetadata,
|
||||
version,
|
||||
releaseNotes,
|
||||
category: plugin.category_type as 'official' | 'community',
|
||||
repositoryUrl: plugin.repositoryUrl || '',
|
||||
tags: []
|
||||
};
|
||||
|
||||
const prUrl = await publishService.publishPlugin(publishInfo, zipPath);
|
||||
|
||||
console.log('[PluginUpdateDialog] Update published:', prUrl);
|
||||
setPrUrl(prUrl);
|
||||
setStep('success');
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('[PluginUpdateDialog] Failed to update plugin:', err);
|
||||
setError(err instanceof Error ? err.message : 'Update failed');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'selectFolder':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<h3>{t('selectFolder')}</h3>
|
||||
<p className="step-description">{t('selectFolderDesc')}</p>
|
||||
<button className="btn-browse" onClick={handleSelectFolder}>
|
||||
<FolderOpen size={16} />
|
||||
{t('browseFolder')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'info':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<div className="current-plugin-info">
|
||||
<h4>{plugin.name}</h4>
|
||||
<p>
|
||||
{t('currentVersion')}: <strong>v{plugin.latestVersion}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pluginFolder && (
|
||||
<div className="selected-folder-info">
|
||||
<FolderOpen size={16} />
|
||||
<span>{pluginFolder}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('newVersion')} *</label>
|
||||
<div className="version-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
placeholder={suggestedVersion}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-suggest"
|
||||
onClick={() => setVersion(suggestedVersion)}
|
||||
>
|
||||
{t('useSuggested')} ({suggestedVersion})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('releaseNotes')} *</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={releaseNotes}
|
||||
onChange={(e) => setReleaseNotes(e.target.value)}
|
||||
placeholder={t('releaseNotesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="update-dialog-actions">
|
||||
<button className="btn-back" onClick={() => setStep('selectFolder')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleBuildAndPublish}
|
||||
disabled={!version || !releaseNotes}
|
||||
>
|
||||
{t('buildAndPublish')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'building':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<h3>{t('building')}</h3>
|
||||
{buildProgress && (
|
||||
<div className="progress-container">
|
||||
<p className="progress-message">{buildProgress.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{buildLog.length > 0 && (
|
||||
<div className="build-log">
|
||||
{buildLog.map((log, index) => (
|
||||
<div key={index} className="log-line">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'publishing':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<h3>{t('publishing')}</h3>
|
||||
{publishProgress && (
|
||||
<div className="progress-container">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${publishProgress.progress}%` }} />
|
||||
</div>
|
||||
<p className="progress-message">{publishProgress.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<div className="update-dialog-step success-step">
|
||||
<CheckCircle size={64} className="success-icon" />
|
||||
<h3>{t('success')}</h3>
|
||||
<p className="success-message">{t('reviewMessage')}</p>
|
||||
{prUrl && (
|
||||
<button className="btn-view-pr" onClick={() => open(prUrl)}>
|
||||
{t('viewPR')}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="update-dialog-step error-step">
|
||||
<AlertCircle size={64} className="error-icon" />
|
||||
<h3>{t('error')}</h3>
|
||||
<p className="error-message">{error}</p>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plugin-update-dialog-overlay">
|
||||
<div className="plugin-update-dialog">
|
||||
<div className="update-dialog-header">
|
||||
<h2>{t('title')}: {plugin.name}</h2>
|
||||
<button className="update-dialog-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="update-dialog-content">{renderStepContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Github, LogOut, User, LayoutDashboard, Loader2 } from 'lucide-react';
|
||||
import type { GitHubService, GitHubUser } from '../services/GitHubService';
|
||||
import '../styles/UserProfile.css';
|
||||
|
||||
interface UserProfileProps {
|
||||
githubService: GitHubService;
|
||||
onLogin: () => void;
|
||||
onOpenDashboard: () => void;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }: UserProfileProps) {
|
||||
const [user, setUser] = useState<GitHubUser | null>(githubService.getUser());
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isLoadingUser, setIsLoadingUser] = useState(githubService.isLoadingUserInfo());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
dashboard: '个人中心',
|
||||
profile: '个人信息',
|
||||
notLoggedIn: '未登录',
|
||||
loadingUser: '加载中...'
|
||||
},
|
||||
en: {
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
dashboard: 'Dashboard',
|
||||
profile: 'Profile',
|
||||
notLoggedIn: 'Not logged in',
|
||||
loadingUser: 'Loading...'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听加载状态变化
|
||||
const unsubscribe = githubService.onUserLoadStateChange((isLoading) => {
|
||||
console.log('[UserProfile] User load state changed:', isLoading);
|
||||
setIsLoadingUser(isLoading);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [githubService]);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听认证状态变化
|
||||
const checkUser = () => {
|
||||
const currentUser = githubService.getUser();
|
||||
setUser((prevUser) => {
|
||||
if (currentUser && (!prevUser || currentUser.login !== prevUser.login)) {
|
||||
console.log('[UserProfile] User state changed:', currentUser.login);
|
||||
return currentUser;
|
||||
} else if (!currentUser && prevUser) {
|
||||
console.log('[UserProfile] User logged out');
|
||||
return null;
|
||||
}
|
||||
return prevUser;
|
||||
});
|
||||
};
|
||||
|
||||
// 每秒检查一次用户状态
|
||||
const interval = setInterval(checkUser, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [githubService]);
|
||||
|
||||
const handleLogout = () => {
|
||||
githubService.logout();
|
||||
setUser(null);
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="user-profile">
|
||||
<button
|
||||
className="login-button"
|
||||
onClick={onLogin}
|
||||
disabled={isLoadingUser}
|
||||
title={isLoadingUser ? t('loadingUser') : undefined}
|
||||
>
|
||||
{isLoadingUser ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spinning" />
|
||||
{t('loadingUser')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Github size={16} />
|
||||
{t('login')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-profile" ref={menuRef}>
|
||||
<button className="user-avatar-button" onClick={() => setShowMenu(!showMenu)}>
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.name} className="user-avatar" />
|
||||
) : (
|
||||
<div className="user-avatar-placeholder">
|
||||
<User size={20} />
|
||||
</div>
|
||||
)}
|
||||
<span className="user-name">{user.name || user.login}</span>
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="user-menu">
|
||||
<div className="user-menu-header">
|
||||
<img src={user.avatar_url} alt={user.name} className="user-menu-avatar" />
|
||||
<div className="user-menu-info">
|
||||
<div className="user-menu-name">{user.name || user.login}</div>
|
||||
<div className="user-menu-login">@{user.login}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
onOpenDashboard();
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t('dashboard')}
|
||||
</button>
|
||||
|
||||
<button className="user-menu-item" onClick={handleLogout}>
|
||||
<LogOut size={16} />
|
||||
{t('logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'reflect-metadata';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { singleton } from 'tsyringe';
|
||||
import { DIContainer, globalContainer } from '../di/DIContainer';
|
||||
import { EditorEventBus } from '../events/EditorEventBus';
|
||||
import { CommandRegistry } from '../commands/CommandRegistry';
|
||||
import { PanelRegistry } from '../commands/PanelRegistry';
|
||||
|
||||
export interface EditorContext {
|
||||
container: DIContainer;
|
||||
eventBus: EditorEventBus;
|
||||
commands: CommandRegistry;
|
||||
panels: PanelRegistry;
|
||||
scene: Core;
|
||||
}
|
||||
|
||||
@singleton()
|
||||
export class EditorBootstrap {
|
||||
private initialized = false;
|
||||
|
||||
async initialize(): Promise<EditorContext> {
|
||||
if (this.initialized) {
|
||||
throw new Error('EditorBootstrap has already been initialized');
|
||||
}
|
||||
|
||||
console.log('[EditorBootstrap] Starting editor initialization...');
|
||||
|
||||
const scene = await this.createScene();
|
||||
console.log('[EditorBootstrap] Scene created');
|
||||
|
||||
const container = globalContainer;
|
||||
|
||||
const eventBus = container.resolve(EditorEventBus);
|
||||
console.log('[EditorBootstrap] EventBus initialized');
|
||||
|
||||
const commands = container.resolve(CommandRegistry);
|
||||
console.log('[EditorBootstrap] CommandRegistry initialized');
|
||||
|
||||
const panels = container.resolve(PanelRegistry);
|
||||
console.log('[EditorBootstrap] PanelRegistry initialized');
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[EditorBootstrap] Editor initialized successfully');
|
||||
|
||||
return {
|
||||
container,
|
||||
eventBus,
|
||||
commands,
|
||||
panels,
|
||||
scene
|
||||
};
|
||||
}
|
||||
|
||||
private async createScene(): Promise<Core> {
|
||||
const scene = await Core.create();
|
||||
return scene;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './EditorBootstrap';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import type { ICommand, ICommandRegistry, KeyBinding } from '../interfaces/ICommandRegistry';
|
||||
|
||||
@singleton()
|
||||
export class CommandRegistry implements ICommandRegistry {
|
||||
private commands = new Map<string, ICommand>();
|
||||
|
||||
register(command: ICommand): void {
|
||||
if (this.commands.has(command.id)) {
|
||||
console.warn(`Command ${command.id} is already registered. Overwriting.`);
|
||||
}
|
||||
this.commands.set(command.id, command);
|
||||
}
|
||||
|
||||
unregister(commandId: string): void {
|
||||
this.commands.delete(commandId);
|
||||
}
|
||||
|
||||
async execute(commandId: string, context?: unknown): Promise<void> {
|
||||
const command = this.commands.get(commandId);
|
||||
if (!command) {
|
||||
throw new Error(`Command ${commandId} not found`);
|
||||
}
|
||||
|
||||
if (command.when && !command.when()) {
|
||||
console.warn(`Command ${commandId} cannot be executed (when clause failed)`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(context);
|
||||
} catch (error) {
|
||||
console.error(`Error executing command ${commandId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getCommand(commandId: string): ICommand | undefined {
|
||||
return this.commands.get(commandId);
|
||||
}
|
||||
|
||||
getCommands(): ICommand[] {
|
||||
return Array.from(this.commands.values());
|
||||
}
|
||||
|
||||
getKeybindings(): Array<{ command: ICommand; keybinding: KeyBinding }> {
|
||||
const bindings: Array<{ command: ICommand; keybinding: KeyBinding }> = [];
|
||||
|
||||
for (const command of this.commands.values()) {
|
||||
if (command.keybinding) {
|
||||
bindings.push({ command, keybinding: command.keybinding });
|
||||
}
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import type { IPanelRegistry, PanelDescriptor } from '../interfaces/IPanelRegistry';
|
||||
|
||||
@singleton()
|
||||
export class PanelRegistry implements IPanelRegistry {
|
||||
private panels = new Map<string, PanelDescriptor>();
|
||||
|
||||
register(panel: PanelDescriptor): void {
|
||||
if (this.panels.has(panel.id)) {
|
||||
console.warn(`Panel ${panel.id} is already registered. Overwriting.`);
|
||||
}
|
||||
this.panels.set(panel.id, panel);
|
||||
}
|
||||
|
||||
unregister(panelId: string): void {
|
||||
this.panels.delete(panelId);
|
||||
}
|
||||
|
||||
getPanel(panelId: string): PanelDescriptor | undefined {
|
||||
return this.panels.get(panelId);
|
||||
}
|
||||
|
||||
getPanels(category?: string): PanelDescriptor[] {
|
||||
const allPanels = Array.from(this.panels.values());
|
||||
|
||||
if (!category) {
|
||||
return allPanels.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
return allPanels
|
||||
.filter(panel => panel.category === category)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './CommandRegistry';
|
||||
export * from './PanelRegistry';
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'reflect-metadata';
|
||||
import { container, DependencyContainer, InjectionToken, Lifecycle } from 'tsyringe';
|
||||
|
||||
export class DIContainer {
|
||||
private readonly container: DependencyContainer;
|
||||
|
||||
constructor(parent?: DependencyContainer) {
|
||||
this.container = parent ? parent.createChildContainer() : container;
|
||||
}
|
||||
|
||||
registerSingleton<T>(token: InjectionToken<T>, implementation?: new (...args: unknown[]) => T): void {
|
||||
if (implementation) {
|
||||
this.container.register(token, { useClass: implementation }, { lifecycle: Lifecycle.Singleton });
|
||||
} else {
|
||||
this.container.registerSingleton(token as new (...args: unknown[]) => T);
|
||||
}
|
||||
}
|
||||
|
||||
registerInstance<T>(token: InjectionToken<T>, instance: T): void {
|
||||
this.container.registerInstance(token, instance);
|
||||
}
|
||||
|
||||
registerTransient<T>(token: InjectionToken<T>, implementation: new (...args: unknown[]) => T): void {
|
||||
this.container.register(token, { useClass: implementation }, { lifecycle: Lifecycle.Transient });
|
||||
}
|
||||
|
||||
registerFactory<T>(token: InjectionToken<T>, factory: (container: DependencyContainer) => T): void {
|
||||
this.container.register(token, { useFactory: factory });
|
||||
}
|
||||
|
||||
resolve<T>(token: InjectionToken<T>): T {
|
||||
return this.container.resolve(token);
|
||||
}
|
||||
|
||||
isRegistered<T>(token: InjectionToken<T>): boolean {
|
||||
return this.container.isRegistered(token);
|
||||
}
|
||||
|
||||
createChild(): DIContainer {
|
||||
return new DIContainer(this.container);
|
||||
}
|
||||
|
||||
getNativeContainer(): DependencyContainer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.container.clearInstances();
|
||||
}
|
||||
}
|
||||
|
||||
export const globalContainer = new DIContainer();
|
||||
@@ -0,0 +1,7 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import { TypedEventBus } from './TypedEventBus';
|
||||
import type { EditorEventMap } from './EditorEventMap';
|
||||
|
||||
@singleton()
|
||||
export class EditorEventBus extends TypedEventBus<EditorEventMap> {
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Entity, Component } from '@esengine/ecs-framework';
|
||||
import type { Node } from '@esengine/behavior-tree-editor';
|
||||
|
||||
export interface PluginEvent {
|
||||
name: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface EntityEvent {
|
||||
entity: Entity;
|
||||
}
|
||||
|
||||
export interface ComponentEvent {
|
||||
entity: Entity;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
export interface ComponentPropertyChangedEvent {
|
||||
entity: Entity;
|
||||
component: Component;
|
||||
property: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface AssetFileEvent {
|
||||
path: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface BehaviorTreeNodeEvent {
|
||||
node: Node;
|
||||
}
|
||||
|
||||
export interface BehaviorTreeLoadFileEvent {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface DynamicPanelEvent {
|
||||
panelId: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface UIWindowEvent {
|
||||
windowId: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface FullscreenEvent {
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
export interface LocaleChangedEvent {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface NotificationEvent {
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface SceneEvent {
|
||||
scenePath?: string;
|
||||
}
|
||||
|
||||
export interface EditorEventMap extends Record<string, unknown> {
|
||||
'plugin:installed': PluginEvent;
|
||||
'plugin:enabled': PluginEvent;
|
||||
'plugin:disabled': PluginEvent;
|
||||
|
||||
'entity:selected': EntityEvent;
|
||||
'remote-entity:selected': EntityEvent;
|
||||
'entity:added': EntityEvent;
|
||||
'entity:removed': EntityEvent;
|
||||
'entities:cleared': Record<string, never>;
|
||||
|
||||
'component:added': ComponentEvent;
|
||||
'component:removed': ComponentEvent;
|
||||
'component:property:changed': ComponentPropertyChangedEvent;
|
||||
|
||||
'asset:reveal': AssetFileEvent;
|
||||
'asset-file:selected': AssetFileEvent;
|
||||
|
||||
'behavior-tree:node-selected': BehaviorTreeNodeEvent;
|
||||
'behavior-tree:load-file': BehaviorTreeLoadFileEvent;
|
||||
|
||||
'dynamic-panel:open': DynamicPanelEvent;
|
||||
'ui:openWindow': UIWindowEvent;
|
||||
'editor:fullscreen': FullscreenEvent;
|
||||
|
||||
'locale:changed': LocaleChangedEvent;
|
||||
'notification:show': NotificationEvent;
|
||||
|
||||
'scene:loaded': SceneEvent;
|
||||
'scene:new': SceneEvent;
|
||||
'scene:saved': SceneEvent;
|
||||
'scene:modified': SceneEvent;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Subject, Observable, Subscription } from 'rxjs';
|
||||
import { singleton } from 'tsyringe';
|
||||
import type { IEventBus, Unsubscribe } from '../interfaces/IEventBus';
|
||||
|
||||
@singleton()
|
||||
export class TypedEventBus<TEvents = Record<string, unknown>> implements IEventBus<TEvents> {
|
||||
private subjects = new Map<keyof TEvents, Subject<TEvents[keyof TEvents]>>();
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
async publish<K extends keyof TEvents>(topic: K, data: TEvents[K]): Promise<void> {
|
||||
const subject = this.getSubject(topic);
|
||||
subject.next(data);
|
||||
}
|
||||
|
||||
subscribe<K extends keyof TEvents>(
|
||||
topic: K,
|
||||
handler: (data: TEvents[K]) => void | Promise<void>
|
||||
): Unsubscribe {
|
||||
const subscription = this.observe(topic).subscribe(async (data) => {
|
||||
try {
|
||||
await handler(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in event handler for topic ${String(topic)}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
this.subscriptions.push(subscription);
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
const index = this.subscriptions.indexOf(subscription);
|
||||
if (index !== -1) {
|
||||
this.subscriptions.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
observe<K extends keyof TEvents>(topic: K): Observable<TEvents[K]> {
|
||||
return this.getSubject(topic).asObservable() as Observable<TEvents[K]>;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
this.subscriptions = [];
|
||||
this.subjects.forEach(subject => subject.complete());
|
||||
this.subjects.clear();
|
||||
}
|
||||
|
||||
private getSubject<K extends keyof TEvents>(topic: K): Subject<TEvents[K]> {
|
||||
let subject = this.subjects.get(topic) as Subject<TEvents[K]> | undefined;
|
||||
if (!subject) {
|
||||
subject = new Subject<TEvents[K]>();
|
||||
this.subjects.set(topic, subject as unknown as Subject<TEvents[keyof TEvents]>);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './EditorEventMap';
|
||||
export * from './TypedEventBus';
|
||||
export * from './EditorEventBus';
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './interfaces';
|
||||
export * from './di/DIContainer';
|
||||
export * from './events';
|
||||
export * from './commands';
|
||||
export * from './bootstrap';
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface KeyBinding {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
alt?: boolean;
|
||||
shift?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
export interface ICommand {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon?: string;
|
||||
readonly keybinding?: KeyBinding;
|
||||
readonly when?: () => boolean;
|
||||
execute(context?: unknown): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ICommandRegistry {
|
||||
register(command: ICommand): void;
|
||||
unregister(commandId: string): void;
|
||||
execute(commandId: string, context?: unknown): Promise<void>;
|
||||
getCommand(commandId: string): ICommand | undefined;
|
||||
getCommands(): ICommand[];
|
||||
getKeybindings(): Array<{ command: ICommand; keybinding: KeyBinding }>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface IEventBus<TEvents = Record<string, unknown>> {
|
||||
publish<K extends keyof TEvents>(topic: K, data: TEvents[K]): Promise<void>;
|
||||
|
||||
subscribe<K extends keyof TEvents>(
|
||||
topic: K,
|
||||
handler: (data: TEvents[K]) => void | Promise<void>
|
||||
): Unsubscribe;
|
||||
|
||||
observe<K extends keyof TEvents>(topic: K): Observable<TEvents[K]>;
|
||||
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
export interface PanelDescriptor {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
component: ComponentType<unknown>;
|
||||
category?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface IPanelRegistry {
|
||||
register(panel: PanelDescriptor): void;
|
||||
unregister(panelId: string): void;
|
||||
getPanel(panelId: string): PanelDescriptor | undefined;
|
||||
getPanels(category?: string): PanelDescriptor[];
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './IEventBus';
|
||||
export * from './ICommandRegistry';
|
||||
export * from './IPanelRegistry';
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Node } from '../models/Node';
|
||||
import { Position } from '../value-objects/Position';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
|
||||
export const ROOT_NODE_ID = 'root-node';
|
||||
|
||||
export const createRootNodeTemplate = (): NodeTemplate => ({
|
||||
type: 'root',
|
||||
displayName: '根节点',
|
||||
category: '根节点',
|
||||
icon: 'TreePine',
|
||||
description: '行为树根节点',
|
||||
color: '#FFD700',
|
||||
maxChildren: 1,
|
||||
defaultConfig: {
|
||||
nodeType: 'root'
|
||||
},
|
||||
properties: []
|
||||
});
|
||||
|
||||
export const createRootNode = (): Node => {
|
||||
const template = createRootNodeTemplate();
|
||||
const position = new Position(400, 100);
|
||||
return new Node(ROOT_NODE_ID, template, { nodeType: 'root' }, position, []);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 领域错误基类
|
||||
*/
|
||||
export abstract class DomainError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { DomainError } from './DomainError';
|
||||
|
||||
/**
|
||||
* 节点未找到错误
|
||||
*/
|
||||
export class NodeNotFoundError extends DomainError {
|
||||
constructor(public readonly nodeId: string) {
|
||||
super(`节点未找到: ${nodeId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { DomainError } from './DomainError';
|
||||
|
||||
/**
|
||||
* 验证错误
|
||||
* 当业务规则验证失败时抛出
|
||||
*/
|
||||
export class ValidationError extends DomainError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly field?: string,
|
||||
public readonly value?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static rootNodeMaxChildren(): ValidationError {
|
||||
return new ValidationError(
|
||||
'根节点只能连接一个子节点',
|
||||
'children'
|
||||
);
|
||||
}
|
||||
|
||||
static decoratorNodeMaxChildren(): ValidationError {
|
||||
return new ValidationError(
|
||||
'装饰节点只能连接一个子节点',
|
||||
'children'
|
||||
);
|
||||
}
|
||||
|
||||
static leafNodeNoChildren(): ValidationError {
|
||||
return new ValidationError(
|
||||
'叶子节点不能有子节点',
|
||||
'children'
|
||||
);
|
||||
}
|
||||
|
||||
static circularReference(nodeId: string): ValidationError {
|
||||
return new ValidationError(
|
||||
`检测到循环引用,节点 ${nodeId} 不能连接到自己或其子节点`,
|
||||
'connection',
|
||||
nodeId
|
||||
);
|
||||
}
|
||||
|
||||
static invalidConnection(from: string, to: string, reason: string): ValidationError {
|
||||
return new ValidationError(
|
||||
`无效的连接:${reason}`,
|
||||
'connection',
|
||||
{ from, to }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { DomainError } from './DomainError';
|
||||
export { ValidationError } from './ValidationError';
|
||||
export { NodeNotFoundError } from './NodeNotFoundError';
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './models';
|
||||
export * from './value-objects';
|
||||
export * from './interfaces';
|
||||
export { DomainError, ValidationError as DomainValidationError, NodeNotFoundError } from './errors';
|
||||
export * from './services';
|
||||
@@ -1,32 +0,0 @@
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Node } from '../models/Node';
|
||||
import { Position } from '../value-objects';
|
||||
|
||||
/**
|
||||
* 节点工厂接口
|
||||
* 负责创建不同类型的节点
|
||||
*/
|
||||
export interface INodeFactory {
|
||||
/**
|
||||
* 创建节点
|
||||
*/
|
||||
createNode(
|
||||
template: NodeTemplate,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node;
|
||||
|
||||
/**
|
||||
* 根据模板类型创建节点
|
||||
*/
|
||||
createNodeByType(
|
||||
nodeType: string,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node;
|
||||
|
||||
/**
|
||||
* 克隆节点
|
||||
*/
|
||||
cloneNode(node: Node, newPosition?: Position): Node;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { BehaviorTree } from '../models/BehaviorTree';
|
||||
|
||||
/**
|
||||
* 仓储接口
|
||||
* 负责行为树的持久化
|
||||
*/
|
||||
export interface IBehaviorTreeRepository {
|
||||
/**
|
||||
* 保存行为树
|
||||
*/
|
||||
save(tree: BehaviorTree, path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 加载行为树
|
||||
*/
|
||||
load(path: string): Promise<BehaviorTree>;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
exists(path: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 删除行为树文件
|
||||
*/
|
||||
delete(path: string): Promise<void>;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { BehaviorTree } from '../models/BehaviorTree';
|
||||
|
||||
/**
|
||||
* 序列化格式
|
||||
*/
|
||||
export type SerializationFormat = 'json' | 'binary';
|
||||
|
||||
/**
|
||||
* 序列化接口
|
||||
* 负责行为树的序列化和反序列化
|
||||
*/
|
||||
export interface ISerializer {
|
||||
/**
|
||||
* 序列化行为树
|
||||
*/
|
||||
serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array;
|
||||
|
||||
/**
|
||||
* 反序列化行为树
|
||||
*/
|
||||
deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree;
|
||||
|
||||
/**
|
||||
* 导出为运行时资产格式
|
||||
*/
|
||||
exportToRuntimeAsset(
|
||||
tree: BehaviorTree,
|
||||
format: SerializationFormat
|
||||
): string | Uint8Array;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { BehaviorTree } from '../models/BehaviorTree';
|
||||
import { Node } from '../models/Node';
|
||||
import { Connection } from '../models/Connection';
|
||||
|
||||
/**
|
||||
* 验证结果
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证错误详情
|
||||
*/
|
||||
export interface ValidationError {
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证器接口
|
||||
* 负责行为树的验证逻辑
|
||||
*/
|
||||
export interface IValidator {
|
||||
/**
|
||||
* 验证整个行为树
|
||||
*/
|
||||
validateTree(tree: BehaviorTree): ValidationResult;
|
||||
|
||||
/**
|
||||
* 验证节点
|
||||
*/
|
||||
validateNode(node: Node): ValidationResult;
|
||||
|
||||
/**
|
||||
* 验证连接
|
||||
*/
|
||||
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult;
|
||||
|
||||
/**
|
||||
* 验证是否会产生循环引用
|
||||
*/
|
||||
validateNoCycles(tree: BehaviorTree): ValidationResult;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { type INodeFactory } from './INodeFactory';
|
||||
export { type ISerializer, type SerializationFormat } from './ISerializer';
|
||||
export { type IBehaviorTreeRepository } from './IRepository';
|
||||
export { type IValidator, type ValidationResult, type ValidationError } from './IValidator';
|
||||
@@ -1,353 +0,0 @@
|
||||
import { Node } from './Node';
|
||||
import { Connection } from './Connection';
|
||||
import { Blackboard } from './Blackboard';
|
||||
import { ValidationError, NodeNotFoundError } from '../errors';
|
||||
|
||||
/**
|
||||
* 行为树聚合根
|
||||
* 管理整个行为树的节点、连接和黑板
|
||||
*/
|
||||
export class BehaviorTree {
|
||||
private readonly _nodes: Map<string, Node>;
|
||||
private readonly _connections: Connection[];
|
||||
private readonly _blackboard: Blackboard;
|
||||
private readonly _rootNodeId: string | null;
|
||||
|
||||
constructor(
|
||||
nodes: Node[] = [],
|
||||
connections: Connection[] = [],
|
||||
blackboard: Blackboard = Blackboard.empty(),
|
||||
rootNodeId: string | null = null
|
||||
) {
|
||||
this._nodes = new Map(nodes.map((node) => [node.id, node]));
|
||||
this._connections = [...connections];
|
||||
this._blackboard = blackboard;
|
||||
this._rootNodeId = rootNodeId;
|
||||
|
||||
this.validateTree();
|
||||
}
|
||||
|
||||
get nodes(): ReadonlyArray<Node> {
|
||||
return Array.from(this._nodes.values());
|
||||
}
|
||||
|
||||
get connections(): ReadonlyArray<Connection> {
|
||||
return this._connections;
|
||||
}
|
||||
|
||||
get blackboard(): Blackboard {
|
||||
return this._blackboard;
|
||||
}
|
||||
|
||||
get rootNodeId(): string | null {
|
||||
return this._rootNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定节点
|
||||
*/
|
||||
getNode(nodeId: string): Node {
|
||||
const node = this._nodes.get(nodeId);
|
||||
if (!node) {
|
||||
throw new NodeNotFoundError(nodeId);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点是否存在
|
||||
*/
|
||||
hasNode(nodeId: string): boolean {
|
||||
return this._nodes.has(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点
|
||||
*/
|
||||
addNode(node: Node): BehaviorTree {
|
||||
if (this._nodes.has(node.id)) {
|
||||
throw new ValidationError(`节点 ${node.id} 已存在`);
|
||||
}
|
||||
|
||||
if (node.isRoot()) {
|
||||
if (this._rootNodeId) {
|
||||
throw new ValidationError('行为树只能有一个根节点');
|
||||
}
|
||||
return new BehaviorTree(
|
||||
[...this.nodes, node],
|
||||
this._connections,
|
||||
this._blackboard,
|
||||
node.id
|
||||
);
|
||||
}
|
||||
|
||||
return new BehaviorTree(
|
||||
[...this.nodes, node],
|
||||
this._connections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除节点
|
||||
* 会同时移除相关的连接
|
||||
*/
|
||||
removeNode(nodeId: string): BehaviorTree {
|
||||
if (!this._nodes.has(nodeId)) {
|
||||
throw new NodeNotFoundError(nodeId);
|
||||
}
|
||||
|
||||
const node = this.getNode(nodeId);
|
||||
const newNodes = Array.from(this.nodes.filter((n) => n.id !== nodeId));
|
||||
const newConnections = this._connections.filter(
|
||||
(conn) => conn.from !== nodeId && conn.to !== nodeId
|
||||
);
|
||||
|
||||
const newRootNodeId = node.isRoot() ? null : this._rootNodeId;
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
newConnections,
|
||||
this._blackboard,
|
||||
newRootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点
|
||||
*/
|
||||
updateNode(nodeId: string, updater: (node: Node) => Node): BehaviorTree {
|
||||
const node = this.getNode(nodeId);
|
||||
const updatedNode = updater(node);
|
||||
|
||||
const newNodes = Array.from(this.nodes.map((n) => n.id === nodeId ? updatedNode : n));
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
this._connections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加连接
|
||||
* 会验证连接的合法性
|
||||
*/
|
||||
addConnection(connection: Connection): BehaviorTree {
|
||||
const fromNode = this.getNode(connection.from);
|
||||
const toNode = this.getNode(connection.to);
|
||||
|
||||
if (this.hasConnection(connection.from, connection.to)) {
|
||||
throw new ValidationError(`连接已存在:${connection.from} -> ${connection.to}`);
|
||||
}
|
||||
|
||||
if (this.wouldCreateCycle(connection.from, connection.to)) {
|
||||
throw ValidationError.circularReference(connection.to);
|
||||
}
|
||||
|
||||
if (connection.isNodeConnection()) {
|
||||
if (!fromNode.canAddChild()) {
|
||||
if (fromNode.isRoot()) {
|
||||
throw ValidationError.rootNodeMaxChildren();
|
||||
}
|
||||
if (fromNode.nodeType.isDecorator()) {
|
||||
throw ValidationError.decoratorNodeMaxChildren();
|
||||
}
|
||||
throw new ValidationError(`节点 ${connection.from} 无法添加更多子节点`);
|
||||
}
|
||||
|
||||
if (toNode.nodeType.getMaxChildren() === 0 && toNode.nodeType.isLeaf()) {
|
||||
}
|
||||
|
||||
const updatedFromNode = fromNode.addChild(connection.to);
|
||||
const newNodes = Array.from(this.nodes.map((n) =>
|
||||
n.id === connection.from ? updatedFromNode : n
|
||||
));
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
[...this._connections, connection],
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
return new BehaviorTree(
|
||||
Array.from(this.nodes),
|
||||
[...this._connections, connection],
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除连接
|
||||
*/
|
||||
removeConnection(from: string, to: string, fromProperty?: string, toProperty?: string): BehaviorTree {
|
||||
const connection = this._connections.find((c) => c.matches(from, to, fromProperty, toProperty));
|
||||
|
||||
if (!connection) {
|
||||
throw new ValidationError(`连接不存在:${from} -> ${to}`);
|
||||
}
|
||||
|
||||
const newConnections = this._connections.filter((c) => !c.matches(from, to, fromProperty, toProperty));
|
||||
|
||||
if (connection.isNodeConnection()) {
|
||||
const fromNode = this.getNode(from);
|
||||
const updatedFromNode = fromNode.removeChild(to);
|
||||
const newNodes = Array.from(this.nodes.map((n) =>
|
||||
n.id === from ? updatedFromNode : n
|
||||
));
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
newConnections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
return new BehaviorTree(
|
||||
Array.from(this.nodes),
|
||||
newConnections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否存在连接
|
||||
*/
|
||||
hasConnection(from: string, to: string): boolean {
|
||||
return this._connections.some((c) => c.from === from && c.to === to);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否会创建循环引用
|
||||
*/
|
||||
private wouldCreateCycle(from: string, to: string): boolean {
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [to];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
|
||||
if (current === from) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.has(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(current);
|
||||
|
||||
const childConnections = this._connections.filter((c) => c.from === current && c.isNodeConnection());
|
||||
childConnections.forEach((conn) => queue.push(conn.to));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新黑板
|
||||
*/
|
||||
updateBlackboard(updater: (blackboard: Blackboard) => Blackboard): BehaviorTree {
|
||||
return new BehaviorTree(
|
||||
Array.from(this.nodes),
|
||||
this._connections,
|
||||
updater(this._blackboard),
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的子节点
|
||||
*/
|
||||
getChildren(nodeId: string): Node[] {
|
||||
const node = this.getNode(nodeId);
|
||||
return node.children.map((childId) => this.getNode(childId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的父节点
|
||||
*/
|
||||
getParent(nodeId: string): Node | null {
|
||||
const parentConnection = this._connections.find(
|
||||
(c) => c.to === nodeId && c.isNodeConnection()
|
||||
);
|
||||
|
||||
if (!parentConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getNode(parentConnection.from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证树的完整性
|
||||
*/
|
||||
private validateTree(): void {
|
||||
const rootNodes = this.nodes.filter((n) => n.isRoot());
|
||||
|
||||
if (rootNodes.length > 1) {
|
||||
throw new ValidationError('行为树只能有一个根节点');
|
||||
}
|
||||
|
||||
if (rootNodes.length === 1 && rootNodes[0] && this._rootNodeId !== rootNodes[0].id) {
|
||||
throw new ValidationError('根节点ID不匹配');
|
||||
}
|
||||
|
||||
this._connections.forEach((conn) => {
|
||||
if (!this._nodes.has(conn.from)) {
|
||||
throw new NodeNotFoundError(conn.from);
|
||||
}
|
||||
if (!this._nodes.has(conn.to)) {
|
||||
throw new NodeNotFoundError(conn.to);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为普通对象
|
||||
*/
|
||||
toObject(): {
|
||||
nodes: ReturnType<Node['toObject']>[];
|
||||
connections: ReturnType<Connection['toObject']>[];
|
||||
blackboard: Record<string, unknown>;
|
||||
rootNodeId: string | null;
|
||||
} {
|
||||
return {
|
||||
nodes: this.nodes.map((n) => n.toObject()),
|
||||
connections: this._connections.map((c) => c.toObject()),
|
||||
blackboard: this._blackboard.toObject(),
|
||||
rootNodeId: this._rootNodeId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从普通对象创建行为树
|
||||
*/
|
||||
static fromObject(obj: {
|
||||
nodes: Parameters<typeof Node.fromObject>[0][];
|
||||
connections: Parameters<typeof Connection.fromObject>[0][];
|
||||
blackboard: Record<string, unknown>;
|
||||
rootNodeId: string | null;
|
||||
}): BehaviorTree {
|
||||
return new BehaviorTree(
|
||||
obj.nodes.map((n) => Node.fromObject(n)),
|
||||
obj.connections.map((c) => Connection.fromObject(c)),
|
||||
Blackboard.fromObject(obj.blackboard),
|
||||
obj.rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空行为树
|
||||
*/
|
||||
static empty(): BehaviorTree {
|
||||
return new BehaviorTree();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user