From 9a31a835edce402b471a535037e868ba388e45cb Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 16 Jun 2025 10:34:11 +0800 Subject: [PATCH 01/15] Add multi-repository sync tools and documentation --- sync-repos.ps1 | 162 +++++++++++++++++++++++++++++++++++++++++++++++ sync_workflow.md | 128 +++++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 sync-repos.ps1 create mode 100644 sync_workflow.md diff --git a/sync-repos.ps1 b/sync-repos.ps1 new file mode 100644 index 0000000..ae9390f --- /dev/null +++ b/sync-repos.ps1 @@ -0,0 +1,162 @@ +# Git Multi-Repository Sync Script (PowerShell) +# For syncing mirror repository to your personal repository + +param( + [switch]$UseRebase = $false, + [switch]$DryRun = $false +) + +function Write-ColorOutput { + param( + [string]$Message, + [string]$Color = "White" + ) + Write-Host $Message -ForegroundColor $Color +} + +function Check-GitStatus { + $status = git status --porcelain + if ($status) { + Write-ColorOutput "Warning: Working directory is not clean. Please commit or stash your changes first!" "Yellow" + git status + return $false + } + return $true +} + +function Sync-Repositories { + Write-ColorOutput "Starting repository sync..." "Cyan" + + # Check working directory status + if (-not (Check-GitStatus)) { + if (-not $DryRun) { + return + } + } + + # Fetch latest code from upstream + Write-ColorOutput "Fetching latest code from upstream..." "Green" + if (-not $DryRun) { + git fetch upstream + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "Failed to fetch upstream code!" "Red" + return + } + } else { + Write-ColorOutput "[DRY RUN] git fetch upstream" "Gray" + } + + # Check if there are new commits + $upstreamCommits = git log HEAD..upstream/master --oneline + if ($upstreamCommits) { + Write-ColorOutput "Found new commits from upstream:" "Yellow" + Write-ColorOutput $upstreamCommits "Gray" + + if ($UseRebase) { + Write-ColorOutput "Merging code using rebase..." "Green" + if (-not $DryRun) { + git rebase upstream/master + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "Rebase failed! Please resolve conflicts manually." "Red" + return + } + } else { + Write-ColorOutput "[DRY RUN] git rebase upstream/master" "Gray" + } + } else { + Write-ColorOutput "Merging code using merge..." "Green" + if (-not $DryRun) { + git merge upstream/master + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "Merge failed! Please resolve conflicts manually." "Red" + return + } + } else { + Write-ColorOutput "[DRY RUN] git merge upstream/master" "Gray" + } + } + + # Push to your repository + Write-ColorOutput "Pushing to your repository..." "Green" + if (-not $DryRun) { + if ($UseRebase) { + git push origin master --force-with-lease + } else { + git push origin master + } + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "Push failed!" "Red" + return + } + } else { + $pushCmd = if ($UseRebase) { "git push origin master --force-with-lease" } else { "git push origin master" } + Write-ColorOutput "[DRY RUN] $pushCmd" "Gray" + } + + Write-ColorOutput "Sync completed successfully!" "Green" + } else { + Write-ColorOutput "No new commits from upstream. Checking for local changes..." "Blue" + + # Check if there are local changes to push + $localCommits = git log origin/master..HEAD --oneline + if ($localCommits) { + Write-ColorOutput "Found local changes to push:" "Yellow" + Write-ColorOutput $localCommits "Gray" + + Write-ColorOutput "Pushing local changes..." "Green" + if (-not $DryRun) { + git push origin master + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "Push failed!" "Red" + return + } + } else { + Write-ColorOutput "[DRY RUN] git push origin master" "Gray" + } + Write-ColorOutput "Push completed!" "Green" + } else { + Write-ColorOutput "Nothing to sync." "Blue" + } + } +} + +function Show-Help { + Write-ColorOutput "Git Multi-Repository Sync Script" "Cyan" + Write-ColorOutput "Usage: .\sync-repos.ps1 [options]" "White" + Write-ColorOutput "" + Write-ColorOutput "Options:" "Yellow" + Write-ColorOutput " -UseRebase Use rebase instead of merge" "White" + Write-ColorOutput " -DryRun Preview mode, don't execute actual git commands" "White" + Write-ColorOutput " -Help Show this help message" "White" + Write-ColorOutput "" + Write-ColorOutput "Examples:" "Yellow" + Write-ColorOutput " .\sync-repos.ps1 # Sync using merge" "White" + Write-ColorOutput " .\sync-repos.ps1 -UseRebase # Sync using rebase" "White" + Write-ColorOutput " .\sync-repos.ps1 -DryRun # Preview mode" "White" +} + +# Main program +if ($args -contains "-Help" -or $args -contains "--help" -or $args -contains "-h") { + Show-Help + return +} + +Write-ColorOutput "Git Multi-Repository Sync Tool" "Cyan" +Write-ColorOutput "==============================" "Cyan" + +# Show current configuration +Write-ColorOutput "Current remote repository configuration:" "Blue" +git remote -v + +Write-ColorOutput "" +if ($DryRun) { + Write-ColorOutput "Preview mode - no actual git commands will be executed" "Yellow" +} +if ($UseRebase) { + Write-ColorOutput "Using rebase mode" "Yellow" +} else { + Write-ColorOutput "Using merge mode" "Yellow" +} +Write-ColorOutput "" + +Sync-Repositories \ No newline at end of file diff --git a/sync_workflow.md b/sync_workflow.md new file mode 100644 index 0000000..15651e6 --- /dev/null +++ b/sync_workflow.md @@ -0,0 +1,128 @@ +# Git多远程仓库同步工作流 + +## 仓库配置 + +- **upstream**: `http://192.168.189.2:8418/amr.core/web-amr` (镜像仓库,只读) +- **origin**: `http://192.168.189.2:8418/xudan/web-map` (你的仓库,可读写) + +## 日常工作流程 + +### 1. 从镜像仓库获取最新代码 + +```bash +# 获取镜像仓库的最新代码 +git fetch upstream + +# 查看镜像仓库的更新 +git log HEAD..upstream/master --oneline +``` + +### 2. 合并镜像仓库的最新代码到本地 + +```bash +# 方法1: 使用merge (推荐,保留完整历史) +git merge upstream/master + +# 方法2: 使用rebase (如果你想要线性历史) +git rebase upstream/master +``` + +### 3. 推送到你的仓库 + +```bash +# 推送到你的仓库 +git push origin master +``` + +## 完整的同步脚本 + +### 方法1: 使用merge + +```bash +#!/bin/bash +echo "开始同步镜像仓库..." + +# 获取镜像仓库最新代码 +git fetch upstream + +# 检查是否有新的提交 +if git log HEAD..upstream/master --oneline | grep -q .; then + echo "发现镜像仓库有新的提交,开始合并..." + + # 合并镜像仓库的代码 + git merge upstream/master + + # 推送到你的仓库 + git push origin master + + echo "同步完成!" +else + echo "镜像仓库没有新的提交,检查本地是否有未推送的修改..." + + # 检查是否有本地修改需要推送 + if git log origin/master..HEAD --oneline | grep -q .; then + echo "发现本地有未推送的修改,推送中..." + git push origin master + echo "推送完成!" + else + echo "没有需要同步的内容。" + fi +fi +``` + +### 方法2: 使用rebase (如果你想要更干净的历史) + +```bash +#!/bin/bash +echo "开始同步镜像仓库..." + +# 获取镜像仓库最新代码 +git fetch upstream + +# 检查是否有新的提交 +if git log HEAD..upstream/master --oneline | grep -q .; then + echo "发现镜像仓库有新的提交,开始rebase..." + + # rebase到镜像仓库的最新代码 + git rebase upstream/master + + # 推送到你的仓库 (可能需要force push) + git push origin master --force-with-lease + + echo "同步完成!" +else + echo "镜像仓库没有新的提交,检查本地是否有未推送的修改..." + + # 检查是否有本地修改需要推送 + if git log origin/master..HEAD --oneline | grep -q .; then + echo "发现本地有未推送的修改,推送中..." + git push origin master + echo "推送完成!" + else + echo "没有需要同步的内容。" + fi +fi +``` + +## 推荐的工作流程 + +1. **每天开始工作前**: 运行同步脚本,确保有最新的镜像仓库代码 +2. **进行本地开发**: 正常开发你的功能 +3. **提交本地修改**: `git add` 和 `git commit` +4. **推送前再次同步**: 运行同步脚本,确保没有冲突 +5. **推送到你的仓库**: `git push origin master` + +## 处理冲突 + +如果在合并时遇到冲突: + +1. 解决冲突文件 +2. `git add` 冲突文件 +3. `git commit` 完成合并 +4. `git push origin master` + +## 注意事项 + +- 使用 `--force-with-lease` 而不是 `--force` 来避免意外覆盖其他人的提交 +- 定期清理本地分支: `git branch -d ` +- 如果镜像仓库有大量更新,考虑创建一个新分支来处理合并 From d495296cd8da0d4ff36e46586c23a6cb497ce3a7 Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 16 Jun 2025 10:39:47 +0800 Subject: [PATCH 02/15] Update sync script: remove auto-push and fix encoding issues --- sync-repos.ps1 | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/sync-repos.ps1 b/sync-repos.ps1 index ae9390f..7e97937 100644 --- a/sync-repos.ps1 +++ b/sync-repos.ps1 @@ -1,5 +1,4 @@ # Git Multi-Repository Sync Script (PowerShell) -# For syncing mirror repository to your personal repository param( [switch]$UseRebase = $false, @@ -58,6 +57,7 @@ function Sync-Repositories { git rebase upstream/master if ($LASTEXITCODE -ne 0) { Write-ColorOutput "Rebase failed! Please resolve conflicts manually." "Red" + Write-ColorOutput "After resolving conflicts, run: git rebase --continue" "Yellow" return } } else { @@ -69,6 +69,7 @@ function Sync-Repositories { git merge upstream/master if ($LASTEXITCODE -ne 0) { Write-ColorOutput "Merge failed! Please resolve conflicts manually." "Red" + Write-ColorOutput "After resolving conflicts, run: git commit" "Yellow" return } } else { @@ -76,24 +77,14 @@ function Sync-Repositories { } } - # Push to your repository - Write-ColorOutput "Pushing to your repository..." "Green" - if (-not $DryRun) { - if ($UseRebase) { - git push origin master --force-with-lease - } else { - git push origin master - } - if ($LASTEXITCODE -ne 0) { - Write-ColorOutput "Push failed!" "Red" - return - } - } else { - $pushCmd = if ($UseRebase) { "git push origin master --force-with-lease" } else { "git push origin master" } - Write-ColorOutput "[DRY RUN] $pushCmd" "Gray" + Write-ColorOutput "Sync completed successfully!" "Green" + Write-ColorOutput "" + Write-ColorOutput "Next steps:" "Cyan" + Write-ColorOutput " Please manually push to your repository: git push origin master" "White" + if ($UseRebase) { + Write-ColorOutput " (If using rebase, you might need: git push origin master --force-with-lease)" "Yellow" } - Write-ColorOutput "Sync completed successfully!" "Green" } else { Write-ColorOutput "No new commits from upstream. Checking for local changes..." "Blue" @@ -103,17 +94,9 @@ function Sync-Repositories { Write-ColorOutput "Found local changes to push:" "Yellow" Write-ColorOutput $localCommits "Gray" - Write-ColorOutput "Pushing local changes..." "Green" - if (-not $DryRun) { - git push origin master - if ($LASTEXITCODE -ne 0) { - Write-ColorOutput "Push failed!" "Red" - return - } - } else { - Write-ColorOutput "[DRY RUN] git push origin master" "Gray" - } - Write-ColorOutput "Push completed!" "Green" + Write-ColorOutput "" + Write-ColorOutput "Next steps:" "Cyan" + Write-ColorOutput " Please manually push to your repository: git push origin master" "White" } else { Write-ColorOutput "Nothing to sync." "Blue" } @@ -133,6 +116,8 @@ function Show-Help { Write-ColorOutput " .\sync-repos.ps1 # Sync using merge" "White" Write-ColorOutput " .\sync-repos.ps1 -UseRebase # Sync using rebase" "White" Write-ColorOutput " .\sync-repos.ps1 -DryRun # Preview mode" "White" + Write-ColorOutput "" + Write-ColorOutput "Note: Script will NOT automatically push to your repository." "Yellow" } # Main program From bdfeaed850e7e548d61b108f3641b9f1a42035a6 Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 16 Jun 2025 10:45:06 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat:=E5=88=A0=E9=99=A4=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=96=87=E6=A1=A3=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0PowerShell=E8=84=9A=E6=9C=AC=E4=B8=BA=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=B3=A8=E9=87=8A=EF=BC=8C=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=8F=90=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sync-repos.ps1 | 102 +++++++++++++++++++------------------ sync_workflow.md | 128 ----------------------------------------------- 2 files changed, 54 insertions(+), 176 deletions(-) delete mode 100644 sync_workflow.md diff --git a/sync-repos.ps1 b/sync-repos.ps1 index 7e97937..4946215 100644 --- a/sync-repos.ps1 +++ b/sync-repos.ps1 @@ -1,4 +1,5 @@ -# Git Multi-Repository Sync Script (PowerShell) +# Git多仓库同步脚本 (PowerShell) +# 用于同步镜像仓库到你的个人仓库 param( [switch]$UseRebase = $false, @@ -16,7 +17,7 @@ function Write-ColorOutput { function Check-GitStatus { $status = git status --porcelain if ($status) { - Write-ColorOutput "Warning: Working directory is not clean. Please commit or stash your changes first!" "Yellow" + Write-ColorOutput "警告: 工作目录不干净,请先提交或储藏你的更改!" "Yellow" git status return $false } @@ -24,124 +25,129 @@ function Check-GitStatus { } function Sync-Repositories { - Write-ColorOutput "Starting repository sync..." "Cyan" + Write-ColorOutput "开始同步镜像仓库..." "Cyan" - # Check working directory status + # 检查工作目录状态 if (-not (Check-GitStatus)) { if (-not $DryRun) { return } } - # Fetch latest code from upstream - Write-ColorOutput "Fetching latest code from upstream..." "Green" + # 获取镜像仓库最新代码 + Write-ColorOutput "正在获取upstream最新代码..." "Green" if (-not $DryRun) { git fetch upstream if ($LASTEXITCODE -ne 0) { - Write-ColorOutput "Failed to fetch upstream code!" "Red" + Write-ColorOutput "获取upstream代码失败!" "Red" return } } else { - Write-ColorOutput "[DRY RUN] git fetch upstream" "Gray" + Write-ColorOutput "[预览模式] git fetch upstream" "Gray" } - # Check if there are new commits + # 检查是否有新的提交 $upstreamCommits = git log HEAD..upstream/master --oneline if ($upstreamCommits) { - Write-ColorOutput "Found new commits from upstream:" "Yellow" + Write-ColorOutput "发现镜像仓库有新的提交:" "Yellow" Write-ColorOutput $upstreamCommits "Gray" if ($UseRebase) { - Write-ColorOutput "Merging code using rebase..." "Green" + Write-ColorOutput "正在使用rebase合并代码..." "Green" if (-not $DryRun) { git rebase upstream/master if ($LASTEXITCODE -ne 0) { - Write-ColorOutput "Rebase failed! Please resolve conflicts manually." "Red" - Write-ColorOutput "After resolving conflicts, run: git rebase --continue" "Yellow" + Write-ColorOutput "Rebase失败!请手动解决冲突后再继续。" "Red" + Write-ColorOutput "解决冲突后请运行: git rebase --continue" "Yellow" return } } else { - Write-ColorOutput "[DRY RUN] git rebase upstream/master" "Gray" + Write-ColorOutput "[预览模式] git rebase upstream/master" "Gray" } } else { - Write-ColorOutput "Merging code using merge..." "Green" + Write-ColorOutput "正在使用merge合并代码..." "Green" if (-not $DryRun) { git merge upstream/master if ($LASTEXITCODE -ne 0) { - Write-ColorOutput "Merge failed! Please resolve conflicts manually." "Red" - Write-ColorOutput "After resolving conflicts, run: git commit" "Yellow" + Write-ColorOutput "Merge失败!请手动解决冲突后再继续。" "Red" + Write-ColorOutput "解决冲突后请运行: git commit" "Yellow" return } } else { - Write-ColorOutput "[DRY RUN] git merge upstream/master" "Gray" + Write-ColorOutput "[预览模式] git merge upstream/master" "Gray" } } - Write-ColorOutput "Sync completed successfully!" "Green" + Write-ColorOutput "同步完成!" "Green" Write-ColorOutput "" - Write-ColorOutput "Next steps:" "Cyan" - Write-ColorOutput " Please manually push to your repository: git push origin master" "White" + Write-ColorOutput "下一步操作提示:" "Cyan" + Write-ColorOutput " 请手动推送到你的仓库: git push origin master" "White" if ($UseRebase) { - Write-ColorOutput " (If using rebase, you might need: git push origin master --force-with-lease)" "Yellow" + Write-ColorOutput " (如果使用了rebase,可能需要: git push origin master --force-with-lease)" "Yellow" } } else { - Write-ColorOutput "No new commits from upstream. Checking for local changes..." "Blue" + Write-ColorOutput "镜像仓库没有新的提交,检查本地是否有未推送的修改..." "Blue" - # Check if there are local changes to push + # 检查是否有本地修改需要推送 $localCommits = git log origin/master..HEAD --oneline if ($localCommits) { - Write-ColorOutput "Found local changes to push:" "Yellow" + Write-ColorOutput "发现本地有未推送的修改:" "Yellow" Write-ColorOutput $localCommits "Gray" Write-ColorOutput "" - Write-ColorOutput "Next steps:" "Cyan" - Write-ColorOutput " Please manually push to your repository: git push origin master" "White" + Write-ColorOutput "下一步操作提示:" "Cyan" + Write-ColorOutput " 请手动推送到你的仓库: git push origin master" "White" } else { - Write-ColorOutput "Nothing to sync." "Blue" + Write-ColorOutput "没有需要同步的内容。" "Blue" } } } function Show-Help { - Write-ColorOutput "Git Multi-Repository Sync Script" "Cyan" - Write-ColorOutput "Usage: .\sync-repos.ps1 [options]" "White" + Write-ColorOutput "Git多仓库同步脚本" "Cyan" + Write-ColorOutput "用法: .\sync-repos.ps1 [选项]" "White" Write-ColorOutput "" - Write-ColorOutput "Options:" "Yellow" - Write-ColorOutput " -UseRebase Use rebase instead of merge" "White" - Write-ColorOutput " -DryRun Preview mode, don't execute actual git commands" "White" - Write-ColorOutput " -Help Show this help message" "White" + Write-ColorOutput "选项:" "Yellow" + Write-ColorOutput " -UseRebase 使用rebase代替merge进行合并" "White" + Write-ColorOutput " -DryRun 预览模式,不执行实际的git命令" "White" + Write-ColorOutput " -Help 显示此帮助信息" "White" Write-ColorOutput "" - Write-ColorOutput "Examples:" "Yellow" - Write-ColorOutput " .\sync-repos.ps1 # Sync using merge" "White" - Write-ColorOutput " .\sync-repos.ps1 -UseRebase # Sync using rebase" "White" - Write-ColorOutput " .\sync-repos.ps1 -DryRun # Preview mode" "White" + Write-ColorOutput "示例:" "Yellow" + Write-ColorOutput " .\sync-repos.ps1 # 使用merge同步" "White" + Write-ColorOutput " .\sync-repos.ps1 -UseRebase # 使用rebase同步" "White" + Write-ColorOutput " .\sync-repos.ps1 -DryRun # 预览模式" "White" Write-ColorOutput "" - Write-ColorOutput "Note: Script will NOT automatically push to your repository." "Yellow" + Write-ColorOutput "注意: 脚本不会自动推送到你的仓库,需要手动执行推送命令。" "Yellow" } -# Main program +# 主程序 if ($args -contains "-Help" -or $args -contains "--help" -or $args -contains "-h") { Show-Help return } -Write-ColorOutput "Git Multi-Repository Sync Tool" "Cyan" -Write-ColorOutput "==============================" "Cyan" +Write-ColorOutput "Git多仓库同步工具" "Cyan" +Write-ColorOutput "===================" "Cyan" -# Show current configuration -Write-ColorOutput "Current remote repository configuration:" "Blue" +# 显示当前配置 +Write-ColorOutput "当前远程仓库配置:" "Blue" git remote -v Write-ColorOutput "" if ($DryRun) { - Write-ColorOutput "Preview mode - no actual git commands will be executed" "Yellow" + Write-ColorOutput "预览模式 - 不会执行实际的git命令" "Yellow" } if ($UseRebase) { - Write-ColorOutput "Using rebase mode" "Yellow" + Write-ColorOutput "使用rebase模式" "Yellow" } else { - Write-ColorOutput "Using merge mode" "Yellow" + Write-ColorOutput "使用merge模式" "Yellow" } Write-ColorOutput "" -Sync-Repositories \ No newline at end of file +Sync-Repositories + +# 等待用户按键后再关闭 +Write-ColorOutput "" +Write-ColorOutput "按任意键继续..." "Green" +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") \ No newline at end of file diff --git a/sync_workflow.md b/sync_workflow.md deleted file mode 100644 index 15651e6..0000000 --- a/sync_workflow.md +++ /dev/null @@ -1,128 +0,0 @@ -# Git多远程仓库同步工作流 - -## 仓库配置 - -- **upstream**: `http://192.168.189.2:8418/amr.core/web-amr` (镜像仓库,只读) -- **origin**: `http://192.168.189.2:8418/xudan/web-map` (你的仓库,可读写) - -## 日常工作流程 - -### 1. 从镜像仓库获取最新代码 - -```bash -# 获取镜像仓库的最新代码 -git fetch upstream - -# 查看镜像仓库的更新 -git log HEAD..upstream/master --oneline -``` - -### 2. 合并镜像仓库的最新代码到本地 - -```bash -# 方法1: 使用merge (推荐,保留完整历史) -git merge upstream/master - -# 方法2: 使用rebase (如果你想要线性历史) -git rebase upstream/master -``` - -### 3. 推送到你的仓库 - -```bash -# 推送到你的仓库 -git push origin master -``` - -## 完整的同步脚本 - -### 方法1: 使用merge - -```bash -#!/bin/bash -echo "开始同步镜像仓库..." - -# 获取镜像仓库最新代码 -git fetch upstream - -# 检查是否有新的提交 -if git log HEAD..upstream/master --oneline | grep -q .; then - echo "发现镜像仓库有新的提交,开始合并..." - - # 合并镜像仓库的代码 - git merge upstream/master - - # 推送到你的仓库 - git push origin master - - echo "同步完成!" -else - echo "镜像仓库没有新的提交,检查本地是否有未推送的修改..." - - # 检查是否有本地修改需要推送 - if git log origin/master..HEAD --oneline | grep -q .; then - echo "发现本地有未推送的修改,推送中..." - git push origin master - echo "推送完成!" - else - echo "没有需要同步的内容。" - fi -fi -``` - -### 方法2: 使用rebase (如果你想要更干净的历史) - -```bash -#!/bin/bash -echo "开始同步镜像仓库..." - -# 获取镜像仓库最新代码 -git fetch upstream - -# 检查是否有新的提交 -if git log HEAD..upstream/master --oneline | grep -q .; then - echo "发现镜像仓库有新的提交,开始rebase..." - - # rebase到镜像仓库的最新代码 - git rebase upstream/master - - # 推送到你的仓库 (可能需要force push) - git push origin master --force-with-lease - - echo "同步完成!" -else - echo "镜像仓库没有新的提交,检查本地是否有未推送的修改..." - - # 检查是否有本地修改需要推送 - if git log origin/master..HEAD --oneline | grep -q .; then - echo "发现本地有未推送的修改,推送中..." - git push origin master - echo "推送完成!" - else - echo "没有需要同步的内容。" - fi -fi -``` - -## 推荐的工作流程 - -1. **每天开始工作前**: 运行同步脚本,确保有最新的镜像仓库代码 -2. **进行本地开发**: 正常开发你的功能 -3. **提交本地修改**: `git add` 和 `git commit` -4. **推送前再次同步**: 运行同步脚本,确保没有冲突 -5. **推送到你的仓库**: `git push origin master` - -## 处理冲突 - -如果在合并时遇到冲突: - -1. 解决冲突文件 -2. `git add` 冲突文件 -3. `git commit` 完成合并 -4. `git push origin master` - -## 注意事项 - -- 使用 `--force-with-lease` 而不是 `--force` 来避免意外覆盖其他人的提交 -- 定期清理本地分支: `git branch -d ` -- 如果镜像仓库有大量更新,考虑创建一个新分支来处理合并 From ac07eae01a18755a43f059aa7c7ae88f7f576bf8 Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 16 Jun 2025 11:05:46 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=84=9A=E6=9C=AC=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sync-repos.ps1 => 自动同步仓库.ps1 | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename sync-repos.ps1 => 自动同步仓库.ps1 (100%) diff --git a/sync-repos.ps1 b/自动同步仓库.ps1 similarity index 100% rename from sync-repos.ps1 rename to 自动同步仓库.ps1 From 96def3b411546abe5e3e704138e64fa35926bb28 Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 16 Jun 2025 16:00:16 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0token=E5=92=8C=E7=A7=9F=E6=88=B7ID=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 6 +++++- src/services/http.ts | 28 +++++++++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.env.development b/.env.development index df9a476..953bb31 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,7 @@ ENV_APP_TITLE=运输控制系统(开发) # ENV_HTTP_BASE=/mocks -ENV_WEBSOCKET_BASE=/ws \ No newline at end of file +ENV_WEBSOCKET_BASE=/ws + +# 开发环境token配置 - 可以手动设置或从另一个项目获取后填入 +ENV_DEV_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTAzMzkwMTcsInVzZXJuYW1lIjoiYWRtaW4ifQ.uGWMIPH9-sdyEwr0bQBMKQSTAjYBZhlIVDRHGtheENE +ENV_DEV_TENANT_ID=1000 \ No newline at end of file diff --git a/src/services/http.ts b/src/services/http.ts index 845ca40..1c0ff06 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -11,15 +11,29 @@ export default http; // 添加请求拦截器 http.interceptors.request.use( - (config) => { + async (config) => { try { - const token = - JSON.parse(localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}')?.value - .TOKEN__.value || ''; + let token = ''; + let tenantId = ''; + + // 开发环境处理逻辑 + if (import.meta.env.DEV) { + try { + token = import.meta.env.ENV_DEV_TOKEN; + tenantId = import.meta.env.ENV_DEV_TENANT_ID; + } catch (error) { + console.error('获取开发环境token失败:', error); + } + } else { + // 生产环境直接从localStorage获取 + const localStorageData = JSON.parse( + localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}', + ); + token = localStorageData?.value?.TOKEN__?.value || ''; + tenantId = localStorageData?.value?.TENANT_ID?.value || ''; + } + config.headers['x-access-token'] = token; - const tenantId = - JSON.parse(localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}')?.value - .TENANT_ID.value || ''; config.headers['x-tenant-id'] = tenantId; console.log(config); } catch (error) { From b874ca43f0ac5e8d11d2d928320d67bff61e7671 Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 16 Jun 2025 17:56:09 +0800 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=BC=96=E8=BE=91=E5=99=A8=E6=A0=B8=E5=BF=83=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E5=92=8C=E7=BB=84=E4=BB=B6=E8=AF=A6=E7=BB=86=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 场景编辑器组件详细分析.md | 508 ++++++++++++++++++++++++++++ 编辑器服务核心架构分析.md | 677 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1185 insertions(+) create mode 100644 场景编辑器组件详细分析.md create mode 100644 编辑器服务核心架构分析.md diff --git a/场景编辑器组件详细分析.md b/场景编辑器组件详细分析.md new file mode 100644 index 0000000..c9f608e --- /dev/null +++ b/场景编辑器组件详细分析.md @@ -0,0 +1,508 @@ +# 场景编辑器组件详细分析 + +## 1. 组件概述 + +`scene-editor.vue` 是一个基于 Vue 3 的复杂场景编辑器组件,主要用于管理和编辑工业机器人的场景配置。该组件提供了完整的场景编辑功能,包括机器人管理、路径规划、区域设置等。 + +## 2. 核心功能分析 + +### 2.1 场景数据管理 + +- **场景读取**: 通过 `getSceneById` API 获取场景数据 +- **场景推送**: 通过 `pushSceneById` API 将场景数据推送到数据库 +- **场景保存**: 通过编辑器服务保存场景配置 +- **文件导入/导出**: 支持 `.scene` 格式文件的导入导出 + +### 2.2 编辑器状态控制 + +- **编辑模式切换**: 通过 `editable` 状态控制编辑器的启用/禁用 +- **权限管理**: 根据编辑状态显示不同的操作按钮和功能 +- **实时状态同步**: 编辑状态变化时自动更新编辑器服务状态 + +### 2.3 三大管理区域 + +- **机器人管理**: 显示和管理场景中的机器人组和单个机器人 +- **库区管理**: 管理各种类型的库区(仅显示库区类型的区域) +- **高级组管理**: 管理复杂的路径、点位、区域等元素 + +### 2.4 详情卡片系统 + +- **动态卡片显示**: 根据选中元素类型显示对应的详情卡片 +- **编辑/查看模式**: 根据编辑状态显示编辑卡片或查看卡片 +- **悬浮定位**: 卡片固定在右侧悬浮显示 + +## 3. 技术架构分析 + +### 3.1 核心依赖关系 + +```typescript +// 主要导入依赖 +import { getSceneById, pushSceneById } from '@api/scene'; // 场景API +import { EditorService } from '@core/editor.service'; // 编辑器服务 +import { decodeTextFile, downloadFile, selectFile, textToBlob } from '@core/utils'; // 工具函数 +``` + +### 3.2 组件架构设计 + +#### 3.2.1 状态管理 + +```typescript +// 核心状态定义 +const title = ref(''); // 场景标题 +const editable = ref(false); // 编辑状态 +const show = ref(true); // 卡片显示状态 +const current = ref<{ type: string; id: string }>(); // 当前选中元素 +const container = shallowRef(); // 编辑器容器 +const editor = shallowRef(); // 编辑器服务实例 +``` + +#### 3.2.2 依赖注入系统 + +```typescript +const EDITOR_KEY = Symbol('editor-key'); +provide(EDITOR_KEY, editor); +``` + +使用 Vue 3 的依赖注入机制,将编辑器服务注入到子组件中。 + +### 3.3 EditorService 核心服务分析 + +#### 3.3.1 服务基础 + +```typescript +export class EditorService extends Meta2d { + // 继承自 Meta2d 图形引擎 + // 提供场景编辑的核心功能 +} +``` + +#### 3.3.2 核心方法 + +- **load()**: 加载场景数据到编辑器 +- **save()**: 保存当前场景数据 +- **setState()**: 设置编辑器状态(可编辑/只读) +- **updateRobots()**: 更新机器人数据 +- **addArea()**: 添加区域 +- **deleteById()**: 删除指定元素 + +### 3.4 API 接口设计 + +#### 3.4.1 场景相关API + +```typescript +// 获取场景数据 +export async function getSceneById(id: string): Promise; + +// 推送场景到数据库 +export async function pushSceneById(id: string): Promise; + +// 保存场景数据 +export async function saveSceneById(id: string, json: string, png?: string): Promise; +``` + +#### 3.4.2 文件操作工具 + +```typescript +// 文件选择 +export async function selectFile(accept?: string, limit?: number): Promise; + +// 文件解码 +export async function decodeTextFile(file: File): Promise; + +// 文本转二进制 +export function textToBlob(text: string): Blob | undefined; + +// 文件下载 +export function downloadFile(url: string, name?: string): void; +``` + +## 4. 从零开发实现过程 + +### 4.1 第一步:创建基础组件结构 + +```vue + + + +``` + +### 4.2 第二步:集成编辑器服务 + +```typescript +// 1. 导入编辑器服务 +import { EditorService } from '@core/editor.service'; + +// 2. 创建编辑器实例 +const container = shallowRef(); +const editor = shallowRef(); + +// 3. 组件挂载时初始化编辑器 +onMounted(() => { + editor.value = new EditorService(container.value!); +}); + +// 4. 设置依赖注入 +const EDITOR_KEY = Symbol('editor-key'); +provide(EDITOR_KEY, editor); +``` + +### 4.3 第三步:实现场景数据管理 + +```typescript +// 1. 导入API +import { getSceneById, pushSceneById } from '@api/scene'; + +// 2. 读取场景数据 +const readScene = async () => { + const res = await getSceneById(props.id); + title.value = res?.label ?? ''; + editor.value?.load(res?.json, editable.value); +}; + +// 3. 推送场景数据 +const pushScene = async () => { + const res = await pushSceneById(props.id); + if (!res) return Promise.reject(); + message.success(t('场景推送成功')); + return Promise.resolve(); +}; + +// 4. 监听场景ID变化 +watch( + () => props.id, + () => readScene(), + { immediate: true, flush: 'post' }, +); +``` + +### 4.4 第四步:实现文件导入导出 + +```typescript +// 1. 导入工具函数 +import { decodeTextFile, downloadFile, selectFile, textToBlob } from '@core/utils'; + +// 2. 导入场景文件 +const importScene = async () => { + const file = await selectFile('.scene'); + if (!file?.size) return; + const json = await decodeTextFile(file); + editor.value?.load(json, editable.value); +}; + +// 3. 导出场景文件 +const exportScene = () => { + const json = editor.value?.save(); + if (!json) return; + const blob = textToBlob(json); + if (!blob?.size) return; + const url = URL.createObjectURL(blob); + downloadFile(url, `${title.value || 'unknown'}.scene`); + URL.revokeObjectURL(url); +}; +``` + +### 4.5 第五步:集成管理组件 + +```vue + +``` + +### 4.6 第六步:实现选中元素监听 + +```typescript +// 1. 监听编辑器选中元素 +watch( + () => editor.value?.selected.value[0], + (v) => { + const pen = editor.value?.getPenById(v); + if (pen?.id) { + current.value = { type: pen.name as 'point' | 'line' | 'area', id: pen.id }; + return; + } + if (current.value?.type === 'robot') return; + current.value = undefined; + }, +); + +// 2. 计算选中元素类型 +const isRobot = computed(() => current.value?.type === 'robot'); +const isPoint = computed(() => current.value?.type === 'point'); +const isRoute = computed(() => current.value?.type === 'line'); +const isArea = computed(() => current.value?.type === 'area'); + +// 3. 机器人选择处理 +const selectRobot = (id: string) => { + current.value = { type: 'robot', id }; + editor.value?.inactive(); +}; +``` + +### 4.7 第七步:添加工具栏和详情卡片 + +```vue + +``` + +## 5. 子组件详细分析 + +### 5.1 RobotGroups 组件 + +**功能**: 管理机器人组和单个机器人 +**核心特性**: + +- 机器人组的增删改查 +- 机器人的添加、注册、移除 +- 批量操作支持(全选、批量移除) +- 搜索过滤功能 + +**关键实现**: + +```typescript +// 机器人列表获取 +const robots = computed(() => editor.value.robots.filter(({ label }) => label.includes(keyword.value))); + +// 批量选择管理 +const selected = reactive>(new Set()); +const selectAll = (checked: boolean) => { + if (checked) { + robots.value.forEach(({ id }) => selected.add(id)); + } else { + selected.clear(); + } +}; +``` + +### 5.2 PenGroups 组件 + +**功能**: 管理点位、路线、区域等绘制元素 +**核心特性**: + +- 分类显示不同类型的元素(点位、路线、区域) +- 支持筛选特定类型(如仅显示库区) +- 搜索过滤功能 +- 点击选中功能 + +**关键实现**: + +```typescript +// 点位列表 +const points = computed(() => + editor.value.points.value.filter(({ label }) => label?.includes(keyword.value)), +); + +// 区域列表(按类型分组) +const areas = computed(() => editor.value.areas.value.filter(({ label }) => label?.includes(keyword.value))); +``` + +### 5.3 EditorToolbar 组件 + +**功能**: 提供编辑工具栏 +**核心特性**: + +- 区域添加工具(库区、互斥区、非互斥区) +- 场景保存功能 +- 撤销/重做操作 +- 删除操作 + +**关键实现**: + +```typescript +// 区域添加模式 +const mode = ref(); +watch(editor.value.mouseBrush, (v) => { + if (!mode.value) return; + const [p1, p2] = v ?? []; + if (isEmpty(p1) || isEmpty(p2)) return; + editor.value.addArea(p1, p2, mode.value); + mode.value = undefined; +}); +``` + +## 6. 样式设计分析 + +### 6.1 布局结构 + +- **头部**: 固定高度64px,包含标题和操作按钮 +- **主体**: 左侧面板320px宽度,右侧编辑器自适应 +- **工具栏**: 固定在底部中央,悬浮显示 +- **详情卡片**: 固定在右侧,320px宽度,悬浮显示 + +### 6.2 核心样式 + +```scss +.editor-container { + background-color: transparent !important; +} + +.toolbar-container { + position: fixed; + bottom: 40px; + left: 50%; + z-index: 100; + transform: translateX(-50%); +} + +.card-container { + position: fixed; + top: 80px; + right: 64px; + z-index: 100; + width: 320px; + height: calc(100% - 96px); + overflow: visible; + pointer-events: none; + + & > * { + pointer-events: all; + } +} +``` + +## 7. 维护和调试指南 + +### 7.1 常见问题排查 + +#### 问题1: 场景数据加载失败 + +**排查步骤**: + +1. 检查 `props.id` 是否正确传入 +2. 检查 `getSceneById` API 是否正常响应 +3. 检查编辑器服务是否正确初始化 + +#### 问题2: 编辑器功能异常 + +**排查步骤**: + +1. 检查 `container` 元素是否正确获取 +2. 检查 `EditorService` 是否正确实例化 +3. 检查依赖注入是否正常工作 + +#### 问题3: 文件导入导出失败 + +**排查步骤**: + +1. 检查工具函数是否正确导入 +2. 检查文件格式是否正确 +3. 检查浏览器兼容性 + +### 7.2 性能优化建议 + +1. **使用 shallowRef**: 对于大对象使用 `shallowRef` 避免深度响应式 +2. **组件懒加载**: 使用 `v-if` 控制组件渲染时机 +3. **事件防抖**: 对于频繁触发的事件(如搜索)使用防抖 +4. **内存管理**: 及时清理事件监听器和定时器 + +### 7.3 扩展开发指南 + +#### 添加新的元素类型 + +1. 在 `EditorService` 中添加对应的管理方法 +2. 在 `PenGroups` 组件中添加新的分组 +3. 创建对应的详情卡片组件 +4. 在主组件中添加类型判断逻辑 + +#### 添加新的工具 + +1. 在 `EditorToolbar` 组件中添加工具按钮 +2. 在 `EditorService` 中实现对应功能 +3. 处理工具状态管理和交互逻辑 + +## 8. 总结 + +这个场景编辑器组件是一个功能完整、架构清晰的复杂组件,主要特点: + +1. **模块化设计**: 通过子组件分离不同功能模块 +2. **服务化架构**: 核心逻辑封装在 EditorService 中 +3. **响应式状态管理**: 使用 Vue 3 的响应式系统管理复杂状态 +4. **依赖注入**: 通过 provide/inject 实现服务共享 +5. **文件操作**: 完整的文件导入导出功能 +6. **用户体验**: 良好的交互设计和视觉反馈 + +对于维护和扩展这个组件,需要重点关注: + +- EditorService 的 API 设计和实现 +- 各子组件之间的通信机制 +- 状态管理的一致性 +- 性能优化和内存管理 + diff --git a/编辑器服务核心架构分析.md b/编辑器服务核心架构分析.md new file mode 100644 index 0000000..82386c0 --- /dev/null +++ b/编辑器服务核心架构分析.md @@ -0,0 +1,677 @@ +# 编辑器服务核心架构分析 + +## 1. 概述 + +`EditorService` 是整个场景编辑器的核心服务类,继承自 `Meta2d` 图形引擎。它负责管理场景中的所有元素(机器人、点位、路线、区域),处理用户交互,以及场景数据的序列化和反序列化。 + +```typescript +export class EditorService extends Meta2d { + // 继承 Meta2d 获得强大的 2D 图形渲染能力 +} +``` + +## 2. 核心架构分析 + +### 2.1 继承架构 + +``` +EditorService + ↓ 继承 +Meta2d (第三方图形引擎) + ↓ 提供 +- Canvas 渲染能力 +- 图形元素管理 +- 事件系统 +- 坐标变换 +- 撤销重做 +``` + +### 2.2 核心组成模块 + +1. **场景文件管理** - 序列化/反序列化 +2. **机器人管理** - 机器人组和个体管理 +3. **点位管理** - 各种类型点位的创建和管理 +4. **路线管理** - 连接点位的路径管理 +5. **区域管理** - 矩形区域的创建和管理 +6. **实时交互** - 鼠标事件处理和状态管理 +7. **自定义绘制** - Canvas 绘制函数 +8. **事件监听** - 编辑器状态变化监听 + +## 3. 场景文件管理详解 + +### 3.1 场景数据结构 + +```typescript +type StandardScene = { + robotGroups?: RobotGroup[]; // 机器人组 + robots?: RobotInfo[]; // 机器人列表 + points?: StandardScenePoint[]; // 点位数据 + routes?: StandardSceneRoute[]; // 路线数据 + areas?: StandardSceneArea[]; // 区域数据 + blocks?: any[]; // 其他块数据 +}; +``` + +### 3.2 场景加载过程(为什么场景文件能生成对应区域) + +#### 3.2.1 加载入口函数 + +```typescript +public async load(map?: string, editable = false, detail?: Partial): Promise { + // 1. 解析 JSON 字符串为场景对象 + const scene: StandardScene = map ? JSON.parse(map) : {}; + + // 2. 如果有组详情,优先使用组数据 + if (!isEmpty(detail?.group)) { + scene.robotGroups = [detail.group]; + scene.robots = detail.robots; + } + + // 3. 提取各类数据 + const { robotGroups, robots, points, routes, areas } = scene; + + // 4. 初始化编辑器 + this.open(); // 打开 Meta2d 画布 + this.setState(editable); // 设置编辑状态 + + // 5. 按顺序加载各类元素 + this.#loadRobots(robotGroups, robots); // 加载机器人 + await this.#loadScenePoints(points); // 加载点位 + this.#loadSceneRoutes(routes); // 加载路线 + await this.#loadSceneAreas(areas); // 加载区域 ⭐ + + // 6. 清空历史记录 + this.store.historyIndex = undefined; + this.store.histories = []; +} +``` + +#### 3.2.2 区域加载详细过程 + +```typescript +async #loadSceneAreas(areas?: StandardSceneArea[]): Promise { + if (!areas?.length) return; + + // 并行处理所有区域 + await Promise.all( + areas.map(async (v) => { + // 1. 从场景数据中提取区域信息 + const { id, name, desc, x, y, w, h, type, points, routes, properties } = v; + + // 2. 调用 addArea 方法在画布上创建实际的图形对象 + await this.addArea( + { x, y }, // 左上角坐标 + { x: x + w, y: y + h }, // 右下角坐标 + type, // 区域类型 + id // 区域ID + ); + + // 3. 设置区域的详细属性 + this.setValue( + { + id, + label: name, // 显示名称 + desc, // 描述 + properties, // 自定义属性 + area: { type, points, routes } // 区域特定数据 + }, + { render: false, history: false, doEvent: false } + ); + }) + ); +} +``` + +**关键理解点**: + +- 场景文件中的 `areas` 数组包含了所有区域的完整信息 +- 每个区域包含位置 `(x, y, w, h)`、类型 `type`、关联的点位和路线 +- `addArea` 方法负责在 Canvas 上创建实际的可视化图形 +- `setValue` 方法设置图形对象的业务属性 + +## 4. 区域绘制原理详解(为什么可以在页面画一个区域) + +### 4.1 鼠标事件监听系统 + +```typescript +// 鼠标事件主题 +readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>(); + +// 点击事件流 +public readonly mouseClick = useObservable( + this.#mouse$$.pipe( + filter(({ type }) => type === 'click'), + debounceTime(100), + map(({ value }) => value), + ), +); + +// 拖拽事件流 ⭐ 关键!这是画区域的核心 +public readonly mouseBrush = useObservable<[Point, Point]>( + this.#mouse$$.pipe( + filter(({ type }) => type === 'mousedown'), // 监听鼠标按下 + switchMap(({ value: s }) => + this.#mouse$$.pipe( + filter(({ type }) => type === 'mouseup'), // 监听鼠标抬起 + map(({ value: e }) => <[Point, Point]>[s, e]), // 返回起始和结束点 + ), + ), + ), +); +``` + +### 4.2 工具栏组件中的区域创建监听 + +```typescript +// 在 EditorToolbar 组件中 +const mode = ref(); + +// 监听鼠标拖拽事件 +watch(editor.value.mouseBrush, (v) => { + if (!mode.value) return; // 如果没有选择区域工具,不处理 + const [p1, p2] = v ?? []; // 获取起始点和结束点 + if (isEmpty(p1) || isEmpty(p2)) return; // 验证点位有效性 + + // 调用编辑器服务创建区域 ⭐ + editor.value.addArea(p1, p2, mode.value); + mode.value = undefined; // 重置工具状态 +}); +``` + +### 4.3 addArea 方法详细实现 + +```typescript +public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { + // 1. 获取当前缩放比例 + const scale = this.data().scale ?? 1; + + // 2. 计算区域宽高 + const w = Math.abs(p1.x - p2.x); + const h = Math.abs(p1.y - p2.y); + + // 3. 最小尺寸检查(防止创建过小的区域) + if (w * scale < 50 || h * scale < 60) return; + + // 4. 准备关联数据 + const points = new Array(); + const routes = new Array(); + + if (!id) { + id = s8(); // 生成唯一ID + const selected = this.store.active; // 获取当前选中的元素 + + // 5. 根据区域类型自动关联相关元素 + switch (type) { + case MapAreaType.库区: + // 库区只关联动作点 + selected?.filter(({ point }) => point?.type === MapPointType.动作点) + .forEach(({ id }) => points.push(id!)); + break; + case MapAreaType.互斥区: + // 互斥区关联所有点位和路线 + selected?.filter(({ point }) => point?.type) + .forEach(({ id }) => points.push(id!)); + selected?.filter(({ route }) => route?.type) + .forEach(({ id }) => routes.push(id!)); + break; + case MapAreaType.非互斥区: + // 非互斥区只关联点位 + selected?.filter(({ point }) => point?.type) + .forEach(({ id }) => points.push(id!)); + break; + } + } + + // 6. 创建区域图形对象 + const pen: MapPen = { + id, + name: 'area', // 图形类型标识 + tags: ['area', `area-${type}`], // 标签用于查找和分类 + label: `A${id}`, // 显示标签 + x: Math.min(p1.x, p2.x), // 左上角 X + y: Math.min(p1.y, p2.y), // 左上角 Y + width: w, // 宽度 + height: h, // 高度 + lineWidth: 1, // 边框宽度 + area: { type, points, routes }, // 区域业务数据 + locked: LockState.DisableMoveScale, // 锁定状态(禁止移动缩放) + }; + + // 7. 添加到画布并设置层级 + const area = await this.addPen(pen, true, true, true); + this.bottom(area); // 将区域放到最底层 +} +``` + +**关键理解点**: + +1. **事件流处理**:通过 RxJS 的事件流来处理鼠标拖拽 +2. **坐标计算**:将鼠标坐标转换为画布坐标系中的区域 +3. **图形对象创建**:创建符合 Meta2d 要求的图形对象 +4. **层级管理**:区域作为背景层,放在最底层 +5. **状态管理**:自动关联当前选中的相关元素 + +## 5. 自定义绘制系统 + +### 5.1 绘制函数注册 + +```typescript +#register() { + // 注册基础图形 + this.register({ line: () => new Path2D() }); + + // 注册自定义绘制函数 ⭐ + this.registerCanvasDraw({ + point: drawPoint, // 点位绘制 + line: drawLine, // 路线绘制 + area: drawArea, // 区域绘制 ⭐ + robot: drawRobot // 机器人绘制 + }); + + // 注册锚点 + this.registerAnchors({ point: anchorPoint }); + + // 注册线条绘制函数 + this.addDrawLineFn('bezier2', lineBezier2); + this.addDrawLineFn('bezier3', lineBezier3); +} +``` + +### 5.2 区域绘制函数详解 + +```typescript +function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { + // 1. 获取主题配置 + const theme = sTheme.editor; + + // 2. 获取绘制参数 + const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; + const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {}; + const { type } = pen.area ?? {}; + const { label = '' } = pen ?? {}; + + // 3. 开始绘制 + ctx.save(); + + // 4. 绘制矩形区域 + ctx.rect(x, y, w, h); + + // 5. 填充颜色(根据区域类型) + ctx.fillStyle = get(theme, `area.fill-${type}`) ?? ''; + ctx.fill(); + + // 6. 绘制边框(根据激活状态) + ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? ''; + ctx.stroke(); + + // 7. 绘制标签文字 + ctx.fillStyle = get(theme, 'color') ?? ''; + ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); + + ctx.restore(); +} +``` + +**关键理解点**: + +- Canvas 2D API 直接绘制矩形和文字 +- 主题系统提供颜色配置 +- 根据区域类型和激活状态使用不同的样式 +- 文字标签显示在区域上方 + +## 6. 响应式状态管理 + +### 6.1 数据流设计 + +```typescript +// 变化事件主题 +readonly #change$$ = new Subject(); + +// 区域列表响应式数据 +public readonly areas = useObservable( + this.#change$$.pipe( + filter((v) => v), // 只响应数据变化事件 + debounceTime(100), // 防抖处理 + map(() => this.find('area')), // 查找所有区域 + ), + { initialValue: new Array() }, +); + +// 当前选中元素 +public readonly current = useObservable( + this.#change$$.pipe( + debounceTime(100), + map(() => clone(this.store.active?.[0])), + ), +); +``` + +### 6.2 事件监听系统 + +```typescript +#listen(e: unknown, v: any) { + switch (e) { + case 'opened': + this.#load(sTheme.theme); + this.#change$$.next(true); // 触发数据更新 + break; + + case 'add': + this.#change$$.next(true); // 元素添加后更新 + break; + + case 'delete': + this.#onDelete(v); + this.#change$$.next(true); // 元素删除后更新 + break; + + case 'update': + case 'valueUpdate': + this.#change$$.next(true); // 元素更新后更新 + break; + + case 'active': + case 'inactive': + this.#change$$.next(false); // 选择状态变化 + break; + + case 'click': + case 'mousedown': + case 'mouseup': + // 将鼠标事件传递给事件流 + this.#mouse$$.next({ type: e, value: pick(v, 'x', 'y') }); + break; + } +} +``` + +## 7. 场景保存原理 + +### 7.1 保存入口函数 + +```typescript +public save(): string { + // 1. 构建标准场景对象 + const scene: StandardScene = { + robotGroups: this.robotGroups.value, + robots: this.robots, + // 2. 将画布上的图形对象转换为标准格式 + points: this.points.value.map((v) => this.#mapScenePoint(v)).filter((v) => !isNil(v)), + routes: this.routes.value.map((v) => this.#mapSceneRoute(v)).filter((v) => !isNil(v)), + areas: this.areas.value.map((v) => this.#mapSceneArea(v)).filter((v) => !isNil(v)), // ⭐ + blocks: [], + }; + + // 3. 序列化为 JSON 字符串 + return JSON.stringify(scene); +} +``` + +### 7.2 区域数据映射 + +```typescript +#mapSceneArea(pen: MapPen): StandardSceneArea | null { + if (!pen.id || isEmpty(pen.area)) return null; + + // 1. 提取基础信息 + const { id, label, desc, properties } = pen; + const { type, points, routes } = pen.area; + + // 2. 获取区域的实际位置和尺寸 + const { x, y, width, height } = this.getPenRect(pen); + + // 3. 构建标准区域对象 + const area: StandardSceneArea = { + id, + name: label || id, + desc, + x, // 左上角 X 坐标 + y, // 左上角 Y 坐标 + w: width, // 宽度 + h: height, // 高度 + type, // 区域类型 + config: {}, + properties, + }; + + // 4. 根据区域类型设置关联数据 + if (MapAreaType.库区 === type) { + // 库区只保存动作点 + area.points = points?.filter((v) => + this.getPenById(v)?.point?.type === MapPointType.动作点 + ); + } + + if ([MapAreaType.互斥区, MapAreaType.非互斥区].includes(type)) { + // 互斥区和非互斥区保存所有非禁行点 + area.points = points?.filter((v) => { + const { point } = this.getPenById(v) ?? {}; + if (isNil(point)) return false; + if (point.type === MapPointType.禁行点) return false; + return true; + }); + } + + if (MapAreaType.互斥区 === type) { + // 互斥区还要保存关联的路线 + area.routes = routes?.filter((v) => !isEmpty(this.getPenById(v)?.area)); + } + + return area; +} +``` + +## 8. 机器人管理系统 + +### 8.1 机器人数据结构 + +```typescript +// 机器人映射表(响应式) +readonly #robotMap = reactive>(new Map()); + +// 机器人组流 +readonly #robotGroups$$ = new BehaviorSubject([]); +public readonly robotGroups = useObservable( + this.#robotGroups$$.pipe(debounceTime(300)) +); +``` + +### 8.2 实时机器人更新 + +```typescript +public refreshRobot(id: RobotInfo['id'], info: Partial): void { + const pen = this.getPenById(id); + const { rotate: or, robot } = pen ?? {}; + if (!robot?.type) return; + + // 1. 获取当前位置 + const { x: ox, y: oy } = this.getPenRect(pen!); + + // 2. 解析实时数据 + const { x: cx = 37, y: cy = 37, active, angle, path: points } = info; + + // 3. 计算新位置(机器人中心点偏移) + const x = cx - 37; + const y = cy - 37; + const rotate = angle ?? or; + + // 4. 处理路径数据 + const path = points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? + robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); + + // 5. 更新机器人状态 + const o = { ...robot, ...omitBy({ active, path }, isNil) }; + + // 6. 根据激活状态使用不同的更新策略 + if (isNil(active)) { + // 只更新位置和路径 + this.setValue( + { id, x, y, rotate, robot: o, visible: true }, + { render: true, history: false, doEvent: false } + ); + } else { + // 同时更新图片资源 + this.setValue( + { + id, + ...this.#mapRobotImage(robot.type, active), + x, y, rotate, robot: o, visible: true + }, + { render: true, history: false, doEvent: false } + ); + } +} +``` + +## 9. 点位和路线管理 + +### 9.1 点位创建 + +```typescript +public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { + id ||= s8(); + + // 1. 创建点位图形对象 + const pen: MapPen = { + ...p, // 坐标 + ...this.#mapPoint(type), // 尺寸配置 + ...this.#mapPointImage(type), // 图片配置 + id, + name: 'point', + tags: ['point'], + label: `P${id}`, + point: { type }, + }; + + // 2. 调整坐标到中心点 + pen.x! -= pen.width! / 2; + pen.y! -= pen.height! / 2; + + // 3. 添加到画布 + await this.addPen(pen, false, true, true); +} +``` + +### 9.2 路线创建 + +```typescript +public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void { + const [p1, p2] = p; + if (!p1?.anchors?.length || !p2?.anchors?.length) return; + + // 1. 连接两个点位 + const line = this.connectLine(p1, p2, undefined, undefined, false); + + // 2. 设置ID + id ||= line.id!; + this.changePenId(line.id!, id); + + // 3. 设置路线属性 + const pen: MapPen = { tags: ['route'], route: { type }, lineWidth: 1 }; + this.setValue({ id, ...pen }, { render: false, history: false, doEvent: false }); + + // 4. 更新线条类型 + this.updateLineType(line, type); + + // 5. 选中并渲染 + this.active(id); + this.render(); +} +``` + +## 10. 主题系统集成 + +### 10.1 主题响应 + +```typescript +// 监听主题变化 +watch( + () => sTheme.theme, + (v) => this.#load(v), + { immediate: true }, +); + +#load(theme: string): void { + // 1. 设置 Meta2d 主题 + this.setTheme(theme); + + // 2. 更新编辑器配置 + this.setOptions({ color: get(sTheme.editor, 'color') }); + + // 3. 更新所有点位图片 + this.find('point').forEach((pen) => { + if (!pen.point?.type) return; + if (pen.point.type < 10) return; + this.canvas.updateValue(pen, this.#mapPointImage(pen.point.type)); + }); + + // 4. 更新所有机器人图片 + this.find('robot').forEach((pen) => { + if (!pen.robot?.type) return; + this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active)); + }); + + // 5. 重新渲染 + this.render(); +} +``` + +## 11. 性能优化策略 + +### 11.1 防抖处理 + +```typescript +// 所有响应式数据都使用防抖 +debounceTime(100); // 100ms 防抖 +debounceTime(300); // 300ms 防抖(机器人组) +``` + +### 11.2 浅层响应式 + +```typescript +// 使用 shallowRef 避免深度响应式 +const editor = shallowRef(); +``` + +### 11.3 并行处理 + +```typescript +// 场景加载时并行处理 +await Promise.all( + areas.map(async (v) => { + await this.addArea(/* ... */); + }), +); +``` + +## 12. 总结 + +### 12.1 画区域的完整流程 + +1. **工具选择**:用户点击工具栏的区域工具,设置 `mode` +2. **鼠标监听**:`mouseBrush` 流监听鼠标拖拽事件 +3. **坐标获取**:获取拖拽的起始点和结束点 +4. **区域创建**:调用 `addArea` 方法创建区域对象 +5. **画布绘制**:`drawArea` 函数在 Canvas 上绘制实际图形 +6. **状态更新**:触发响应式数据更新,通知 Vue 组件 + +### 12.2 场景文件生成区域的完整流程 + +1. **文件解析**:将 JSON 字符串解析为 `StandardScene` 对象 +2. **数据提取**:从 `areas` 数组中提取每个区域的信息 +3. **图形创建**:调用 `addArea` 方法在画布上创建图形对象 +4. **属性设置**:通过 `setValue` 设置业务属性 +5. **绘制渲染**:自定义绘制函数在 Canvas 上渲染图形 + +### 12.3 架构优势 + +1. **分层设计**:业务逻辑与图形引擎分离 +2. **响应式驱动**:状态变化自动更新 UI +3. **事件流处理**:RxJS 提供强大的异步事件处理 +4. **自定义绘制**:完全控制图形的渲染效果 +5. **类型安全**:TypeScript 提供完整的类型检查 + +这个编辑器服务是一个设计精良的复杂系统,通过合理的架构设计实现了强大的场景编辑功能。 From 4ac309e73216a4b81caf31a57db0d60960856548 Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 16 Jun 2025 18:09:43 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DPowerShell?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=B8=AD=E7=9A=84=E7=BC=96=E7=A0=81=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E7=A1=AE=E4=BF=9D=E4=B8=AD=E6=96=87=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E6=98=BE=E7=A4=BA=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 自动同步仓库.ps1 | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/自动同步仓库.ps1 b/自动同步仓库.ps1 index 4946215..03d8613 100644 --- a/自动同步仓库.ps1 +++ b/自动同步仓库.ps1 @@ -6,6 +6,13 @@ param( [switch]$DryRun = $false ) +# 设置控制台编码以正确显示中文 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +# 设置Git配置以正确处理中文 +$env:LC_ALL = "zh_CN.UTF-8" + function Write-ColorOutput { param( [string]$Message, @@ -47,10 +54,10 @@ function Sync-Repositories { } # 检查是否有新的提交 - $upstreamCommits = git log HEAD..upstream/master --oneline + $upstreamCommits = & git log HEAD..upstream/master --oneline | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::Default.GetBytes($_)) } if ($upstreamCommits) { Write-ColorOutput "发现镜像仓库有新的提交:" "Yellow" - Write-ColorOutput $upstreamCommits "Gray" + $upstreamCommits | ForEach-Object { Write-ColorOutput $_ "Gray" } if ($UseRebase) { Write-ColorOutput "正在使用rebase合并代码..." "Green" @@ -90,10 +97,10 @@ function Sync-Repositories { Write-ColorOutput "镜像仓库没有新的提交,检查本地是否有未推送的修改..." "Blue" # 检查是否有本地修改需要推送 - $localCommits = git log origin/master..HEAD --oneline + $localCommits = & git log origin/master..HEAD --oneline | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::Default.GetBytes($_)) } if ($localCommits) { Write-ColorOutput "发现本地有未推送的修改:" "Yellow" - Write-ColorOutput $localCommits "Gray" + $localCommits | ForEach-Object { Write-ColorOutput $_ "Gray" } Write-ColorOutput "" Write-ColorOutput "下一步操作提示:" "Cyan" From d06bd10bf1fca7e8b6154b48fa5b713a362ca877 Mon Sep 17 00:00:00 2001 From: xudan Date: Tue, 17 Jun 2025 09:57:59 +0800 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E4=BB=BF=E7=9C=9F=E5=92=8C=E5=9C=BA=E6=99=AF=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=A1=B5=E9=9D=A2=E6=A0=87=E9=A2=98=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=9B=B8=E5=BA=94=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/movement-supervision.vue | 2 +- src/pages/scene-editor.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/movement-supervision.vue b/src/pages/movement-supervision.vue index 654e72d..1555d36 100644 --- a/src/pages/movement-supervision.vue +++ b/src/pages/movement-supervision.vue @@ -86,7 +86,7 @@ const selectRobot = (id: string) => { - {{ title }} + {{ title }}--场景仿真 diff --git a/src/pages/scene-editor.vue b/src/pages/scene-editor.vue index edf01bc..be8d67d 100644 --- a/src/pages/scene-editor.vue +++ b/src/pages/scene-editor.vue @@ -105,7 +105,7 @@ const selectRobot = (id: string) => { - {{ title }} + {{ title }} --场景编辑 From e4047e62a0daa315ffeed7d4e28d23001020c025 Mon Sep 17 00:00:00 2001 From: xudan Date: Tue, 17 Jun 2025 15:34:56 +0800 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E7=82=B9=E4=BD=8D=E3=80=81=E8=B7=AF=E7=BA=BF=E5=92=8C?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E7=B1=BB=E5=9E=8B=E6=9E=9A=E4=B8=BE=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AF=A6=E7=BB=86=E6=B3=A8=E9=87=8A=E5=92=8C?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/map/constant.ts | 85 +- src/services/editor.service.ts | 113 ++- 场景编辑器组件详细分析.md | 1579 +++++++++++++++++++++++++++++++- 3 files changed, 1736 insertions(+), 41 deletions(-) diff --git a/src/apis/map/constant.ts b/src/apis/map/constant.ts index 51940bf..0b143f3 100644 --- a/src/apis/map/constant.ts +++ b/src/apis/map/constant.ts @@ -1,77 +1,160 @@ import { KeydownType, type Options } from '@meta2d/core'; import { invert } from 'lodash-es'; +/** + * 地图点位类型枚举 + * 定义了场景编辑器中所有支持的点位类型 + * 数值1-9为小点位,10以上为大点位(有特殊图标) + */ //#region 点位 export enum MapPointType { + /** 普通点 - 基础导航点,机器人可通过 */ 普通点 = 1, + /** 等待点 - 机器人等待或暂停的位置 */ 等待点, + /** 避让点 - 机器人主动避让其他机器人的点位 */ 避让点, + /** 临时避让点 - 动态生成的临时避让位置,有特殊标记 */ 临时避让点, + /** 电梯点 - 机器人乘坐电梯的专用点位 */ 电梯点 = 11, + /** 自动门点 - 需要自动门控制的通行点位 */ 自动门点, + /** 充电点 - 机器人充电的专用位置,可绑定特定机器人 */ 充电点, + /** 停靠点 - 机器人停靠等待的位置,可绑定特定机器人 */ 停靠点, + /** 动作点 - 机器人执行特定动作的位置(如取货、放货) */ 动作点, + /** 禁行点 - 禁止机器人通过的点位 */ 禁行点, } + +/** + * 点位类型映射数组,用于UI显示和选择 + * 过滤掉非数字类型的枚举项 + */ export const MAP_POINT_TYPES = Object.freeze( <[string, MapPointType][]>Object.entries(MapPointType).filter(([, v]) => typeof v === 'number'), ); //#endregion +/** + * 地图路线类型枚举 + * 定义了连接点位之间的路径类型 + */ //#region 线路 export enum MapRouteType { + /** 直线 - 两点间直线连接 */ 直线 = 'line', + /** 二阶贝塞尔曲线 - 带一个控制点的曲线 */ 二阶贝塞尔曲线 = 'bezier2', + /** 三阶贝塞尔曲线 - 带两个控制点的曲线,更灵活 */ 三阶贝塞尔曲线 = 'bezier3', } + +/** + * 路线类型反向映射,用于从字符串值获取枚举键 + */ export const MAP_ROUTE_TYPE = invert(MapRouteType); + +/** + * 路线类型映射数组,用于UI显示和选择 + */ export const MAP_ROUTE_TYPES = Object.freeze(<[string, MapRouteType][]>Object.entries(MapRouteType)); +/** + * 路线通行类型枚举 + * 定义了路线的通行权限和限制 + */ export enum MapRoutePassType { + /** 无限制 - 所有机器人都可以通行 */ 无, + /** 仅空载可通行 - 只有空载的机器人可以通过 */ 仅空载可通行, + /** 仅载货可通行 - 只有载货的机器人可以通过 */ 仅载货可通行, + /** 禁行 - 禁止所有机器人通行,显示为虚线 */ 禁行 = 10, } + +/** + * 路线通行类型映射数组,用于UI显示和选择 + */ export const MAP_ROUTE_PASS_TYPES = Object.freeze( <[string, MapRoutePassType][]>Object.entries(MapRoutePassType).filter(([, v]) => typeof v === 'number'), ); //#endregion +/** + * 地图区域类型枚举 + * 定义了场景中不同功能的区域类型 + */ //#region 区域 export enum MapAreaType { + /** 库区 - 仓储作业区域,包含动作点 */ 库区 = 1, + /** 互斥区 - 同时只能有一个机器人进入的区域 */ 互斥区 = 11, + /** 非互斥区 - 可以同时有多个机器人进入的区域 */ 非互斥区, } + +/** + * 区域类型映射数组,用于UI显示和选择 + */ export const MAP_AREA_TYPES = Object.freeze( <[string, MapAreaType][]>Object.entries(MapAreaType).filter(([, v]) => typeof v === 'number'), ); //#endregion +/** + * 场景编辑器核心配置 + * 基于Meta2D引擎的编辑器配置参数 + */ export const EDITOR_CONFIG: Options = { + /** 键盘事件类型 - 禁用所有键盘快捷键 */ keydown: KeydownType.None, + /** 严格作用域 - 限制编辑操作范围 */ strictScope: true, + /** 移动连接线 - 禁用拖动时自动移动连接的线条 */ moveConnectedLine: false, + /** 禁用输入 - 禁用文本输入功能 */ disableInput: true, + /** 禁用旋转 - 禁用图形旋转功能 */ disableRotate: true, + /** 禁用尺寸调整 - 禁用图形大小调整 */ disableSize: true, + /** 禁用锚点 - 禁用连接锚点显示 */ disableAnchor: true, + /** 禁用空线条 - 不允许创建没有连接点的线条 */ disableEmptyLine: true, + /** 禁用重复线条 - 不允许在同一对点之间创建多条线 */ disableRepeatLine: true, + /** 最小缩放比例 - 画布最小缩放到24% */ minScale: 0.24, + /** 最大缩放比例 - 画布最大缩放到401% */ maxScale: 4.01, - scaleOff: 0.01, + /** 缩放步长 - 每次滚轮滚动的缩放幅度(5%) */ + scaleOff: 0.05, + /** 默认锚点 - 不设置默认锚点 */ defaultAnchors: [], + /** 全局透明度 - 普通状态下图形透明度(0为不透明) */ globalAlpha: 0, + /** 激活状态全局透明度 - 选中状态下图形透明度 */ activeGlobalAlpha: 0, + /** 默认字体大小 - 14像素 */ fontSize: 14, + /** 行高倍数 - 1.5倍行高 */ lineHeight: 1.5, + /** 字体族 - 使用系统默认字体 */ fontFamily: 'system-ui', + /** 文本旋转 - 禁用文本跟随图形旋转 */ textRotate: false, + /** 文本水平对齐 - 居中对齐 */ textAlign: 'center', + /** 文本垂直基线 - 顶部对齐 */ textBaseline: 'top', }; diff --git a/src/services/editor.service.ts b/src/services/editor.service.ts index 03e20fa..7d7ca3f 100644 --- a/src/services/editor.service.ts +++ b/src/services/editor.service.ts @@ -27,8 +27,25 @@ import { clone, get, isEmpty, isNil, isString, nth, omitBy, pick, remove, some } import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs'; import { reactive, watch } from 'vue'; +/** + * 场景编辑器服务类 + * 继承自Meta2D,提供完整的场景编辑功能 + * + * 主要功能: + * - 场景文件的加载、保存和管理 + * - 点位、路线、区域的创建和编辑 + * - 机器人组的管理和实时状态更新 + * - 鼠标事件的处理和响应式数据流 + * - 自定义绘制和渲染逻辑 + */ export class EditorService extends Meta2d { - //#region 场景文件 + //#region 场景文件管理 + /** + * 加载场景文件到编辑器 + * @param map 场景文件的JSON字符串,为空则创建新场景 + * @param editable 是否可编辑状态,控制编辑器锁定状态 + * @param detail 群组场景详情,包含机器人组和机器人信息 + */ public async load(map?: string, editable = false, detail?: Partial): Promise { const scene: StandardScene = map ? JSON.parse(map) : {}; if (!isEmpty(detail?.group)) { @@ -45,6 +62,10 @@ export class EditorService extends Meta2d { this.store.historyIndex = undefined; this.store.histories = []; } + /** + * 保存当前场景为JSON字符串 + * @returns 包含完整场景数据的JSON字符串 + */ public save(): string { const scene: StandardScene = { robotGroups: this.robotGroups.value, @@ -57,11 +78,20 @@ export class EditorService extends Meta2d { return JSON.stringify(scene); } + /** + * 加载机器人数据到编辑器 + * @param groups 机器人组列表 + * @param robots 机器人信息列表 + */ #loadRobots(groups?: RobotGroup[], robots?: RobotInfo[]): void { this.#robotMap.clear(); robots?.forEach((v) => this.#robotMap.set(v.id, v)); this.#robotGroups$$.next(groups ?? []); } + /** + * 从场景数据加载点位到画布 + * @param points 标准场景点位数据数组 + */ async #loadScenePoints(points?: StandardScenePoint[]): Promise { if (!points?.length) return; await Promise.all( @@ -75,6 +105,10 @@ export class EditorService extends Meta2d { }), ); } + /** + * 从场景数据加载路线到画布 + * @param routes 标准场景路线数据数组 + */ #loadSceneRoutes(routes?: StandardSceneRoute[]): void { if (!routes?.length) return; routes.map((v) => { @@ -206,6 +240,10 @@ export class EditorService extends Meta2d { } //#endregion + /** + * 设置编辑器状态 + * @param editable 是否可编辑,true为可编辑状态,false为只读状态 + */ public setState(editable?: boolean): void { this.lock(editable ? LockState.None : LockState.DisableEdit); } @@ -214,7 +252,10 @@ export class EditorService extends Meta2d { return super.data(); } + /** 鼠标事件流主体,用于内部事件分发 */ readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>(); + + /** 鼠标点击事件的响应式流,防抖处理后的点击坐标 */ public readonly mouseClick = useObservable( this.#mouse$$.pipe( filter(({ type }) => type === 'click'), @@ -222,6 +263,8 @@ export class EditorService extends Meta2d { map(({ value }) => value), ), ); + + /** 鼠标拖拽事件的响应式流,返回起始点和结束点坐标,用于创建区域 */ public readonly mouseBrush = useObservable<[Point, Point]>( this.#mouse$$.pipe( filter(({ type }) => type === 'mousedown'), @@ -234,8 +277,11 @@ export class EditorService extends Meta2d { ), ); - //#region 机器人组 + //#region 机器人组管理 + /** 机器人信息映射表,响应式存储所有机器人数据 */ readonly #robotMap = reactive>(new Map()); + + /** 获取所有机器人信息数组 */ public get robots(): RobotInfo[] { return Array.from(this.#robotMap.values()); } @@ -319,13 +365,18 @@ export class EditorService extends Meta2d { } //#endregion + /** 画布变化事件流,用于触发响应式数据更新 */ readonly #change$$ = new Subject(); + + /** 当前选中的图形对象,响应式更新 */ public readonly current = useObservable( this.#change$$.pipe( debounceTime(100), map(() => clone(this.store.active?.[0])), ), ); + + /** 当前选中的图形ID列表,响应式更新 */ public readonly selected = useObservable( this.#change$$.pipe( filter((v) => !v), @@ -334,6 +385,8 @@ export class EditorService extends Meta2d { ), { initialValue: new Array() }, ); + + /** 画布上所有图形对象列表,响应式更新 */ public readonly pens = useObservable( this.#change$$.pipe( filter((v) => v), @@ -432,6 +485,7 @@ export class EditorService extends Meta2d { //#endregion //#region 点位 + /** 画布上所有点位对象列表,响应式更新 */ public readonly points = useObservable( this.#change$$.pipe( filter((v) => v), @@ -447,6 +501,12 @@ export class EditorService extends Meta2d { return { x: x + width / 2, y: y + height / 2, width, height }; } + /** + * 在指定位置添加点位 + * @param p 点位坐标 + * @param type 点位类型,默认为普通点 + * @param id 点位ID,未指定则自动生成 + */ public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { id ||= s8(); const pen: MapPen = { @@ -503,6 +563,7 @@ export class EditorService extends Meta2d { //#endregion //#region 线路 + /** 画布上所有路线对象列表,响应式更新,包含动态生成的标签 */ public readonly routes = useObservable( this.#change$$.pipe( filter((v) => v), @@ -525,6 +586,12 @@ export class EditorService extends Meta2d { return `${p1.label}${(d ?? direction) > 0 ? '→' : '←'}${p2.label}`; } + /** + * 在两个点位之间添加路线 + * @param p 两个点位的数组 + * @param type 路线类型,默认为直线 + * @param id 路线ID,未指定则自动生成 + */ public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void { const [p1, p2] = p; if (!p1?.anchors?.length || !p2?.anchors?.length) return; @@ -553,6 +620,7 @@ export class EditorService extends Meta2d { //#endregion //#region 区域 + /** 画布上所有区域对象列表,响应式更新 */ public readonly areas = useObservable( this.#change$$.pipe( filter((v) => v), @@ -571,6 +639,13 @@ export class EditorService extends Meta2d { }); } + /** + * 在指定区域添加功能区域 + * @param p1 区域起始坐标 + * @param p2 区域结束坐标 + * @param type 区域类型,默认为库区 + * @param id 区域ID,未指定则自动生成 + */ public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { const scale = this.data().scale ?? 1; const w = Math.abs(p1.x - p2.x); @@ -621,13 +696,21 @@ export class EditorService extends Meta2d { } //#endregion + /** + * 构造函数 - 初始化场景编辑器 + * @param container 编辑器容器DOM元素 + */ constructor(container: HTMLDivElement) { super(container, EDITOR_CONFIG); + // 禁用第6个子元素的拖放功能 (container.children.item(5)).ondrop = null; + // 监听所有画布事件 this.on('*', (e, v) => this.#listen(e, v)); + // 注册自定义绘制函数和锚点 this.#register(); + // 监听主题变化并重新加载样式 watch( () => sTheme.theme, (v) => this.#load(v), @@ -711,7 +794,12 @@ export class EditorService extends Meta2d { } } -//#region 绘制函数 +//#region 自定义绘制函数 +/** + * 绘制点位的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 点位图形对象 + */ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; @@ -777,6 +865,10 @@ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); ctx.restore(); } +/** + * 设置点位的连接锚点 + * @param pen 点位图形对象 + */ function anchorPoint(pen: MapPen): void { pen.anchors = [ { penId: pen.id, id: '0', x: 0.5, y: 0.5 }, @@ -787,6 +879,11 @@ function anchorPoint(pen: MapPen): void { ]; } +/** + * 绘制路线的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 路线图形对象 + */ function drawLine(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, lineWidth: s = 1 } = pen.calculative ?? {}; @@ -874,6 +971,11 @@ function lineBezier3(_: Meta2dStore, pen: MapPen): void { pen.calculative.worldAnchors[1].prev = { x: x2 + dx2 * s, y: y2 + dy2 * s }; } +/** + * 绘制区域的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 区域图形对象 + */ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; @@ -895,6 +997,11 @@ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { ctx.restore(); } +/** + * 绘制机器人的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 机器人图形对象 + */ function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { lineWidth: s = 1 } = pen.calculative ?? {}; diff --git a/场景编辑器组件详细分析.md b/场景编辑器组件详细分析.md index c9f608e..8bfc833 100644 --- a/场景编辑器组件详细分析.md +++ b/场景编辑器组件详细分析.md @@ -4,36 +4,603 @@ `scene-editor.vue` 是一个基于 Vue 3 的复杂场景编辑器组件,主要用于管理和编辑工业机器人的场景配置。该组件提供了完整的场景编辑功能,包括机器人管理、路径规划、区域设置等。 -## 2. 核心功能分析 +## 2. 架构图示分析 -### 2.1 场景数据管理 +### 2.1 组件整体架构图 + +```mermaid +graph TB + subgraph "Scene Editor Component" + A[scene-editor.vue] --> B[EditorService] + A --> C[RobotGroups] + A --> D[PenGroups] + A --> E[EditorToolbar] + A --> F[Detail Cards] + + B --> B1[Meta2d Engine] + B --> B2[Canvas Rendering] + B --> B3[Event System] + + C --> C1[Robot Management] + C --> C2[Group Operations] + + D --> D1[Points Management] + D --> D2[Routes Management] + D --> D3[Areas Management] + + E --> E1[Drawing Tools] + E --> E2[Operations] + + F --> F1[Robot Detail] + F --> F2[Point Detail] + F --> F3[Route Detail] + F --> F4[Area Detail] + end + + subgraph "External Dependencies" + G[Scene API] + H[Robot API] + I[Map API] + J[File System] + end + + A --> G + A --> H + B --> I + A --> J +``` + +### 2.2 数据流架构图 + +```mermaid +sequenceDiagram + participant U as User + participant SE as SceneEditor + participant ES as EditorService + participant API as SceneAPI + participant Canvas as Meta2d Canvas + + U->>SE: Load Scene + SE->>API: getSceneById(id) + API-->>SE: Scene Data + SE->>ES: load(sceneData) + ES->>Canvas: Render Elements + + U->>SE: Edit Element + SE->>ES: updatePen/addArea/addPoint + ES->>Canvas: Update Render + ES->>SE: Emit Change Event + SE->>SE: Update UI State + + U->>SE: Save Scene + SE->>ES: save() + ES-->>SE: JSON String + SE->>API: saveSceneById(id, json) + API-->>SE: Success Response +``` + +### 2.3 EditorService 内部架构图 + +```mermaid +graph LR + subgraph "EditorService Core" + A[Meta2d Base] --> B[Event System] + A --> C[Canvas Layer] + A --> D[State Management] + + B --> B1[Mouse Events] + B --> B2[Change Events] + B --> B3[RxJS Streams] + + C --> C1[Point Rendering] + C --> C2[Route Rendering] + C --> C3[Area Rendering] + C --> C4[Robot Rendering] + + D --> D1[Current Selection] + D --> D2[Robot Groups] + D --> D3[Elements Cache] + end + + subgraph "Rendering Pipeline" + E[drawPoint] --> F[Canvas Context] + G[drawLine] --> F + H[drawArea] --> F + I[drawRobot] --> F + end + + C1 --> E + C2 --> G + C3 --> H + C4 --> I +``` + +## 3. 核心功能分析 + +### 3.1 场景数据管理 - **场景读取**: 通过 `getSceneById` API 获取场景数据 - **场景推送**: 通过 `pushSceneById` API 将场景数据推送到数据库 - **场景保存**: 通过编辑器服务保存场景配置 - **文件导入/导出**: 支持 `.scene` 格式文件的导入导出 -### 2.2 编辑器状态控制 +### 3.2 编辑器状态控制 - **编辑模式切换**: 通过 `editable` 状态控制编辑器的启用/禁用 - **权限管理**: 根据编辑状态显示不同的操作按钮和功能 - **实时状态同步**: 编辑状态变化时自动更新编辑器服务状态 -### 2.3 三大管理区域 +### 3.3 三大管理区域 - **机器人管理**: 显示和管理场景中的机器人组和单个机器人 - **库区管理**: 管理各种类型的库区(仅显示库区类型的区域) - **高级组管理**: 管理复杂的路径、点位、区域等元素 -### 2.4 详情卡片系统 +### 3.4 详情卡片系统 - **动态卡片显示**: 根据选中元素类型显示对应的详情卡片 - **编辑/查看模式**: 根据编辑状态显示编辑卡片或查看卡片 - **悬浮定位**: 卡片固定在右侧悬浮显示 -## 3. 技术架构分析 +## 4. 大场景渲染性能优化分析 -### 3.1 核心依赖关系 +### 4.1 性能瓶颈识别 + +#### 4.1.1 主要性能问题 + +```mermaid +graph TD + A[大场景性能问题] --> B[元素数量过多] + A --> C[频繁重绘] + A --> D[内存泄漏] + A --> E[事件处理] + + B --> B1[点位: 1000+ 个] + B --> B2[路线: 5000+ 条] + B --> B3[区域: 100+ 个] + B --> B4[机器人: 50+ 个] + + C --> C1[每次状态变更全量重绘] + C --> C2[鼠标移动频繁触发] + C --> C3[RxJS 防抖不足] + + D --> D1[大量 DOM 监听器] + D --> D2[Canvas 上下文未释放] + D --> D3[图片资源未缓存] + + E --> E1[hit-test 计算复杂] + E --> E2[事件冒泡处理] +``` + +#### 4.1.2 当前代码中的性能问题点 + +```typescript +// 问题1: 频繁的全量数据更新 +public readonly pens = useObservable( + this.#change$$.pipe( + filter((v) => v), + debounceTime(100), // 防抖时间过短 + map(() => this.data().pens), // 每次返回全量数据 + ), +); + +// 问题2: 复杂的过滤操作 +const robots = computed(() => + editor.value.robots.filter(({ label }) => + label.includes(keyword.value) // 每次重新过滤全部数据 + ) +); + +// 问题3: 同步的大量元素创建 +async #loadScenePoints(points?: StandardScenePoint[]): Promise { + if (!points?.length) return; + await Promise.all( // 并发创建所有点位,可能导致界面卡顿 + points.map(async (v) => { + // ... 创建逻辑 + }), + ); +} +``` + +### 4.2 性能优化策略 + +#### 4.2.1 虚拟化渲染优化 + +```typescript +// 优化建议1: 实现视口裁剪 +class ViewportCulling { + private viewport: Rect; + private visibleElements: Map = new Map(); + + updateViewport(viewport: Rect): void { + this.viewport = viewport; + this.updateVisibleElements(); + } + + private updateVisibleElements(): void { + const elements = this.getAllElements(); + this.visibleElements.clear(); + + elements.forEach((element) => { + if (this.isInViewport(element)) { + this.visibleElements.set(element.id, element); + } + }); + } + + private isInViewport(element: MapPen): boolean { + const elementRect = this.getElementRect(element); + return this.rectIntersects(this.viewport, elementRect); + } +} + +// 优化建议2: 层级渲染 +class LayeredRenderer { + private staticLayer: CanvasRenderingContext2D; // 静态elementos(点位、区域) + private dynamicLayer: CanvasRenderingContext2D; // 动态elementos(机器人、路径) + private uiLayer: CanvasRenderingContext2D; // UI层(选中状态、工具提示) + + render(): void { + this.renderStaticLayer(); // 仅在元素变更时重绘 + this.renderDynamicLayer(); // 频繁重绘 + this.renderUILayer(); // 交互时重绘 + } +} +``` + +#### 4.2.2 数据结构优化 + +```typescript +// 优化建议3: 使用空间索引 +class SpatialIndex { + private quadTree: QuadTree; + + insert(element: MapPen): void { + const bounds = this.getElementBounds(element); + this.quadTree.insert(element, bounds); + } + + query(viewport: Rect): MapPen[] { + return this.quadTree.query(viewport); + } +} + +// 优化建议4: 缓存计算结果 +class RenderCache { + private pathCache: Map = new Map(); + private imageCache: Map = new Map(); + + getPath(element: MapPen): Path2D { + const key = this.getPathKey(element); + if (!this.pathCache.has(key)) { + this.pathCache.set(key, this.createPath(element)); + } + return this.pathCache.get(key)!; + } +} +``` + +#### 4.2.3 事件处理优化 + +```typescript +// 优化建议5: 事件委托和节流 +class EventOptimizer { + private mouseThrottle = throttle(this.handleMouseMove.bind(this), 16); // 60fps + + setupEventListeners(): void { + // 使用事件委托,减少监听器数量 + this.canvas.addEventListener('mousemove', this.mouseThrottle); + this.canvas.addEventListener('click', this.handleClick); + } + + private handleMouseMove(event: MouseEvent): void { + // 只处理必要的鼠标移动事件 + const elements = this.spatialIndex.query(this.getMouseViewport(event)); + this.updateHoverState(elements); + } +} +``` + +#### 4.2.4 内存管理优化 + +```typescript +// 优化建议6: 对象池模式 +class ObjectPool { + private pool: T[] = []; + + acquire(): T { + return this.pool.pop() || this.create(); + } + + release(obj: T): void { + this.reset(obj); + this.pool.push(obj); + } +} + +// 优化建议7: 及时清理资源 +class ResourceManager { + private observers: Set<() => void> = new Set(); + + cleanup(): void { + this.observers.forEach((cleanup) => cleanup()); + this.observers.clear(); + + // 清理Canvas上下文 + this.clearCanvasContexts(); + + // 清理图片缓存 + this.clearImageCache(); + } +} +``` + +### 4.3 具体优化实施建议 + +#### 4.3.1 分批加载策略 + +```typescript +// 建议实现: 分批加载大量元素 +async loadSceneInBatches(scene: StandardScene): Promise { + const BATCH_SIZE = 100; + + // 分批加载点位 + if (scene.points?.length) { + for (let i = 0; i < scene.points.length; i += BATCH_SIZE) { + const batch = scene.points.slice(i, i + BATCH_SIZE); + await this.loadPointsBatch(batch); + await this.nextTick(); // 让出主线程 + } + } + + // 分批加载路线 + if (scene.routes?.length) { + for (let i = 0; i < scene.routes.length; i += BATCH_SIZE) { + const batch = scene.routes.slice(i, i + BATCH_SIZE); + await this.loadRoutesBatch(batch); + await this.nextTick(); + } + } +} + +private nextTick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} +``` + +#### 4.3.2 LOD(Level of Detail)优化 + +```typescript +// 建议实现: 根据缩放级别调整渲染详度 +class LODRenderer { + private renderLevel = 0; + + updateRenderLevel(scale: number): void { + if (scale > 2) + this.renderLevel = 3; // 高详度 + else if (scale > 1) + this.renderLevel = 2; // 中详度 + else if (scale > 0.5) + this.renderLevel = 1; // 低详度 + else this.renderLevel = 0; // 最低详度 + } + + renderPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { + switch (this.renderLevel) { + case 0: + this.renderPointSimple(ctx, pen); + break; + case 1: + this.renderPointNormal(ctx, pen); + break; + case 2: + this.renderPointDetailed(ctx, pen); + break; + case 3: + this.renderPointHighDetail(ctx, pen); + break; + } + } +} +``` + +## 5. 异步区域绘制深层原因分析 + +### 5.1 为什么使用 async/await + +#### 5.1.1 代码分析 + +```typescript +// 关键代码段分析 +public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { + // ... 前置逻辑 + const area = await this.addPen(pen, true, true, true); + // ↑ 这里是关键 - addPen 是异步的 + this.bottom(area); +} + +public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { + // ... 创建pen对象 + await this.addPen(pen, false, true, true); + // ↑ 同样是异步调用 +} +``` + +#### 5.1.2 深层原因分析图 + +```mermaid +graph TD + A[addArea/addPoint 调用] --> B[创建 MapPen 对象] + B --> C[调用 addPen 方法] + C --> D{addPen 为什么异步?} + + D --> E[Canvas 渲染队列] + D --> F[DOM 更新时机] + D --> G[图片资源加载] + D --> H[动画系统集成] + + E --> E1[Canvas需要等待渲染完成] + E --> E2[避免渲染冲突] + E --> E3[批量更新优化] + + F --> F1[等待浏览器重绘] + F --> F2[确保DOM状态同步] + + G --> G1[点位图标加载] + G --> G2[机器人图片加载] + G --> G3[主题相关资源] + + H --> H1[过渡动画] + H --> H2[缩放动画] + H --> H3[状态切换动画] +``` + +### 5.2 Meta2d 底层机制分析 + +#### 5.2.1 addPen 异步的根本原因 + +```typescript +// Meta2d 内部可能的实现机制 (推测) +class Meta2d { + async addPen(pen: Pen, history?: boolean, render?: boolean, doEvent?: boolean): Promise { + // 1. 资源预加载 - 确保图片等资源准备就绪 + if (pen.image) { + await this.loadImage(pen.image); + } + + // 2. 渲染管道同步 - 等待当前渲染任务完成 + await this.renderQueue.nextTick(); + + // 3. 添加到画布数据结构 + this.store.data.pens.push(pen); + + // 4. 计算布局和碰撞检测 + await this.calculateLayout(pen); + + // 5. 触发重绘 + if (render) { + await this.render(); + } + + // 6. 触发事件 + if (doEvent) { + this.emit('add', pen); + } + + return pen; + } + + private async loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + } + + private async calculateLayout(pen: Pen): Promise { + // 复杂的布局计算可能需要多帧完成 + return new Promise((resolve) => { + requestAnimationFrame(() => { + this.updatePenBounds(pen); + this.updateSpatialIndex(pen); + resolve(); + }); + }); + } +} +``` + +#### 5.2.2 异步的必要性分析 + +```mermaid +sequenceDiagram + participant User as User Action + participant Service as EditorService + participant Meta2d as Meta2d Engine + participant Canvas as Canvas Context + participant Browser as Browser + + User->>Service: addArea() + Service->>Meta2d: addPen(pen) + + Note over Meta2d: 检查是否需要加载图片资源 + Meta2d->>Browser: 加载图片 (async) + Browser-->>Meta2d: 图片加载完成 + + Note over Meta2d: 等待渲染队列空闲 + Meta2d->>Meta2d: 添加到数据结构 + + Note over Meta2d: 计算元素边界和布局 + Meta2d->>Canvas: 请求重绘 + Canvas->>Browser: requestAnimationFrame + Browser-->>Canvas: 下一帧回调 + + Meta2d-->>Service: 返回创建的元素 + Service->>Service: 调用 bottom() 设置层级 +``` + +### 5.3 性能影响分析 + +#### 5.3.1 异步的性能优势 + +```typescript +// 优势1: 避免阻塞主线程 +// 同步版本 (假设的问题版本) +public addAreaSync(p1: Point, p2: Point): void { + const pen = this.createPen(); + this.addPenSync(pen); // 会阻塞主线程 + this.render(); // 立即渲染,可能导致卡顿 +} + +// 异步版本 (当前实现) +public async addArea(p1: Point, p2: Point): Promise { + const pen = this.createPen(); + await this.addPen(pen); // 非阻塞,允许其他任务执行 + this.bottom(pen); // 确保pen已经正确添加后再操作 +} +``` + +#### 5.3.2 批量操作的性能考虑 + +```typescript +// 当前的批量加载实现 +async #loadSceneAreas(areas?: StandardSceneArea[]): Promise { + if (!areas?.length) return; + await Promise.all( // 并发执行,但可能导致资源竞争 + areas.map(async (v) => { + await this.addArea({ x: v.x, y: v.y }, { x: v.x + v.w, y: v.y + v.h }, v.type, v.id); + }), + ); +} + +// 优化建议: 控制并发数量 +async #loadSceneAreasOptimized(areas?: StandardSceneArea[]): Promise { + if (!areas?.length) return; + + const CONCURRENT_LIMIT = 5; // 限制并发数量 + for (let i = 0; i < areas.length; i += CONCURRENT_LIMIT) { + const batch = areas.slice(i, i + CONCURRENT_LIMIT); + await Promise.all( + batch.map(async (v) => { + await this.addArea( + { x: v.x, y: v.y }, + { x: v.x + v.w, y: v.y + v.h }, + v.type, + v.id + ); + }) + ); + // 每批次之间给主线程喘息时间 + await new Promise(resolve => setTimeout(resolve, 10)); + } +} +``` + +## 6. 技术架构分析 + +### 6.1 核心依赖关系 ```typescript // 主要导入依赖 @@ -42,9 +609,9 @@ import { EditorService } from '@core/editor.service'; // 编辑器服务 import { decodeTextFile, downloadFile, selectFile, textToBlob } from '@core/utils'; // 工具函数 ``` -### 3.2 组件架构设计 +### 6.2 组件架构设计 -#### 3.2.1 状态管理 +#### 6.2.1 状态管理 ```typescript // 核心状态定义 @@ -56,7 +623,7 @@ const container = shallowRef(); // 编辑器容器 const editor = shallowRef(); // 编辑器服务实例 ``` -#### 3.2.2 依赖注入系统 +#### 6.2.2 依赖注入系统 ```typescript const EDITOR_KEY = Symbol('editor-key'); @@ -65,9 +632,9 @@ provide(EDITOR_KEY, editor); 使用 Vue 3 的依赖注入机制,将编辑器服务注入到子组件中。 -### 3.3 EditorService 核心服务分析 +### 6.3 EditorService 核心服务分析 -#### 3.3.1 服务基础 +#### 6.3.1 服务基础 ```typescript export class EditorService extends Meta2d { @@ -76,7 +643,7 @@ export class EditorService extends Meta2d { } ``` -#### 3.3.2 核心方法 +#### 6.3.2 核心方法 - **load()**: 加载场景数据到编辑器 - **save()**: 保存当前场景数据 @@ -85,9 +652,9 @@ export class EditorService extends Meta2d { - **addArea()**: 添加区域 - **deleteById()**: 删除指定元素 -### 3.4 API 接口设计 +### 6.4 API 接口设计 -#### 3.4.1 场景相关API +#### 6.4.1 场景相关API ```typescript // 获取场景数据 @@ -100,7 +667,7 @@ export async function pushSceneById(id: string): Promise; export async function saveSceneById(id: string, json: string, png?: string): Promise; ``` -#### 3.4.2 文件操作工具 +#### 6.4.2 文件操作工具 ```typescript // 文件选择 @@ -116,9 +683,9 @@ export function textToBlob(text: string): Blob | undefined; export function downloadFile(url: string, name?: string): void; ``` -## 4. 从零开发实现过程 +## 7. 从零开发实现过程 -### 4.1 第一步:创建基础组件结构 +### 7.1 第一步:创建基础组件结构 ```vue +``` + +### 5.3 方案三: EditorService增强 + +```typescript +// 为EditorService添加状态缓存和同步机制 +export class EditorService extends Meta2d { + // 添加状态缓存 + private robotStateCache = new Map(); + + /** + * 改进的坐标转换方法 + */ + private normalizeCoordinates(info: Partial): { x: number; y: number } | null { + const { x: cx, y: cy } = info; + + // 严格的坐标验证 + if (typeof cx !== 'number' || typeof cy !== 'number' || isNaN(cx) || isNaN(cy) || cx < 0 || cy < 0) { + return null; // 返回null表示无效坐标 + } + + // 坐标转换:从中心点转换为左上角 + return { + x: cx - 37, // 机器人宽度74,中心偏移37 + y: cy - 37, // 机器人高度74,中心偏移37 + }; + } + + /** + * 改进的refreshRobot方法 + */ + public refreshRobot(id: RobotInfo['id'], info: Partial): void { + const pen = this.getPenById(id); + const { rotate: or, robot } = pen ?? {}; + if (!robot?.type) return; + + // 使用改进的坐标转换 + const coords = this.normalizeCoordinates(info); + + // 无效坐标处理 + if (!coords) { + this.setValue({ id, visible: false }, { render: true, history: false, doEvent: false }); + return; + } + + const { x, y } = coords; + const { active, angle, path: points } = info; + const rotate = angle ?? or; + + // 路径处理优化 + let path: Point[] | undefined; + if (points && Array.isArray(points)) { + // 新路径:相对于机器人中心的坐标 + path = points.map((p) => ({ + x: (p.x || 0) - (info.x || 37), + y: (p.y || 0) - (info.y || 37), + })); + } else if (robot.path) { + // 保持原有路径,但需要调整坐标 + const { x: ox, y: oy } = this.getPenRect(pen!); + path = robot.path.map((p) => ({ + x: p.x + ox - x, + y: p.y + oy - y, + })); + } + + const robotState = { ...robot, ...omitBy({ active, path }, isNil) }; + + // 根据active状态决定渲染方式 + if (typeof active === 'boolean') { + // 有明确的活跃状态,更新图标 + this.setValue( + { + id, + ...this.#mapRobotImage(robot.type, active), + x, + y, + rotate, + robot: robotState, + visible: true, + }, + { render: true, history: false, doEvent: false }, + ); + } else { + // 无活跃状态信息,只更新位置 + this.setValue( + { id, x, y, rotate, robot: robotState, visible: true }, + { render: true, history: false, doEvent: false }, + ); + } + } +} +``` + +## 6. 性能优化建议 + +### 6.1 渲染优化 + +- 使用`requestAnimationFrame`批量处理渲染更新 +- 实现视口裁剪,只渲染可见区域的机器人 +- 添加机器人状态变化的diff检测,避免无效渲染 + +### 6.2 内存管理 + +- 定期清理过期的机器人状态缓存 +- 使用WeakMap存储临时状态,避免内存泄漏 +- 在组件卸载时正确清理WebSocket连接和事件监听器 + +### 6.3 网络优化 + +- 实现WebSocket连接池,复用连接 +- 添加消息压缩,减少网络传输量 +- 使用心跳机制检测连接状态 + +## 7. 总结 + +机器人运动监控组件的多页面位置不一致问题主要源于: + +1. **架构设计缺陷**: 缺乏全局状态管理,每个页面独立维护状态 +2. **WebSocket连接独立性**: 多个连接可能接收到不同时间点的数据 +3. **初始化时序问题**: 不同页面的初始化时间不同,导致状态基线不一致 +4. **坐标转换逻辑**: 默认值处理和坐标转换在边界情况下存在问题 +5. **状态验证不足**: 缺乏对接收数据的有效性验证 + +通过实施全局状态管理、WebSocket连接复用、状态缓存机制和坐标转换优化等解决方案,可以有效解决这些问题,确保多页面间机器人位置的一致性。 From d7305130e649f6c1d7f252b5b469d23afebd5a03 Mon Sep 17 00:00:00 2001 From: xudan Date: Thu, 19 Jun 2025 10:48:08 +0800 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0WebSocket?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E6=9C=8D=E5=8A=A1=E6=8A=80=E6=9C=AF=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E6=96=87=E6=A1=A3=EF=BC=8C=E8=AF=A6=E7=BB=86=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E6=9E=B6=E6=9E=84=E3=80=81=E5=BF=83=E8=B7=B3=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E3=80=81=E9=87=8D=E8=BF=9E=E6=9C=BA=E5=88=B6=E5=8F=8A?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSocket增强服务技术设计文档.md | 646 +++++++++++++++++++++++++++++++ src/services/ws.ts | 326 +++++++++++++++- 2 files changed, 969 insertions(+), 3 deletions(-) create mode 100644 WebSocket增强服务技术设计文档.md diff --git a/WebSocket增强服务技术设计文档.md b/WebSocket增强服务技术设计文档.md new file mode 100644 index 0000000..a4926f0 --- /dev/null +++ b/WebSocket增强服务技术设计文档.md @@ -0,0 +1,646 @@ +# WebSocket增强服务技术设计文档 + +## 概述 + +本文档详细解释了 `src/services/ws.ts` 的技术设计思路、架构选择和实现细节。这个文件实现了一个增强的WebSocket服务,在保持原有接口不变的前提下,添加了心跳检测、自动重连、错误处理等企业级功能。 + +## 设计目标 + +### 主要目标 + +1. **零侵入性**:业务代码无需修改,完全透明的功能增强 +2. **企业级稳定性**:心跳检测、自动重连、错误恢复 +3. **可配置性**:全局配置,易于调整和优化 +4. **类型安全**:完整的TypeScript类型支持 +5. **内存安全**:正确的资源管理,防止内存泄漏 + +### 兼容性目标 + +- 保持原有 `create(path): Promise` 接口不变 +- 返回标准WebSocket实例,支持所有原生API +- 业务代码中的 `ws.onmessage`, `ws.close()` 等调用完全兼容 + +## 架构设计 + +### 整体架构图 + +``` +┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ +│ 业务代码 │ │ EnhancedWebSocket │ │ 原生WebSocket │ +│ │ │ (包装器) │ │ │ +│ ws.onmessage = ... │───▶│ 事件拦截和过滤 │───▶│ 实际网络连接 │ +│ ws.send(data) │ │ 心跳检测逻辑 │ │ │ +│ ws.close() │ │ 重连管理 │ │ │ +└─────────────────────┘ └──────────────────────┘ └─────────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ WS_CONFIG │ + │ (全局配置) │ + │ - 心跳间隔 │ + │ - 重连策略 │ + │ - 超时设置 │ + └──────────────────────┘ +``` + +### 设计模式选择 + +#### 1. 包装器模式 (Wrapper Pattern) + +```typescript +class EnhancedWebSocket { + private ws: WebSocket; // 包装原生WebSocket +} +``` + +**为什么选择包装器而不是继承?** + +1. **继承的问题**: + + ```typescript + // 继承方式的问题 + class EnhancedWebSocket extends WebSocket { + constructor(url: string) { + super(url); // 连接立即开始,无法在事件处理器设置前进行拦截 + } + } + ``` + +2. **包装器的优势**: + ```typescript + // 包装器方式的优势 + class EnhancedWebSocket { + constructor(path: string, baseUrl: string) { + this.ws = new WebSocket(baseUrl + path); // 控制创建时机 + this.setupHandlers(); // 立即设置我们的处理器 + } + } + ``` + +#### 2. 代理模式 (Proxy Pattern) + +通过getter/setter拦截用户对事件处理器的设置: + +```typescript +get onmessage(): ((event: MessageEvent) => void) | null { + return this.userOnMessage; +} + +set onmessage(handler: ((event: MessageEvent) => void) | null) { + this.userOnMessage = handler; // 保存用户的处理器 + // 我们的处理器已经在构造时设置,会调用用户的处理器 +} +``` + +## 核心技术实现 + +### 1. Class 设计选择 + +#### 为什么使用 Class? + +```typescript +class EnhancedWebSocket { + // 私有状态管理 + private ws: WebSocket; + private path: string; + private heartbeatTimer?: NodeJS.Timeout; + // ... +} +``` + +**选择Class的原因:** + +1. **状态封装**:WebSocket连接需要管理多个状态(连接、定时器、配置等) +2. **方法绑定**:事件处理器需要访问实例状态,Class提供了自然的this绑定 +3. **生命周期管理**:连接的创建、维护、销毁有清晰的生命周期 +4. **类型安全**:TypeScript对Class有更好的类型推导和检查 + +**与函数式方案的对比:** + +```typescript +// 函数式方案的问题 +function createEnhancedWS(path: string) { + let heartbeatTimer: NodeJS.Timeout; + let reconnectTimer: NodeJS.Timeout; + // 需要大量闭包来管理状态,复杂度高 +} + +// Class方案的优势 +class EnhancedWebSocket { + private heartbeatTimer?: NodeJS.Timeout; // 清晰的状态管理 + private reconnectTimer?: NodeJS.Timeout; + // 方法可以直接访问状态 +} +``` + +### 2. Private 成员设计 + +#### 为什么大量使用 private? + +```typescript +class EnhancedWebSocket { + private ws: WebSocket; // 内部WebSocket实例 + private path: string; // 连接路径 + private heartbeatTimer?: NodeJS.Timeout; // 心跳定时器 + private reconnectTimer?: NodeJS.Timeout; // 重连定时器 + private reconnectAttempts: number = 0; // 重连次数 + private isManualClose: boolean = false; // 手动关闭标志 + private isHeartbeatTimeout: boolean = false; // 心跳超时标志 +} +``` + +**Private的重要性:** + +1. **封装原则**:防止外部直接访问和修改内部状态 +2. **API稳定性**:内部实现可以随时重构,不影响公共接口 +3. **状态一致性**:防止外部代码破坏内部状态的一致性 +4. **错误预防**:避免用户误用内部API导致的bug + +**示例对比:** + +```typescript +// 如果没有private,用户可能这样做 +const ws = new EnhancedWebSocket('/test'); +ws.heartbeatTimer = undefined; // 💥 破坏了心跳检测 +ws.reconnectAttempts = -1; // 💥 破坏了重连逻辑 + +// 有了private,这些操作被编译器阻止 +// ✅ 确保了内部状态的安全性 +``` + +### 3. Constructor 设计 + +#### 构造函数的关键作用 + +```typescript +constructor(path: string, baseUrl: string) { + this.path = path; + this.baseUrl = baseUrl; + this.ws = new WebSocket(baseUrl + path); // 创建实际连接 + this.setupHandlers(); // 立即设置事件处理器 +} +``` + +**设计要点:** + +1. **立即执行**:构造时立即创建连接和设置处理器 +2. **状态初始化**:确保所有私有状态都有正确的初始值 +3. **参数验证**:(可以添加)对输入参数进行验证 +4. **最小权限**:只接收必要的参数,其他配置使用全局配置 + +**为什么不延迟创建连接?** + +```typescript +// ❌ 错误方案:延迟创建 +constructor(path: string, baseUrl: string) { + this.path = path; + this.baseUrl = baseUrl; + // 不创建连接,等用户调用connect() +} + +// ✅ 正确方案:立即创建 +constructor(path: string, baseUrl: string) { + // 立即创建,因为原有接口期望构造后就有连接 + this.ws = new WebSocket(baseUrl + path); + this.setupHandlers(); +} +``` + +### 4. Getter/Setter 设计 + +#### 透明的属性代理 + +```typescript +// 只读属性的getter +get readyState(): number { + return this.ws.readyState; // 直接代理到内部WebSocket +} + +get url(): string { + return this.ws.url; +} + +// 可写属性的getter/setter +get binaryType(): BinaryType { + return this.ws.binaryType; +} + +set binaryType(value: BinaryType) { + this.ws.binaryType = value; +} +``` + +**为什么需要这些getter/setter?** + +1. **API兼容性**:用户期望能够访问标准WebSocket的所有属性 +2. **透明代理**:用户感觉在使用标准WebSocket,实际上是我们的增强版本 +3. **状态同步**:确保外部看到的状态与内部WebSocket状态一致 + +#### 事件处理器的特殊getter/setter + +```typescript +// 事件处理器的拦截 +get onmessage(): ((event: MessageEvent) => void) | null { + return this.userOnMessage; // 返回用户设置的处理器 +} + +set onmessage(handler: ((event: MessageEvent) => void) | null) { + this.userOnMessage = handler; // 保存用户的处理器 + // 我们的内部处理器会调用用户的处理器 +} +``` + +**关键设计思路:** + +1. **双层处理**:我们的处理器 + 用户的处理器 +2. **透明性**:用户感觉直接在设置WebSocket的事件处理器 +3. **控制权**:我们先处理(如过滤心跳),再传递给用户 + +### 5. 事件处理架构 + +#### 事件流设计 + +``` +WebSocket原生事件 → 我们的处理器 → 过滤/处理 → 用户的处理器 +``` + +#### 具体实现 + +```typescript +private setupHandlers(): void { + // 1. 设置我们的处理器 + this.ws.onmessage = (event) => { + const messageData = event.data; + + // 2. 我们先处理(心跳检测) + let isHeartbeatResponse = false; + if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + + if (isHeartbeatResponse) { + this.clearHeartbeatTimeout(); // 清除心跳超时 + return; // 不传递给用户 + } + + // 3. 传递给用户的处理器 + if (this.userOnMessage) { + this.userOnMessage(event); + } + }; +} +``` + +**设计优势:** + +1. **消息过滤**:自动过滤心跳消息,用户无感知 +2. **状态管理**:自动处理连接状态变化 +3. **错误恢复**:自动处理连接错误和重连 + +### 6. 定时器管理 + +#### 定时器生命周期管理 + +```typescript +class EnhancedWebSocket { + private heartbeatTimer?: NodeJS.Timeout; // 心跳发送定时器 + private heartbeatTimeoutTimer?: NodeJS.Timeout; // 心跳响应超时定时器 + private reconnectTimer?: NodeJS.Timeout; // 重连定时器 +} +``` + +**为什么需要三个定时器?** + +1. **heartbeatTimer**:定期发送心跳包 +2. **heartbeatTimeoutTimer**:检测心跳响应超时 +3. **reconnectTimer**:延迟重连 + +#### 定时器清理策略 + +```typescript +// 停止心跳检测 +private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; // 重置为undefined + } + this.clearHeartbeatTimeout(); // 同时清理超时检测 +} + +// 清除心跳响应超时检测 +private clearHeartbeatTimeout(): void { + if (this.heartbeatTimeoutTimer) { + clearTimeout(this.heartbeatTimeoutTimer); + this.heartbeatTimeoutTimer = undefined; // 重置为undefined + } +} +``` + +**内存安全保证:** + +1. **及时清理**:每次停止时都清理定时器 +2. **状态重置**:清理后设置为undefined +3. **多重清理**:在多个关键点都进行清理(连接关闭、手动关闭等) + +### 7. 状态标志设计 + +#### 关键状态标志 + +```typescript +private isManualClose: boolean = false; // 是否手动关闭 +private isHeartbeatTimeout: boolean = false; // 是否心跳超时 +private reconnectAttempts: number = 0; // 重连次数 +``` + +**为什么需要这些标志?** + +1. **区分关闭原因**:手动关闭 vs 异常断开 vs 心跳超时 +2. **重连决策**:根据不同原因决定是否重连 +3. **状态跟踪**:跟踪重连进度和次数 + +#### 状态转换逻辑 + +```typescript +// 心跳超时时 +private startHeartbeatTimeout(): void { + this.heartbeatTimeoutTimer = setTimeout(() => { + this.isHeartbeatTimeout = true; // 设置心跳超时标志 + this.ws.close(1000, 'Heartbeat timeout'); + }, WS_CONFIG.heartbeatTimeout); +} + +// 连接关闭时的决策 +this.ws.onclose = (event) => { + // 如果不是手动关闭,或者是心跳超时导致的关闭,则重连 + if (!this.isManualClose || this.isHeartbeatTimeout) { + this.scheduleReconnect(); + } + + this.isHeartbeatTimeout = false; // 重置标志 +}; +``` + +### 8. addEventListener/removeEventListener 实现 + +#### 为什么需要这些方法? + +```typescript +addEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | AddEventListenerOptions +): void { + this.ws.addEventListener(type, listener, options); +} + +removeEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | EventListenerOptions +): void { + this.ws.removeEventListener(type, listener, options); +} +``` + +**重要性:** + +1. **完整的API兼容性**:某些业务代码可能使用addEventListener而不是onXXX +2. **事件管理**:支持多个监听器 +3. **标准兼容**:遵循WebSocket标准API + +**类型安全:** + +- 使用泛型 `` 确保事件类型正确 +- listener参数的类型根据事件类型自动推导 + +### 9. 心跳检测机制 + +#### 心跳超时检测逻辑 + +```typescript +// 发送心跳时,只在没有超时检测时才设置新的 +this.heartbeatTimer = setInterval(() => { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(WS_CONFIG.heartbeatMessage); + + if (!this.heartbeatTimeoutTimer) { + // 关键:避免重复设置 + this.startHeartbeatTimeout(); + } + } +}, WS_CONFIG.heartbeatInterval); +``` + +**设计要点:** + +1. **避免重复设置**:只有在没有超时检测时才设置新的 +2. **超时逻辑**:设定时间内没收到响应就断开连接 +3. **状态同步**:收到响应时清除超时检测 + +#### 心跳响应处理 + +```typescript +// 检查是否为心跳响应(支持字符串和JSON格式) +let isHeartbeatResponse = false; + +// 1. 检查简单字符串格式 +if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; +} + +// 2. 检查JSON格式 +if (!isHeartbeatResponse && typeof messageData === 'string') { + try { + const data = JSON.parse(messageData); + if (data.type === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + } catch (e) { + // JSON解析失败,不是JSON格式的心跳响应 + } +} +``` + +**兼容性设计**:支持两种心跳响应格式,适应不同的服务器实现。 + +### 10. 重连机制 + +#### 指数退避算法 + +```typescript +private scheduleReconnect(): void { + if (this.isManualClose || this.reconnectAttempts >= WS_CONFIG.maxReconnectAttempts) { + return; + } + + this.reconnectAttempts++; + + // 指数退避重连策略 + const delay = Math.min( + WS_CONFIG.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1), + WS_CONFIG.maxReconnectDelay + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnect(); + }, delay); +} +``` + +**算法解释:** + +- 第1次重连:1000ms 后 +- 第2次重连:2000ms 后 +- 第3次重连:4000ms 后 +- 第4次重连:8000ms 后 +- 第5次重连:16000ms 后(受maxReconnectDelay限制,实际为30000ms) + +**设计考虑:** + +1. **指数退避**:避免对服务器造成压力 +2. **最大延迟限制**:防止延迟过长 +3. **次数限制**:避免无限重连 +4. **服务器友好**:给服务器恢复时间 + +### 11. 类型安全设计 + +#### 严格的类型定义 + +```typescript +// 事件处理器类型 +private userOnMessage: ((event: MessageEvent) => void) | null = null; +private userOnClose: ((event: CloseEvent) => void) | null = null; +private userOnError: ((event: Event) => void) | null = null; +private userOnOpen: ((event: Event) => void) | null = null; +``` + +**类型安全的好处:** + +1. **编译时检查**:在编译时捕获类型错误 +2. **IDE支持**:更好的自动补全和错误提示 +3. **重构安全**:类型系统确保重构的正确性 + +#### 泛型的使用 + +```typescript +addEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | AddEventListenerOptions +): void +``` + +**泛型的价值:** + +- `K extends keyof WebSocketEventMap`:确保事件类型只能是WebSocket支持的类型 +- `ev: WebSocketEventMap[K]`:根据事件类型自动推导事件对象类型 + +### 12. 资源管理 + +#### 完整的清理机制 + +```typescript +close(code?: number, reason?: string): void { + console.log(`手动关闭WebSocket: ${this.path}`); + this.isManualClose = true; + this.isHeartbeatTimeout = false; // 重置心跳超时标志 + this.stopHeartbeat(); // 清理心跳定时器 + this.clearReconnectTimer(); // 清理重连定时器 + this.ws.close(code, reason); // 关闭实际连接 +} +``` + +**资源清理的重要性:** + +1. **内存泄漏预防**:确保所有定时器都被清理 +2. **状态一致性**:重置所有状态标志 +3. **优雅关闭**:按正确顺序清理资源 + +## 配置设计 + +### 全局配置对象 + +```typescript +const WS_CONFIG = { + heartbeatInterval: 3000, // 心跳间隔 + heartbeatTimeout: 5000, // 心跳响应超时时间 + maxReconnectAttempts: 5, // 最大重连次数 + reconnectBaseDelay: 1000, // 重连基础延迟 + maxReconnectDelay: 30000, // 最大重连延迟 + heartbeatMessage: 'ping', // 心跳消息 + heartbeatResponseType: 'pong', // 心跳响应类型 +}; +``` + +**配置设计原则:** + +1. **集中管理**:所有配置在一个地方,易于维护 +2. **合理默认值**:开箱即用的配置 +3. **易于调整**:生产环境可以快速调整参数 +4. **文档化**:每个配置都有清晰的注释 + +## 接口兼容性 + +### 原有接口保持不变 + +```typescript +// 原有接口 +function create(path: string): Promise { + const baseUrl = import.meta.env.ENV_WEBSOCKET_BASE ?? ''; + const ws = new EnhancedWebSocket(path, baseUrl) as any; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error('WebSocket connection timeout')); + }, 10000); + + ws.addEventListener('open', () => { + clearTimeout(timeout); + resolve(ws); // 返回增强的WebSocket,但类型为WebSocket + }); + + ws.addEventListener('error', (e: any) => { + clearTimeout(timeout); + reject(e); + }); + }); +} +``` + +**兼容性保证:** + +1. **相同的函数签名**:`create(path: string): Promise` +2. **相同的返回类型**:返回Promise +3. **相同的使用方式**:业务代码无需任何修改 + +## 总结 + +### 技术选择总结 + +| 技术选择 | 原因 | 替代方案 | 为什么不选择替代方案 | +| ------------- | -------------------------------- | ------------ | ----------------------- | +| Class | 状态封装、方法绑定、生命周期管理 | 函数+闭包 | 复杂度高,类型支持差 | +| 包装器模式 | 控制创建时机、事件拦截 | 继承 | 无法在事件设置前拦截 | +| Private成员 | 封装、API稳定性、状态保护 | Public成员 | 容易被误用,状态不安全 | +| Getter/Setter | 透明代理、API兼容性 | 直接方法 | 不符合WebSocket API习惯 | +| 多定时器 | 职责分离、精确控制 | 单定时器 | 逻辑混乱,难以维护 | +| 状态标志 | 精确控制重连逻辑 | 仅依赖状态码 | WebSocket状态码限制多 | + +### 架构优势 + +1. **零侵入性**:业务代码完全无需修改 +2. **高可靠性**:多重保障确保连接稳定 +3. **高可维护性**:清晰的架构和完整的类型支持 +4. **高性能**:最小的性能开销 +5. **高扩展性**:易于添加新功能 + +### 最佳实践体现 + +1. **单一职责原则**:每个方法只负责一个功能 +2. **开闭原则**:对扩展开放,对修改封闭 +3. **依赖倒置原则**:依赖抽象(接口)而非具体实现 +4. **接口隔离原则**:用户只看到需要的接口 +5. **里氏替换原则**:增强版本完全可以替换原版本 + +这个实现展示了如何在保持向后兼容的同时,提供企业级的功能增强,是一个很好的渐进式增强的例子。 diff --git a/src/services/ws.ts b/src/services/ws.ts index a35036b..bb0c2d8 100644 --- a/src/services/ws.ts +++ b/src/services/ws.ts @@ -1,8 +1,328 @@ +// WebSocket全局配置 +const WS_CONFIG = { + heartbeatInterval: 30000, // 30秒心跳间隔 + heartbeatTimeout: 5000, // 心跳响应超时时间(5秒) + maxReconnectAttempts: 5, // 最大重连次数 + reconnectBaseDelay: 1000, // 重连基础延迟1秒 + maxReconnectDelay: 30000, // 最大重连延迟30秒 + heartbeatMessage: 'ping', // 心跳消息 + heartbeatResponseType: 'pong', // 心跳响应类型 +}; + +// 增强的WebSocket包装器 +class EnhancedWebSocket { + private ws: WebSocket; + private path: string; + private baseUrl: string; + private heartbeatTimer?: NodeJS.Timeout; + private heartbeatTimeoutTimer?: NodeJS.Timeout; + private reconnectTimer?: NodeJS.Timeout; + private reconnectAttempts: number = 0; + private isManualClose: boolean = false; + private isHeartbeatTimeout: boolean = false; + private userOnMessage: ((event: MessageEvent) => void) | null = null; + private userOnClose: ((event: CloseEvent) => void) | null = null; + private userOnError: ((event: Event) => void) | null = null; + private userOnOpen: ((event: Event) => void) | null = null; + + constructor(path: string, baseUrl: string) { + this.path = path; + this.baseUrl = baseUrl; + this.ws = new WebSocket(baseUrl + path); + this.setupHandlers(); + } + + // 设置事件处理器 + private setupHandlers(): void { + this.ws.onopen = (event) => { + console.log(`WebSocket连接已建立: ${this.path}`); + this.reconnectAttempts = 0; + this.clearReconnectTimer(); + this.startHeartbeat(); + + if (this.userOnOpen) { + this.userOnOpen(event); + } + }; + + this.ws.onmessage = (event) => { + const messageData = event.data; + + // 检查是否为心跳响应(支持字符串和JSON格式) + let isHeartbeatResponse = false; + + // 1. 检查是否为简单字符串心跳响应 + if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + + // 2. 检查是否为JSON格式心跳响应 + if (!isHeartbeatResponse && typeof messageData === 'string') { + try { + const data = JSON.parse(messageData); + if (data.type === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + } catch (e) { + // JSON解析失败,不是JSON格式的心跳响应 + } + } + + if (isHeartbeatResponse) { + // 收到心跳响应,清除超时定时器 + this.clearHeartbeatTimeout(); + return; + } + + // 传递给业务代码 + if (this.userOnMessage) { + this.userOnMessage(event); + } + }; + + this.ws.onclose = (event) => { + console.log(`WebSocket连接关闭: ${this.path}`, event.code, event.reason); + this.stopHeartbeat(); + + // 先调用业务代码的关闭处理 + if (this.userOnClose) { + this.userOnClose(event); + } + + // 如果不是手动关闭,或者是心跳超时导致的关闭,则重连 + if (!this.isManualClose || this.isHeartbeatTimeout) { + this.scheduleReconnect(); + } + + // 重置心跳超时标志 + this.isHeartbeatTimeout = false; + }; + + this.ws.onerror = (event) => { + console.error(`WebSocket连接错误: ${this.path}`, event); + this.stopHeartbeat(); + + if (this.userOnError) { + this.userOnError(event); + } + }; + } + + // 开始心跳检测 + private startHeartbeat(): void { + this.stopHeartbeat(); + console.log(`开始心跳检测: ${this.path}, 间隔: ${WS_CONFIG.heartbeatInterval}ms`); + this.heartbeatTimer = setInterval(() => { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(WS_CONFIG.heartbeatMessage); + + // 只有在没有进行超时检测时才设置新的超时检测 + if (!this.heartbeatTimeoutTimer) { + this.startHeartbeatTimeout(); + } + } + }, WS_CONFIG.heartbeatInterval); + } + + // 停止心跳检测 + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + this.clearHeartbeatTimeout(); + } + + // 开始心跳响应超时检测 + private startHeartbeatTimeout(): void { + // 不再自动清除,只在收到响应时清除 + this.heartbeatTimeoutTimer = setTimeout(() => { + console.log(`心跳响应超时: ${this.path}, ${WS_CONFIG.heartbeatTimeout}ms内未收到响应,主动断开连接`); + // 设置心跳超时标志,触发重连 + this.isHeartbeatTimeout = true; + this.ws.close(1000, 'Heartbeat timeout'); // 使用正常关闭状态码,通过标志来判断是否重连 + }, WS_CONFIG.heartbeatTimeout); + } + + // 清除心跳响应超时检测 + private clearHeartbeatTimeout(): void { + if (this.heartbeatTimeoutTimer) { + clearTimeout(this.heartbeatTimeoutTimer); + this.heartbeatTimeoutTimer = undefined; + } + } + + // 安排重连 + private scheduleReconnect(): void { + if (this.isManualClose || this.reconnectAttempts >= WS_CONFIG.maxReconnectAttempts) { + console.log(`停止重连: ${this.path}, 手动关闭: ${this.isManualClose}, 重连次数: ${this.reconnectAttempts}`); + return; + } + + this.reconnectAttempts++; + + // 指数退避重连策略 + const delay = Math.min( + WS_CONFIG.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1), + WS_CONFIG.maxReconnectDelay, + ); + + console.log( + `WebSocket将在${delay}ms后重连: ${this.path} (${this.reconnectAttempts}/${WS_CONFIG.maxReconnectAttempts})`, + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnect(); + }, delay); + } + + // 重连逻辑 + private reconnect(): void { + if (this.isManualClose) return; + + console.log(`WebSocket重连尝试: ${this.path} (${this.reconnectAttempts}/${WS_CONFIG.maxReconnectAttempts})`); + + // 创建新的WebSocket连接 + this.ws = new WebSocket(this.baseUrl + this.path); + this.setupHandlers(); + } + + // 清理重连定时器 + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + } + + // 公开的WebSocket属性和方法 + get readyState(): number { + return this.ws.readyState; + } + + get url(): string { + return this.ws.url; + } + + get protocol(): string { + return this.ws.protocol; + } + + get extensions(): string { + return this.ws.extensions; + } + + get bufferedAmount(): number { + return this.ws.bufferedAmount; + } + + get binaryType(): BinaryType { + return this.ws.binaryType; + } + + set binaryType(value: BinaryType) { + this.ws.binaryType = value; + } + + // 事件处理器属性 + get onopen(): ((event: Event) => void) | null { + return this.userOnOpen; + } + + set onopen(handler: ((event: Event) => void) | null) { + this.userOnOpen = handler; + } + + get onmessage(): ((event: MessageEvent) => void) | null { + return this.userOnMessage; + } + + set onmessage(handler: ((event: MessageEvent) => void) | null) { + this.userOnMessage = handler; + } + + get onclose(): ((event: CloseEvent) => void) | null { + return this.userOnClose; + } + + set onclose(handler: ((event: CloseEvent) => void) | null) { + this.userOnClose = handler; + } + + get onerror(): ((event: Event) => void) | null { + return this.userOnError; + } + + set onerror(handler: ((event: Event) => void) | null) { + this.userOnError = handler; + } + + // WebSocket方法 + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + this.ws.send(data); + } + + close(code?: number, reason?: string): void { + console.log(`手动关闭WebSocket: ${this.path}`); + this.isManualClose = true; + this.isHeartbeatTimeout = false; // 手动关闭时重置心跳超时标志 + this.stopHeartbeat(); + this.clearReconnectTimer(); + this.ws.close(code, reason); + } + + addEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void { + this.ws.addEventListener(type, listener, options); + } + + removeEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void { + this.ws.removeEventListener(type, listener, options); + } + + dispatchEvent(event: Event): boolean { + return this.ws.dispatchEvent(event); + } + + // 常量 + static readonly CONNECTING = WebSocket.CONNECTING; + static readonly OPEN = WebSocket.OPEN; + static readonly CLOSING = WebSocket.CLOSING; + static readonly CLOSED = WebSocket.CLOSED; + + readonly CONNECTING = WebSocket.CONNECTING; + readonly OPEN = WebSocket.OPEN; + readonly CLOSING = WebSocket.CLOSING; + readonly CLOSED = WebSocket.CLOSED; +} + function create(path: string): Promise { - const ws = new WebSocket((import.meta.env.ENV_WEBSOCKET_BASE ?? '') + path); + const baseUrl = import.meta.env.ENV_WEBSOCKET_BASE ?? ''; + const ws = new EnhancedWebSocket(path, baseUrl) as any; + return new Promise((resolve, reject) => { - ws.onopen = () => resolve(ws); - ws.onerror = (e) => reject(e); + const timeout = setTimeout(() => { + ws.close(); + reject(new Error('WebSocket connection timeout')); + }, 10000); // 10秒连接超时 + + ws.addEventListener('open', () => { + clearTimeout(timeout); + resolve(ws); + }); + + ws.addEventListener('error', (e: any) => { + clearTimeout(timeout); + reject(e); + }); }); } + export default { create }; From 534befd88b0d813784e19e5e34f006a8de7dfb8f Mon Sep 17 00:00:00 2001 From: xudan Date: Thu, 19 Jun 2025 14:07:08 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0Vite=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E7=9A=84API=E5=92=8CWebSocket=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=9C=B0=E5=9D=80=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=BC=80=E5=8F=91=E7=8E=AF=E5=A2=83=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 86fb44e..9bcb965 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -45,12 +45,12 @@ export default ({ mode }: Record) => proxy: { '/mocks/': { target: 'http://localhost:8888/web-amr' }, '/api/': { - target: 'http://82.156.39.91:18080/jeecg-boot', + target: 'http://192.168.189.206:8080/jeecg-boot', rewrite: (path) => path.replace(/^\/api/, ''), changeOrigin: true, }, '/ws/': { - target: 'ws://82.156.39.91:18080/jeecg-boot', + target: 'ws://192.168.189.206:8080/jeecg-boot', rewrite: (path) => path.replace(/^\/ws/, ''), changeOrigin: true, ws: true,