feat - 添加WPFNodeDemo

main
SoulStar 1 month ago
parent 5955b36967
commit ffb005fec6

@ -0,0 +1,228 @@
# NewLife Copilot 协作指令
适用于新生命团队NewLife全部 C#/.NET 仓库。存在本文件则必须遵循。**简体中文回复。**
通用 C# 最佳实践设计模式、SOLID、健壮性等AI 已知,此处不赘述,**仅列出组织专属规则与反常规约定**。
---
## 1. 专用指令(前置检查,必须执行)
**开始任何任务前,必须先将用户请求与下表触发信号逐行匹配。命中则立即用 `get_file` 读取 `.github/instructions/{指令文件}`,读取成功后遵循其中全部规则。未命中任何行才跳过。**
| 触发信号(用户请求含以下任意关键词即命中) | 指令文件 |
|---------|---------|
| XCode/实体生成/Model.xml/数据库 CRUD/`NewLife.XCode` 引用/`*.xcode.xml`/项目名含 `.Data`/`XCode.*` 命名空间/用户提及修改任意 `.xml` 文件 | `xcode.instructions.md` |
| Cube/魔方/Web开发/`NewLife.Cube` 引用/`NewLife.Cube.*` 命名空间 | `cube.instructions.md` |
| 性能测试/基准测试/压力测试/压测/BenchmarkDotNet/Benchmark/benchmark/吞吐量评估/性能分析/性能对比/性能报告/速度对比/速度测试/内存分配/perf/性能优化测试/做性能/跑分/测试报告 | `benchmark.instructions.md` |
| NetServer/NetSession/网络服务器/网络客户端/Socket服务/TCP服务/UDP服务/`NewLife.Net` 引用/`NewLife.Net.*` 命名空间/ISocketClient/ISocketRemote/CreateRemote/StandardCodec/LengthFieldCodec/管道编解码/网络编程/Echo服务/网络会话/长连接/粘包拆包 | `net.instructions.md` |
| 新建系统/新建项目/新增模块/需求整理/需求文档/需求分析/架构设计/技术方案/功能清单/功能拆分/任务分解/迭代开发/迭代计划/验收/PRD/用户故事/做一个系统/做一个平台/开发流程/全部搞完/批量开发/自治模式/一次性做完/继续处理/接着做 | `development.instructions.md` |
---
## 2. 核心原则
检索优先、风格一致、兼容友好、**主动优化**。
发现明显缺陷(资源泄漏、空引用、逻辑错误)时主动修复;优化请求时深入分析,不做表面工作。
改动较小直接做并说明;改动较大(涉及公共 API 或大范围重构)先列方案询问确认。
---
## 3. 兼容性约束(极重要)
NewLife 核心库支持 `.NET 4.5` 至最新版本(`net45` → `net10`)。
- **语言版本**`<LangVersion>latest</LangVersion>`,最大化使用最新 C# 语法糖switch 表达式、集合表达式 `[]`、`?.`/`??`、模式匹配、目标类型 `new`、record 等)
- **禁止高版本专属 BCL API**:❌ `ArgumentNullException.ThrowIfNull()` → ✅ `if (x == null) throw new ArgumentNullException(nameof(x));`
- **条件编译符号**`NETFRAMEWORK`、`NETSTANDARD2_0`、`NETCOREAPP`、`NET5_0_OR_GREATER`、`NET6_0_OR_GREATER`、`NET8_0_OR_GREATER`
- 新增 API 需评估各框架兼容性,必要时提供条件编译降级实现
---
## 4. 编码规范
### 4.1 类型名(关键差异)
**必须**使用 .NET 正式名:`String`/`Int32`/`Boolean`/`Int64`/`Double`/`Object` 等。
**禁止**使用 C# 别名:`string`/`int`/`bool`/`long`/`double`/`object`
### 4.2 命名
| 成员类型 | 规则 | 示例 |
|---------|------|------|
| 类型/公共成员 | PascalCase | `UserService`、`GetName()` |
| 参数/局部变量 | camelCase | `userName`、`count` |
| 私有字段 | `_camelCase` | `_cache`、`_instance` |
| 扩展方法类 | `xxxHelper``xxxExtensions` | `StringHelper`、`CollectionExtensions` |
### 4.3 代码风格
- **命名空间**file-scoped namespace
- **单文件**:每文件一个主要公共类型;较大平台差异使用 `partial`
- **集合初始化**:优先使用集合表达式 `[]`,如 `List<String> Tags { get; set; } = [];`
- **Null 条件运算符**:优先使用 `?.`/`??` 简化空值检查
```csharp
// ✅ 单行 if单语句且整行不过长时同行
if (value == null) return;
if (key == null) throw new ArgumentNullException(nameof(key));
// ✅ 语句较长时另起一行,仍不加花括号
if (value == null)
throw new ArgumentNullException(nameof(value), "Value cannot be null");
// ✅ 多分支单语句:不加花括号
if (count > 0)
DoSomething();
else
DoOther();
// ✅ 循环必须保留花括号(即使单语句)
foreach (var item in list)
{
Process(item);
}
// ✅ using 优先无花括号声明;仅需生命周期(如锁)时用弃元
using var stream = File.OpenRead("file.txt");
using var _ = _lock.AcquireLock();
```
### 4.4 Region 与日志
较长类使用 `#region` 分段,顺序:`属性` → `静态``构造``方法``辅助`**`日志`**。
`ILog Log``WriteLog` 时:**必须放类末尾**,用名为"日志"的 region 包裹,不放入"辅助"。
关键过程可使用 `Tracer?.NewSpan()` 埋点。
### 4.5 文档注释
- `<summary>` **必须同行闭合**`/// <summary>获取名称</summary>`
- 每个参数**必须有** `<param>` 标签,无论方法可见性
- 有返回值**必须有** `<returns>`;复杂方法可增加 `<remarks>`
- `public`/`protected` 成员必须注释;`[Obsolete]` 必须包含迁移建议
### 4.6 异步与性能
- 异步方法后缀 `Async`,库内部默认 `ConfigureAwait(false)`
- 热点路径避免反射/复杂 Linq优先手写循环/`ArrayPool<T>`/`Span`
- 池化资源明确获取/归还,异常分支不遗失归还
### 4.7 错误处理
- 精准异常类型:`ArgumentNullException`/`InvalidOperationException` 等
- TryXxx 模式:不用异常作常规分支
- 类型转换:优先使用 `ToInt()`/`ToBoolean()` 等扩展方法
- 对外异常不暴露内部实现/路径
---
## 5. NewLife 内置工具
优先使用项目内置工具而非标准库,**禁止重复造轮子**
- 字符串构建:`Pool.StringBuilder`(替代 `new StringBuilder()`
- 时间戳:`Runtime.TickCount64`
- 类型转换:`ToInt()`、`ToBoolean()`、`ToDouble()`、`ToDateTime()` 等扩展方法
- 追踪埋点:`Tracer?.NewSpan()`
---
## 6. 防御性注释(禁止删除)
代码中带有说明文字的被注释代码属于**防御性注释**,记录历史踩坑经验。**禁止删除,禁止"恢复"执行**。可补充更详细说明。
```csharp
// 曾经尝试过同步等待,但会导致线程池饥饿和死锁
// var result = task.Result;
// 不要使用 SendAsync 的无超时重载,否则会造成连接泄漏
// await client.SendAsync(data);
```
---
## 7. 工作流
触发检查(第 1 节触发信号表匹配,命中则读取专用指令) → 检索(**优先复用**现有实现) → 评估(公共 API/兼容性/性能) → 方案 → 实施 → 验证 → 说明
- **触发检查**:开始工作前必须完成,遗漏专用指令将导致输出不符合要求
- **实施**:完成主任务;顺带修复明显缺陷;顺带简化重复代码;保留原注释与结构
- **验证**:代码变更必须编译通过;找到相关测试则运行;仅文档变更可跳过
### 主动优化原则
用户要求**分析/优化代码**时:
| 行动 | 说明 |
|------|------|
| **架构梳理** | 重构不清晰的结构,让代码更易懂 |
| **缺陷修复** | 资源泄漏、空引用、并发问题、逻辑错误 → 直接修复 |
| **代码简化** | 提取重复代码、合并冗余判断、应用现代语法 |
| **性能优化** | 缓存重复计算、池化高频对象、避免无用分配 |
| **注释完善** | 补充缺失的 XML 注释和关键逻辑说明 |
---
## 8. 测试
- 框架 xUnit类名 `{ClassName}Tests`;方法加 `[DisplayName("中文描述意图")]`
- 网络端口用 `0`/随机IO 用临时目录
- 先搜索 `{ClassName}` 引用定位测试文件,再找 `{ClassName}Tests.cs`**未找到需说明**,不自动创建测试项目
---
## 9. 文档与发布
### Markdown 文档
UTF-8 无 BOM存放 `Doc/` 目录;文件名优先中文。**已有文件必须先读取再增量修改,禁止覆盖。**
### NuGet 版本
| 类型 | 格式 | 示例 |
|------|------|------|
| 正式版 | `{主}.{子}.{年}.{月日}` | `11.9.2025.0701` |
| 测试版 | `{主}.{子}.{年}.{月日}-beta{时分}` | `11.9.2025.0701-beta0906` |
---
## 10. 重要禁止项
以下是 AI 容易犯但在本项目影响严重的错误:
- 将 `String`/`Int32` 改为 `string`/`int`(本项目反 C# 惯例,**必须用正式名**
- 删除防御性注释(带说明的注释代码)
- 删除循环体的花括号
- 将 `<summary>` 拆成多行
- 擅自删除 `public`/`protected` 成员
- 擅自新增外部 NuGet 依赖(需说明理由)
- 仅删除空白行/注释制造"格式优化"提交
- 虚构不存在的 API/文件/类型
- 伪造测试结果/性能数据
- 在热点路径添加未缓存反射/复杂 Linq
- 输出敏感凭据/内部地址
- 发现问题却视而不见
- 用户要求优化时仅做注释/测试等表面工作
- **跳过第 1 节触发检查**(命中关键词却未加载专用指令文件,是最严重的遗漏错误)
---
## 11. 变更说明模板
```markdown
## 概述
做了什么 / 为什么
## 影响
- 公共 API是/否
- 性能影响:无/有(说明)
## 兼容性
降级策略 / 条件编译点
## 风险与后续
潜在回归 / 是否补测试
```
---
(完)

@ -0,0 +1,118 @@
---
applyTo: "**/Benchmark/**"
---
# 性能测试指令
适用于性能测试、压力测试、基准测试、BenchmarkDotNet 相关任务。
---
## 1. 项目结构
- 基准测试统一放在 `Benchmark/` 项目,按主题分子目录(如 `PacketBenchmarks/`、`CacheBenchmarks/`
- 入口 `Program.cs` 使用 `BenchmarkSwitcher` 模式,**不要修改**
- TFM 使用最新稳定版,`<LangVersion>latest</LangVersion>`
## 2. 代码规范
遵循主指令全部编码规范(类型名用 `String`/`Int32` 等、file-scoped namespace另有以下补充
- **命名空间**`Benchmark.{主题}Benchmarks`
- **类名**`{被测类型}Benchmark` 或 `{被测主题}Benchmark`
- **必须标注** `[MemoryDiagnoser]``[SimpleJob]`(需调整迭代次数时用 `[SimpleJob(iterationCount: N)]`
- **方法描述**`[Benchmark(Description = "中文描述")]`,方便报告阅读
- **参数化**:用 `[Params]``[ParamsSource]` 控制数据规模
- **初始化 / 清理**:分别放 `[GlobalSetup]``[GlobalCleanup]`
- **分组**:同类测试用 `#region` 分组
- **多线程并发**:动态线程数包含 CPU 核心数,推荐模板:
```csharp
public static IEnumerable<Int32> ThreadCounts
{
get
{
var cores = Environment.ProcessorCount;
var set = new SortedSet<Int32> { 1, 4, 8, 32 };
set.Add(cores);
return set;
}
}
[ParamsSource(nameof(ThreadCounts))]
public Int32 ThreadCount { get; set; }
```
## 3. 运行要求
- 必须以 **Release 模式**运行,获取有代表性的峰值数据
- 运行全部:`dotnet run -c Release`
- 运行指定类:`dotnet run -c Release -- --filter *ClassName*`
- ❌ 禁止在 Debug 模式下采集数据写入报告
## 4. 测试维度
- **并发维度**:单线程 + 多线程(多线程含与当前 CPU 核心数相同的并发数)
- **操作维度**:单一操作 + 批量操作
## 5. 常见错误
- ❌ 在 `[Benchmark]` 方法内做初始化(应放 `[GlobalSetup]`
- ❌ 忽略返回值导致 JIT 死码消除(确保返回或赋值给字段)
- ❌ 手动 `Stopwatch` 计时BDN 自动处理)
- ❌ `using``Dispose` 开销混入测量(仅在测试 Dispose 本身时才包含)
## 6. 报告存放
`Doc/Benchmark/{测试主题}性能测试.md`UTF-8 无 BOM
## 7. 报告结构(顺序固定,精简为主)
1. **性能概览**(一句话用途 + 核心结论3~5 条要点,每条一句话)
2. **测试环境**CPU / OS / Runtime代码块 3~4 行即可)
3. **测试结果**BDN 原始表格,保留 Mean / Error / StdDev / Allocated
4. **结果分析**(见下方约束)
5. **瓶颈与优化建议**(见第 8 节,仅列有数据支撑的瓶颈)
### 7.1 结果分析约束
结果分析是对 BDN 数据的**提炼**,不是重新排列。遵循以下规则:
- **禁止重复制表**:不要把 BDN 数据换个单位(如 ops/s再列一张完整表格如需标注业务指标在正文中用"XXX 操作约 N M ops/s"一笔带过
- **对比用文字而非新表**:横向/纵向对比直接写结论("ArrayPacket 构造比 OwnerPacket 快 ~670xSlice 零分配"),不为每个对比维度单独建表
- **仅在对比维度 ≥3 且差异显著时**才建一张对比表,且最多一张
- **总量控制**:结果分析文字不超过 BDN 原始表格总行数的 1/3
### 7.2 篇幅控制
- 分析 + 瓶颈部分的文字行数 ≤ 数据表格行数(含表头)
- 无需为每个数字都单独解读;读者能从原表看出的趋势不必复述
## 8. 瓶颈与优化建议规范
### 8.1 撰写原则
- **数据驱动**:所有结论必须有 BDN 实测数据支撑,**禁止无数据臆测**,没有 profiler 数据时不编造 ns 级拆解
- **量化表达**:用"快 X 倍"、"省 Y%"、"降 Z B/op",避免"显著""明显"等模糊词
- **可操作**:指明具体修改位置和方案,不泛泛建议
- **宁少勿凑**:只列真正有影响的瓶颈(通常 1~3 个),不为凑数列 P3 级微小问题
### 8.2 瓶颈表格(唯一模板,合并展示)
用一张表汇总,不再拆分多张表:
```markdown
| 优先级 | 瓶颈 | 现象(实测数据) | 优化方向 | 预期收益 |
|--------|------|----------------|---------|---------|
| P0 | {名称} | {BDN 数据描述} | {方案} | {速度/内存预估} |
| P1 | {名称} | {BDN 数据描述} | {方案} | {速度/内存预估} |
```
- **P0**:影响核心吞吐或 >30% 性能损失,必须优化
- **P1**:影响扩展性或有明显内存压力,建议优化
- **P2**:次要瓶颈,可选优化(仅在确有数据支撑时列出)
- 同级按影响程度降序;无明显瓶颈时写"未发现显著瓶颈"即可
### 8.3 补充说明(可选)
表格之后可对 P0/P1 瓶颈各写 2~3 句补充根因和方案细节,**不要求也不鼓励**对每个瓶颈展开长篇分析。

@ -0,0 +1,324 @@
---
applyTo: "Doc/**"
---
# AI 辅助开发流程指令
适用于新建应用系统、新增功能模块、需求整理、架构设计等研发全流程任务。
---
## 1. 流程总览
```
需求整理 → 需求评审与拆分 → 技术方案设计 → 任务分解 → 迭代开发 → 集成验证 → 验收回顾
```
**核心原则**:大需求必须拆小,每个迭代交付可验证的最小功能单元。禁止"一次性全做完"。
---
## 2. 各阶段规范
### 2.1 需求整理
用户提供原始描述口语化、列表、草稿均可AI 整理为以下结构(需求 + 功能清单 + 验收 合为一个文件):
```markdown
# {项目/模块名}需求
## 1. 背景与目标
- 为什么做(痛点/动机)
- 做到什么程度算成功(可衡量目标)
## 2. 用户角色
| 角色 | 说明 | 核心诉求 |
|------|------|---------|
## 3. 功能需求
### 3.1 {功能模块名}
- **描述**:一句话说明
- **用户故事**:作为{角色},我希望{操作},以便{价值}
- **验收条件**AC
- [ ] 条件 1
- [ ] 条件 2
- **优先级**Must / Should / Could / Won't
## 4. 非功能需求
- 性能 / 安全 / 兼容性(三项必填)
## 5. 边界与约束
- 不做什么(明确排除项)
- 已知限制 / 技术债务
## 6. 功能清单与迭代计划
(需求评审拆分后填写,见 2.2
## 7. 验收记录
(开发完成后填写,见 2.7
## 8. 术语表
| 术语 | 定义 |
|------|------|
```
**规则**:每个功能必须有 AC无 AC 不可进入开发;优先级用 MoSCoW 四级;非功能需求至少覆盖性能、安全、兼容性。
### 2.2 需求评审与拆分
按**纵向切片**(端到端功能,非技术层)拆分,遵循 INVEST 原则,单个功能单元 ≤ 1-2 天工作量,有依赖须标注。
写入需求文档「6. 功能清单与迭代计划」:
```markdown
## 6. 功能清单与迭代计划
### 迭代 1{主题}Must 级别)
| 编号 | 功能点 | 验收条件 | 前置依赖 | 预估工作量 |
|------|--------|---------|---------|----------|
| F001 | xxx | AC1, AC2 | 无 | 0.5d |
| F002 | xxx | AC1 | F001 | 1d |
### 迭代 2{主题}Should 级别)
...
```
### 2.3 技术方案设计
```markdown
# {项目/模块名}架构
## 1. 架构概览
## 2. 数据模型
## 3. 接口设计
| 接口 | 方法 | 路径/签名 | 入参 | 出参 | 说明 |
|------|------|----------|------|------|------|
## 4. 技术选型
| 领域 | 选型 | 理由 |
|------|------|------|
## 5. 关键设计决策
| 决策点 | 方案 | 备选方案 | 选择理由 |
|--------|------|---------|---------|
## 6. 任务分解
(见 2.4
## 7. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
```
**规则**:优先使用 NewLife 已有组件XCode、Remoting、Stardust 等);数据模型考虑 XCode 实体规范;接口遵循现有 API 风格。
### 2.4 任务分解
单个任务 = 一次 AI 对话可完成的工作量(编码 + 测试 + 自测通过。写入技术方案「6. 任务分解」:
```markdown
### 任务 T001{动词 + 目标}
- **对应功能**F001
- **输入**:前置条件 / 已有代码
- **产出**:新增/修改哪些文件
- **验收**:怎样算完成
```
**批次编排**(用于自治模式,见第 6 节):按依赖关系编排为批次,每批次 5-8 个任务,同批次内尽量无相互依赖,基础设施任务排在前面,每批次结束设 `[检查点 N]`,标注本批次产出是下批次哪些输入。
### 2.5 迭代开发
流程:`理解任务 → 检索现有实现 → 编码 → 编译通过 → 测试通过 → 提交说明`
- 严格遵守主指令编码规范,每个任务必须编译通过
- 常规模式:遇歧义暂停确认;自治模式:记录跳过继续(见第 6 节)
- 有依赖按顺序执行,不跳跃
### 2.6 集成验证
全部编译通过 → 单元测试通过 → 端到端主流程走通 → 异常场景覆盖 → 性能符合预期
### 2.7 验收与回顾
对照需求文档逐条验收写入「7. 验收记录」:
```markdown
## 7. 验收记录
### 功能验收
| 编号 | 功能点 | 验收条件 | 状态 | 备注 |
|------|--------|---------|------|------|
### 遗留问题
| 问题 | 影响 | 后续计划 |
|------|------|---------|
### 经验总结
- 做得好的 / 待改进的
```
---
## 3. 文档存放规范
全流程仅产出 **2 个文档**,扁平存放在 `Doc/` 下:
| 文档 | 文件名 | 包含内容 |
|------|--------|--------|
| 需求文档 | `Doc/{项目名}需求.md` | 背景目标 + 功能需求 + 功能清单 + 验收记录 + 术语表 |
| 技术方案 | `Doc/{项目名}架构.md` | 架构 + 数据模型 + 接口 + 技术选型 + 任务分解 + 风险 |
UTF-8 无 BOM已有文件必须先读取再增量修改禁止覆盖各阶段产出追加到对应章节不新建文件。
---
## 4. AI 协作要点
### 4.1 阶段切换
| 用户说 | 进入阶段 |
|--------|---------|
| "整理需求"/"写需求" | 2.1 需求整理 |
| "拆分"/"拆解"/"排优先级" | 2.2 需求评审与拆分 |
| "技术方案"/"架构设计"/"怎么实现" | 2.3 技术方案设计 |
| "开始开发"/"写代码"/"实现 F001" | 2.5 迭代开发 |
| "全部搞完"/"批量开发"/"自治模式"/"一次性做完"/"继续处理"/"接着做" | 第 6 节自治批处理 |
| "验收"/"检查完成情况" | 2.7 验收与回顾 |
| 一大段描述未指定阶段 | 默认 2.1 需求整理 |
### 4.2 主动引导
每阶段完成后提示下一步:需求整理完 → 拆分? → 技术方案? → 任务分解 → 开发?
### 4.3 大需求防护
功能点 > 5 / 实体 > 3 / 跨 2 层以上 / 描述 > 500 字 → 必须先拆分再开发。
---
## 5. 常见反模式(禁止)
- ❌ 跳过需求直接编码
- ❌ 一次性输出所有代码(大需求必须拆迭代或使用自治模式)
- ❌ 需求文档没有验收条件
- ❌ 功能拆分按技术层而非用户价值
- ❌ 任务没有完成标准就开始编码
- ❌ 完成后不做验收对照
- ❌ 自治模式下遇阻塞问题死等用户(应记录跳过,继续后续)
- ❌ 自治模式下做需要人工决策的架构变更(应记录待确认,现有方案兜底)
- ❌ 跨批次不做编译验证
---
## 6. 自治批处理模式
架构师已确认需求和技术方案后AI 按任务清单自主执行,最小化人工介入。
### 6.1 进入条件(全部满足)
- [ ] 需求文档已完成且架构师已确认
- [ ] 技术方案已完成且架构师已确认
- [ ] 任务已分解并编排为批次
- [ ] 用户明确触发("全部搞完"/"批量开发"/"自治模式"等)
未满足时提示缺少哪些条件。
### 6.2 计划结构与循环刷新
AI 用 plan 工具创建层次化计划,「前置刷新 + 批次执行」循环:
```
1. [前置] 读取需求文档与技术方案
2. [前置] 读取任务清单与进度状态
3. [前置] 全量编译确认基线
4. [前置] 识别可并行的批次组
5. [批次1] 执行 T001-T005子步骤展开各任务
6. [检查点1] 输出批次1报告
7. [刷新] 重读需求文档与技术方案
8. [批次2] 执行 T006-T010
9. [检查点2] 输出批次2报告
...(循环:刷新 → 批次 → 检查点)
N-2. [后置] 全量编译与集成验证
N-1. [后置] 补完被跳过的任务
N. [后置] 生成验收报告
```
**要点**
- 主步骤 15-25 个(不超过 30子步骤展开具体任务仅供参考不单独追踪
- 刷新步骤穿插在每两个批次之间,`get_file` 重读文档对抗上下文漂移
- 用 `update_plan_progress` 跟踪主步骤,不为每个子任务调用
- 无依赖的批次可合并为一个主步骤执行,有依赖的必须顺序执行
### 6.3 执行协议
| 情况 | 处理方式 |
|------|----------|
| 任务明确无歧义 | 直接执行:编码 → 编译 → 测试 |
| 小歧义可合理推断 | 执行并在问题日志记录推断依据 |
| 重大歧义或多种等价方案 | 标记 `⏸️ 待确认`,跳过 |
| 前置任务被跳过 | 标记 `⏸️ 依赖阻塞T0xx`,跳过 |
| 编译失败短时间无法修复 | 回滚改动,记录并跳过 |
| 涉及公共 API / 架构变更 | 标记 `⏸️ 需架构师决策`,兜底或跳过 |
### 6.4 检查点报告
每批次完毕后输出:
```markdown
## 检查点 N 报告
### 完成情况
| 任务 | 状态 | 说明 |
|------|------|------|
| T001 | ✅ 完成 | |
| T003 | ⏸️ 跳过 | 需确认xxx |
### 编译状态
- 全量编译:✅ 通过 / ❌ 失败(错误详情)
### 问题日志
| 编号 | 类型 | 描述 | 影响任务 | 建议方案 |
|------|------|------|---------|----------|
### 统计
- 本批次 N 个,完成 X 个,跳过 Y 个
- 累计进度:已完成 X / 总计 ZXX%
- 上下文预估:{已处理任务数} / {建议上限}
```
### 6.5 用户回复与继续
架构师回来后AI 呈现检查点报告 → 架构师批量回复问题("Q001 OKQ002 选 A")→ AI 修正推断 + 执行跳过的任务 + 继续下批次 → 循环至完成。
触发词:"继续"/"继续处理"/"回复完了"/"接着做"
### 6.6 质量护栏(自动执行)
编译门禁(失败即修复或回滚)/ 命名与技术方案一致 / 编码规范严格遵守 / 新增代码前搜索现有实现避免重复 / 不擅自引入新 NuGet 包
### 6.7 会话边界处理
每个检查点后、连续完成 15+ 任务后、搜索结果不准确时 → 评估是否需要新会话。
**新会话续接模板**
```
我们在做 {项目名} 的自治批处理开发。
- 需求文档Doc/{项目名}需求.md
- 技术方案Doc/{项目名}架构.md
- 当前进度:批次 N 已完成,从批次 N+1 的 T0xx 开始继续
- 待解决问题:{问题编号}
请读取以上文档,从 T0xx 继续执行,自治模式。
```
上下文即将耗尽时 AI 主动提醒并生成上述模板。新会话前 4 步仍为前置刷新,已完成批次直接标记完成。
### 6.8 批次大小建议
| 复杂度 | 批次大小 |
|--------|---------|
| 简单CRUD | 8-10 |
| 中等(业务逻辑) | 5-7 |
| 复杂(算法、并发) | 3-5 |
单会话上限3-4 个批次(约 15-25 个任务)。
---
(完)

@ -0,0 +1,573 @@
---
applyTo: "**/Net/**"
---
# 网络编程指令
适用于基于 `NewLife.Net` 的网络服务器(`NetServer`)和客户端(`ISocketClient`)开发任务。
---
## 1. 架构概览
NewLife 网络框架分为两层:
| 层级 | 服务端 | 客户端 | 说明 |
|------|--------|--------|------|
| **应用层** | `NetServer` / `NetServer<TSession>` | — | 管理监听、会话生命周期、管道 |
| **传输层** | `TcpServer` / `UdpServer` | `TcpSession` / `UdpServer`(客户端模式) | 底层 Socket 收发 |
| **会话** | `NetSession` / `NetSession<TServer>` | — | 每个连接对应一个会话,业务逻辑入口 |
| **管道** | `IPipeline` + `IPipelineHandler` | 同左 | 编解码、粘包拆包、消息匹配 |
**关键接口**
- `ISocketClient` — 客户端连接接口Open/Close/Send/Receive
- `ISocketRemote` — 远程通信接口Send/Receive/SendMessageAsync
- `INetSession` — 网络会话接口(服务端每个连接的业务处理单元)
- `INetHandler` — 网络数据处理器接口Init/Process
---
## 2. 服务端开发规范
### 2.1 基本模式
推荐使用泛型 `NetServer<TSession>` + 自定义 `NetSession` 子类:
```csharp
/// <summary>自定义网络服务器</summary>
class MyServer : NetServer<MySession> { }
/// <summary>自定义会话,每个客户端连接对应一个实例</summary>
class MySession : NetSession<MyServer>
{
/// <summary>客户端连接</summary>
protected override void OnConnected()
{
base.OnConnected();
WriteLog("客户端已连接 {0}", Remote);
}
/// <summary>收到客户端数据</summary>
protected override void OnReceive(ReceivedEventArgs e)
{
base.OnReceive(e);
// 业务处理
}
/// <summary>客户端断开</summary>
protected override void OnDisconnected(String reason)
{
base.OnDisconnected(reason);
}
}
```
### 2.2 服务器启动配置
```csharp
var server = new MyServer
{
Port = 8080, // 监听端口0 表示随机
ProtocolType = NetType.Tcp, // Tcp/Udp/Unknown同时监听
// AddressFamily = AddressFamily.InterNetwork, // 仅IPv4默认同时IPv4+IPv6
ServiceProvider = provider, // 依赖注入
Log = XTrace.Log, // 应用日志
SessionLog = XTrace.Log, // 会话日志
Tracer = tracer, // APM 追踪
#if DEBUG
SocketLog = XTrace.Log, // Socket 层日志(仅调试)
LogSend = true,
LogReceive = true,
#endif
};
server.Start();
```
### 2.3 会话生命周期
```
连接建立 → OnConnected() → OnReceive()... → OnDisconnected(reason) → Dispose()
```
- **OnConnected**:初始化会话状态、发送欢迎消息
- **OnReceive**:核心业务处理入口,`e.Packet` 为原始数据,`e.Message` 为管道解码后的消息
- **OnDisconnected**:清理资源、记录日志,`reason` 包含断开原因
- 会话内可通过 `ServiceProvider` 获取 Scoped 服务
### 2.4 服务端发送数据
| 方法 | 说明 |
|------|------|
| `Send(IPacket)` | 直接发送原始数据,不经过管道 |
| `Send(String)` | 发送字符串,默认 UTF-8 |
| `Send(ReadOnlySpan<Byte>)` | 高性能发送 |
| `SendMessage(Object)` | 通过管道编码后发送,不等待响应 |
| `SendReply(Object, ReceivedEventArgs)` | 发送响应消息,与请求关联(用于 StandardCodec 等协议) |
| `SendMessageAsync(Object)` | 通过管道发送并等待响应 |
### 2.5 群发
```csharp
// 群发数据给所有在线客户端
await server.SendAllAsync(data);
// 带过滤条件群发
await server.SendAllAsync(data, session => session.ID > 100);
// 群发管道消息
server.SendAllMessage(message, session => session["VIP"] is true);
```
群发要求 `UseSession = true`(默认开启)。
### 2.6 事件模式(简单场景)
不需要自定义会话时,可直接使用事件:
```csharp
var server = new NetServer { Port = 8080 };
server.Received += (sender, e) =>
{
if (sender is INetSession session)
session.Send(e.Packet); // Echo
};
server.Start();
```
---
## 3. 客户端开发规范
### 3.1 创建客户端
通过 `NetUri.CreateRemote()` 扩展方法创建:
```csharp
// TCP 客户端
var client = new NetUri("tcp://127.0.0.1:8080").CreateRemote();
// UDP 客户端
var client = new NetUri("udp://127.0.0.1:8080").CreateRemote();
// WebSocket 客户端
var client = new NetUri("ws://127.0.0.1:8080/path").CreateRemote();
```
`CreateRemote` 根据协议自动返回 `TcpSession` / `UdpServer` / `WebSocketClient`
### 3.2 客户端使用
```csharp
var uri = new NetUri("tcp://127.0.0.1:8080");
var client = uri.CreateRemote();
client.Log = XTrace.Log;
client.Open();
// 发送原始数据(不经过管道)
client.Send("Hello");
// 事件驱动接收
client.Received += (sender, e) =>
{
// e.Packet 原始数据e.Message 管道解码后的消息
};
// 或同步/异步接收
using var pk = client.Receive();
using var pk = await client.ReceiveAsync(cancellationToken);
client.Close("完成"); // 或 client.Dispose()
```
### 3.3 请求-响应模式(需要管道编解码器)
```csharp
var client = new NetUri("tcp://127.0.0.1:8080").CreateRemote();
client.Add<StandardCodec>();
client.Open();
var response = await client.SendMessageAsync(payload, cancellationToken); // 等待响应
client.SendMessage(message); // 不等待响应
```
### 3.4 SSL/TLS
```csharp
// 服务端 SSL
var server = new NetServer
{
Port = 443,
SslProtocol = SslProtocols.Tls12,
Certificate = new X509Certificate2("server.pfx", "password"),
};
// 客户端 SSL自动根据端口判断或手动指定
var client = new NetUri("tcp://host:443").CreateRemote();
if (client is TcpSession tcp)
{
tcp.SslProtocol = SslProtocols.Tls12;
// tcp.Certificate = cert; // 客户端证书(如果服务端要求)
}
```
---
## 4. 管道与编解码器
### 4.1 管道机制
管道(`IPipeline`是处理器链Read/Write 返回值作为下一个处理器的输入,返回 `null` 截断管道:
```
接收Socket → [Codec1.Read] → [Codec2.Read] → FireRead → OnReceive
发送SendMessage → [Codec2.Write] → [Codec1.Write] → FireWrite → Socket
```
Open 正序传播Close 逆序传播。先添加的在底层(靠近 Socket后添加的在上层靠近业务
### 4.2 内置编解码器
| 编解码器 | 基类 | 说明 | 典型场景 |
|---------|------|------|---------|
| `StandardCodec` | `MessageCodec<IMessage>` | 4字节头部Flag+Seq+Length支持请求-响应匹配 | 自定义 RPC 协议 |
| `LengthFieldCodec` | `MessageCodec<IPacket>` | 长度字段头部,可配置偏移和大小 | MQTT、通用二进制协议 |
| `JsonCodec` | `Handler` | JSON 文本编解码,不处理粘包 | 文本协议(通常与 StandardCodec 级联) |
| `SplitDataCodec` | `Handler` | 分隔符拆包(默认 `\r\n` | 文本行协议 |
| `WebSocketCodec` | `Handler` | WebSocket 帧编解码 | WebSocket 通信 |
### 4.3 添加编解码器
```csharp
// 服务端添加
server.Add<StandardCodec>();
// 客户端添加
client.Add<StandardCodec>();
// 多层管道级联(按添加顺序组成链)
server.Add<StandardCodec>(); // 底层:粘包拆包 + 请求响应匹配
server.Add<JsonCodec>(); // 上层JSON 编解码
```
### 4.4 StandardCodec 请求-响应
StandardCodec 使用 `DefaultMessage`,包含 Flag1字节、Sequence1字节、Length2字节
支持自动序列号分配和请求-响应匹配。
```csharp
// 服务端 Echo 示例
server.Add<StandardCodec>();
server.Received += (sender, e) =>
{
if (sender is INetSession session && e.Message is IPacket pk)
session.SendReply(pk, e); // 使用 SendReply 关联请求上下文
};
// 客户端请求-响应
client.Add<StandardCodec>();
var response = await client.SendMessageAsync(payload);
```
### 4.5 基类选择
| 基类 | 适用场景 | 典型代表 |
|------|---------|---------|
| `MessageCodec<T>` | 需要粘包拆包和/或请求-响应匹配(内置 `IMatchQueue`、`Encode`/`Decode` | `StandardCodec`、`LengthFieldCodec` |
| `Handler` | 简单转换、帧协议、文本协议(轻量,仅 `Read`/`Write`/`Open`/`Close` | `JsonCodec`、`SplitDataCodec`、`WebSocketCodec` |
### 4.6 编解码器设计规范
#### 4.6.1 粘包拆包PacketCodec 模式)
TCP 是字节流协议,必须处理粘包拆包。统一模式(完整实现见 4.7 模板):
1. 每个连接独立的 `PacketCodec` 实例,存储在 `ss["Codec"]`
2. 通过 `GetLength2` 委托告诉 `PacketCodec` 如何计算完整帧长度
3. `PacketCodec.Parse()` 返回完整帧列表,自动缓存不完整数据
**`GetLength2` 规范**(签名 `Int32 GetLength(ReadOnlySpan<Byte> span)`):返回帧完整长度(含头部),数据不足时返回 `0`
```csharp
public static Int32 GetLength(ReadOnlySpan<Byte> span)
{
if (span.Length < 4) return 0;
var reader = new SpanReader(span) { IsLittleEndian = true };
reader.Advance(2);
return 4 + reader.ReadUInt16(); // 头部4字节 + 负载长度
}
```
#### 4.6.2 编码与内存管理
- **`ExpandHeader(size)`**:编码时优先复用负载缓冲区前置空间写入头部,零拷贝;空间不足时创建 `OwnerPacket`,原包作为 `Next` 链节点
- **`SpanWriter`**:配合 `ExpandHeader` 写入头部字段,注意 `IsLittleEndian` 大小端
- **兜底释放**`MessageCodec<T>.Write` 基类自动 `TryDispose``Handler` 子类需在 `Write``finally` 中手动调用
- **对象池**`DefaultMessage.Rent()` / `DefaultMessage.Return()` 减少 GC 压力
#### 4.6.3 请求-响应匹配
`MessageCodec<T>` 内置 `IMatchQueue`,流程:`Write` → `AddToQueue` 入队 → `Decode` 解码 → `Queue.Match``IsMatch` 匹配 → 唤醒 `SendMessageAsync``Task`
- 重载 `AddToQueue`:控制哪些消息入队(通常只有请求消息)
- 重载 `IsMatch`:根据序列号等字段匹配请求和响应(见 4.7 模板)
- `QueueSize`:匹配队列大小,默认 256
- `Timeout`:等待响应超时,默认 30_000ms
- `UserPacket`:为 `true` 时向上层传递 `Payload` 而非整个 `IMessage`,用于编码器级联
#### 4.6.4 Close 清理
**必须**在 `Close` 中执行 `ss["Codec"] = null` 清理 `PacketCodec`,否则 `MemoryStream` 缓存泄漏(见 4.7 模板)。
#### 4.6.5 上下文扩展IExtend
管道处理器通过 `IExtend` 在会话/上下文上传递元数据:
| 键 | 用途 | 示例 |
|---|------|------|
| `"Codec"` | 每连接的 `PacketCodec` 实例 | 编解码器的 `Decode`/`Close` 中读写 |
| `"Flag"` | 数据类型标记 `DataKinds` | `JsonCodec.Write` 设置 → `StandardCodec.Write` 消费 |
| `"_raw_message"` | 原始请求消息 | `MessageCodec.Read` 设置 → `Write` 中创建响应时消费 |
| `"TaskSource"` | `TaskCompletionSource` | 框架内部,`AddToQueue` 消费 |
#### 4.6.6 多层管道级联
- 底层编解码器处理粘包拆包和请求-响应匹配,上层处理数据格式转换
- `UserPacket = true` 让底层向上层传递 `Payload` 而非整个 `IMessage`
- 上层通过 `ext["Flag"]` 向底层传递数据类型标记
### 4.7 自定义编解码器模板
#### 方式一:继承 MessageCodec<T>(需要粘包/请求响应匹配)
```csharp
/// <summary>自定义协议编解码器</summary>
public class MyCodec : MessageCodec<MyMessage>
{
/// <summary>编码消息为数据包</summary>
protected override Object? Encode(IHandlerContext context, MyMessage msg)
{
return msg.ToPacket();
}
/// <summary>解码数据包为消息</summary>
protected override IEnumerable<MyMessage>? Decode(IHandlerContext context, IPacket pk)
{
if (context.Owner is not IExtend ss) yield break;
if (ss["Codec"] is not PacketCodec pc)
{
ss["Codec"] = pc = new PacketCodec
{
GetLength2 = MyMessage.GetLength,
MaxCache = MaxCache,
Tracer = (context.Owner as ISocket)?.Tracer
};
}
foreach (var item in pc.Parse(pk))
{
var msg = new MyMessage();
if (msg.Read(item)) yield return msg;
}
}
/// <summary>是否匹配响应</summary>
protected override Boolean IsMatch(Object? request, Object? response) =>
request is MyMessage req && response is MyMessage res
&& req.Sequence == res.Sequence;
/// <summary>连接关闭时清理</summary>
public override Boolean Close(IHandlerContext context, String reason)
{
if (context.Owner is IExtend ss) ss["Codec"] = null;
return base.Close(context, reason);
}
}
```
#### 方式二:继承 Handler简单转换/帧协议)
```csharp
/// <summary>自定义帧编解码器</summary>
public class MyFrameCodec : Handler
{
/// <summary>读取数据(接收时)</summary>
public override Object? Read(IHandlerContext context, Object message)
{
if (message is IPacket pk)
{
// 解码:二进制 → 业务对象
var frame = MyFrame.Parse(pk);
message = frame;
}
return base.Read(context, message);
}
/// <summary>写入数据(发送时)</summary>
public override Object? Write(IHandlerContext context, Object message)
{
IPacket? owner = null;
if (message is MyFrame frame)
{
// 编码:业务对象 → 二进制
message = owner = frame.ToPacket();
}
try
{
return base.Write(context, message);
}
finally
{
owner.TryDispose(); // 兜底释放
}
}
/// <summary>连接关闭时清理缓存</summary>
public override Boolean Close(IHandlerContext context, String reason)
{
if (context.Owner is IExtend ss) ss["Codec"] = null;
return base.Close(context, reason);
}
}
```
---
## 5. 常见模式与最佳实践
### 5.1 端口选择
- 测试代码使用端口 `0`(系统自动分配随机端口),避免端口冲突
- 正式服务指定固定端口
- 启动后可通过 `server.Port` 获取实际监听端口
### 5.2 协议选择
| 场景 | 推荐 |
|------|------|
| 可靠传输、长连接 | `NetType.Tcp` |
| 低延迟、广播、允许丢包 | `NetType.Udp` |
| 同时支持(默认) | `NetType.Unknown` |
| Web 浏览器通信 | `NetType.WebSocket` |
### 5.3 会话管理
- `UseSession = true`(默认):维护会话集合,支持群发、按 ID 查找
- `UseSession = false`:不维护会话集合,减少内存开销,适合海量短连接
- `SessionTimeout`:设置会话超时时间(秒),超时无数据自动断开
- 会话中通过 `Items` 字典存储自定义数据
### 5.4 日志分层
| 属性 | 用途 | 建议 |
|------|------|------|
| `Log` | 服务器应用层日志 | 始终设置 |
| `SessionLog` | 会话级别日志 | 调试时设置 |
| `SocketLog` | 底层 Socket 日志 | 仅 DEBUG 时设置 |
| `LogSend` / `LogReceive` | 收发数据内容日志 | 仅 DEBUG 时开启 |
| `Tracer` | 应用层 APM | 生产环境追踪 |
| `SocketTracer` | Socket 层 APM | 排查底层问题 |
### 5.5 资源释放
- 服务端:调用 `server.Stop(reason)``server.Dispose()`
- 客户端:调用 `client.Close(reason)``client.Dispose()`
- 会话自动随连接断开释放,无需手动管理
- `ISocketClient` 实现 `IDisposable`,推荐 `using` 模式
### 5.6 INetHandler 业务处理器
通过重载 `NetServer.CreateHandler` 注入自定义业务处理器:
```csharp
class MyServer : NetServer<MySession>
{
/// <summary>为会话创建网络数据处理器</summary>
public override INetHandler? CreateHandler(INetSession session) => new MyHandler();
}
```
处理器在会话 `Start` 时初始化,`OnReceive` 前调用 `Process`,适合前置协议解析。
---
## 6. 常见错误
- ❌ 在 `OnReceive` 中执行长时间阻塞操作(会影响其他连接的数据接收)
- ❌ 不加管道编解码器直接调用 `SendMessageAsync`(无法匹配响应)
- ❌ 混淆 `Send``SendMessage`:前者直接发原始数据,后者经过管道编码
- ❌ 混淆 `SendMessage``SendReply`:响应消息必须用 `SendReply` 关联请求上下文
- ❌ 忘记调用 `base.OnConnected()` / `base.OnDisconnected(reason)` / `base.OnReceive(e)`
- ❌ 在会话中使用 `Task.Result``Task.Wait()`(导致死锁和线程池饥饿)
- ❌ 使用固定端口编写测试(端口冲突),应使用 `Port = 0`
- ❌ 服务端 SSL 未指定证书
---
## 7. 完整示例
### 7.1 带 StandardCodec 的 Echo 服务
```csharp
// 服务端
var server = new NetServer
{
Port = 8080,
ProtocolType = NetType.Tcp,
Log = XTrace.Log,
};
server.Add<StandardCodec>();
server.Received += (sender, e) =>
{
if (sender is INetSession session && e.Message is IPacket pk)
session.SendReply(pk, e);
};
server.Start();
// 客户端
var client = new NetUri($"tcp://127.0.0.1:{server.Port}").CreateRemote();
client.Add<StandardCodec>();
client.Open();
var response = await client.SendMessageAsync(new ArrayPacket("Hello".GetBytes()));
```
### 7.2 自定义会话服务器
```csharp
class ChatServer : NetServer<ChatSession> { }
class ChatSession : NetSession<ChatServer>
{
protected override void OnConnected()
{
base.OnConnected();
Send($"欢迎 [{Remote}] 进入聊天室!\r\n");
}
protected override void OnReceive(ReceivedEventArgs e)
{
base.OnReceive(e);
var msg = e.Packet?.ToStr();
if (msg.IsNullOrEmpty()) return;
// 广播给所有在线用户
var host = (this as INetSession).Host;
host.SendAllMessage($"[{ID}] {msg}");
}
protected override void OnDisconnected(String reason)
{
base.OnDisconnected(reason);
WriteLog("用户离开:{0}", reason);
}
}
```
---
(完)

@ -0,0 +1,3 @@
<Solution>
<Project Path="WpfNodeTest/WpfNodeTest.csproj" />
</Solution>

@ -0,0 +1,9 @@
<Application x:Class="WpfNodeTest.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfNodeTest"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

@ -0,0 +1,53 @@
using NewLife.Log;
using NodeNetwork;
using System.Configuration;
using System.Data;
using System.Windows;
namespace WpfNodeTest
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
public App()
{
XTrace.UseConsole();
}
//protected override void OnStartup(StartupEventArgs e)
//{
// base.OnStartup(e);
// NNViewRegistrar.RegisterSplat();
//}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
NNViewRegistrar.RegisterSplat(); // 注册NodeNetwork视图
XTrace.WriteLine("1.OnStartup被触发");
}
protected override void OnActivated(EventArgs e)
{
base.OnActivated(e);
XTrace.WriteLine("2.OnActivated被触发");
}
protected override void OnDeactivated(EventArgs e)
{
base.OnDeactivated(e);
XTrace.WriteLine("3.OnDeactivated被触发");
}
protected override void OnExit(ExitEventArgs e)
{
base.OnExit(e);
XTrace.WriteLine("4.OnExit被触发");
}
}
}

@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

@ -0,0 +1,34 @@
{
"NodeTypes": [
{
"TypeName": "提升机",
"DefaultInputCount": 1,
"DefaultOutputCount": 1,
"Description": "垂直运输设备"
},
{
"TypeName": "电梯",
"DefaultInputCount": 1,
"DefaultOutputCount": 1,
"Description": "楼层间运输"
},
{
"TypeName": "工位",
"DefaultInputCount": 1,
"DefaultOutputCount": 0,
"Description": "作业工位"
},
{
"TypeName": "物流接驳位",
"DefaultInputCount": 2,
"DefaultOutputCount": 2,
"Description": "物流中转点"
},
{
"TypeName": "库位",
"DefaultInputCount": 1,
"DefaultOutputCount": 0,
"Description": "存储位置"
}
]
}

@ -0,0 +1,84 @@
<Window x:Class="WpfNodeTest.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfNodeTest"
xmlns:nodenetwork="clr-namespace:NodeNetwork.Views;assembly=NodeNetwork"
xmlns:views="clr-namespace:WpfNodeTest.Views"
mc:Ignorable="d"
Title="MainWindow" Height="720" Width="1280">
<!--基GRID-->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!--顶边功能区-->
<Grid Grid.Row="0" Background="#2C2C2C">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0">
<TextBlock Text="AGV接驳位编辑器" FontSize="16" FontWeight="Bold" Foreground="White" VerticalAlignment="Center" Margin="0,0,20,0"/>
<Button Content="加载" Command="{Binding LoadFileCommand}" Width="80" Height="30" Margin="5,0"/>
<Button Content="保存" Command="{Binding SaveFileCommand}" Width="80" Height="30" Margin="5,0"/>
</StackPanel>
</Grid>
<!--顶边功能区-->
<!--工作区-->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!--侧边栏-->
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="400"/>
</Grid.RowDefinitions>
<!--控件库-->
<Grid Grid.Row="0" Background="#F5F5F5">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="10">
<TextBlock Text="节点类型(拖拽到工作区)" FontWeight="Bold" FontSize="14" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding NodeTypeInfos}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<views:NodeTemplateControl />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Grid>
<!--控件库-->
<!--属性栏-->
<Grid Grid.Row="1" Background="#F5F5F5">
<StackPanel Margin="10" DataContext="{Binding SelectedNode}">
<TextBlock Text="节点属性" FontWeight="Bold" FontSize="14" Margin="0,0,0,10"/>
<TextBlock Text="节点名称:" Margin="0,5,0,2"/>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" Height="25"/>
<TextBlock Text="节点类型:" Margin="0,10,0,2"/>
<TextBox Text="{Binding NodeType, UpdateSourceTrigger=PropertyChanged}" Height="25"/>
<TextBlock Text="输入端口数:" Margin="0,10,0,2"/>
<TextBox Text="{Binding InputCount, UpdateSourceTrigger=PropertyChanged}" Height="25"/>
<TextBlock Text="输出端口数:" Margin="0,10,0,2"/>
<TextBox Text="{Binding OutputCount, UpdateSourceTrigger=PropertyChanged}" Height="25"/>
</StackPanel>
</Grid>
<!--属性栏-->
</Grid>
<!--侧边栏-->
<!--编辑器-->
<Grid Grid.Column="1">
<nodenetwork:NetworkView x:Name="networkView" ViewModel="{Binding Network}" AllowDrop="True" DragOver="NetworkView_DragOver" Drop="NetworkView_Drop" />
</Grid>
<!--编辑器-->
</Grid>
<!--工作区-->
</Grid>
<!--基GRID-->
</Window>

@ -0,0 +1,62 @@
using System.Windows;
using WpfNodeTest.Models;
using WpfNodeTest.ViewModels;
namespace WpfNodeTest
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainWindowViewModel();
}
// 处理拖拽悬停事件,允许拖放
private void NetworkView_DragOver(object sender, DragEventArgs e)
{
System.Diagnostics.Debug.WriteLine("DragOver事件触发");
if (e.Data.GetDataPresent(typeof(NodeTypeInfo)))
{
System.Diagnostics.Debug.WriteLine("检测到NodeTypeInfo数据");
e.Effects = DragDropEffects.Copy;
}
else
{
System.Diagnostics.Debug.WriteLine("未检测到NodeTypeInfo数据");
e.Effects = DragDropEffects.None;
}
e.Handled = true;
}
// 处理拖放事件,在工作区创建新节点
private void NetworkView_Drop(object sender, DragEventArgs e)
{
System.Diagnostics.Debug.WriteLine("Drop事件触发");
if (e.Data.GetData(typeof(NodeTypeInfo)) is NodeTypeInfo nodeType && DataContext is MainWindowViewModel vm)
{
System.Diagnostics.Debug.WriteLine($"创建节点: {nodeType.TypeName}");
var dropPosition = e.GetPosition(networkView);
var node = new AGVDockingNode
{
Name = nodeType.TypeName,
NodeType = nodeType.TypeName,
InputCount = nodeType.DefaultInputCount,
OutputCount = nodeType.DefaultOutputCount,
Position = new Point(dropPosition.X, dropPosition.Y)
};
vm.Network.Nodes.Edit(list => list.Add(node));
System.Diagnostics.Debug.WriteLine($"节点已添加到位置: ({dropPosition.X}, {dropPosition.Y})");
}
else
{
System.Diagnostics.Debug.WriteLine("Drop失败: 数据类型不匹配或ViewModel为空");
}
}
}
}

@ -0,0 +1,92 @@
using NodeNetwork.ViewModels;
using NodeNetwork.Views;
using ReactiveUI;
namespace WpfNodeTest.Models;
/// <summary>
/// AGV接驳位节点模型
/// </summary>
public class AGVDockingNode : NodeViewModel
{
// 静态构造函数:注册节点视图
static AGVDockingNode()
{
Splat.Locator.CurrentMutable.Register(() => new NodeView(), typeof(IViewFor<AGVDockingNode>));
}
// 节点唯一标识
private string _nodeId = Guid.NewGuid().ToString();
public string NodeId
{
get => _nodeId;
set => this.RaiseAndSetIfChanged(ref _nodeId, value);
}
// 节点类型(提升机、电梯、工位、物流接驳位、库位等)
private string _nodeType = "默认";
public string NodeType
{
get => _nodeType;
set => this.RaiseAndSetIfChanged(ref _nodeType, value);
}
// 输入端口数量
private int _inputCount = 1;
public int InputCount
{
get => _inputCount;
set
{
this.RaiseAndSetIfChanged(ref _inputCount, value);
UpdatePorts();
}
}
// 输出端口数量
private int _outputCount = 1;
public int OutputCount
{
get => _outputCount;
set
{
this.RaiseAndSetIfChanged(ref _outputCount, value);
UpdatePorts();
}
}
public AGVDockingNode()
{
Name = "接驳位";
UpdatePorts();
}
// 根据输入输出数量更新端口
private void UpdatePorts()
{
Inputs.Edit(list =>
{
list.Clear();
for (int i = 0; i < InputCount; i++)
{
list.Add(new NodeInputViewModel
{
Name = $"入口{i + 1}",
MaxConnections = int.MaxValue // 允许多个连接
});
}
});
Outputs.Edit(list =>
{
list.Clear();
for (int i = 0; i < OutputCount; i++)
{
list.Add(new NodeOutputViewModel
{
Name = $"出口{i + 1}",
MaxConnections = int.MaxValue // 允许多个连接
});
}
});
}
}

@ -0,0 +1,37 @@
namespace WpfNodeTest.Models;
/// <summary>
/// 节点图数据模型,用于序列化和反序列化
/// </summary>
public class NodeGraphData
{
public List<NodeData> Nodes { get; set; } = new();
public List<ConnectionData> Connections { get; set; } = new();
}
/// <summary>
/// 节点数据
/// </summary>
public class NodeData
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public double X { get; set; }
public double Y { get; set; }
public int InputCount { get; set; }
public int OutputCount { get; set; }
}
/// <summary>
/// 连接数据
/// </summary>
public class ConnectionData
{
public string Id { get; set; } = string.Empty;
public string SourceNodeId { get; set; } = string.Empty;
public int SourcePortIndex { get; set; }
public string TargetNodeId { get; set; } = string.Empty;
public int TargetPortIndex { get; set; }
public double Weight { get; set; } = 1.0; // 权重(耗时),用于路径规划
}

@ -0,0 +1,20 @@
namespace WpfNodeTest.Models;
/// <summary>
/// 节点类型配置
/// </summary>
public class NodeTypeConfig
{
public List<NodeTypeInfo> NodeTypes { get; set; } = new();
}
/// <summary>
/// 节点类型信息
/// </summary>
public class NodeTypeInfo
{
public string TypeName { get; set; } = string.Empty; // 类型名称
public int DefaultInputCount { get; set; } = 1; // 默认输入端口数
public int DefaultOutputCount { get; set; } = 1; // 默认输出端口数
public string Description { get; set; } = string.Empty; // 描述
}

@ -0,0 +1,121 @@
using System.IO;
using System.Text.Json;
using NodeNetwork.ViewModels;
using WpfNodeTest.Models;
namespace WpfNodeTest.Services;
/// <summary>
/// 节点图序列化服务
/// </summary>
public class NodeGraphSerializer
{
/// <summary>
/// 保存节点图到JSON文件
/// </summary>
public void SaveToFile(NetworkViewModel network, string filePath)
{
var graphData = new NodeGraphData();
// 保存节点数据
foreach (var node in network.Nodes.Items)
{
if (node is AGVDockingNode dockingNode)
{
graphData.Nodes.Add(new NodeData
{
Id = dockingNode.NodeId,
Name = dockingNode.Name,
Type = dockingNode.NodeType,
X = dockingNode.Position.X,
Y = dockingNode.Position.Y,
InputCount = dockingNode.InputCount,
OutputCount = dockingNode.OutputCount
});
}
}
// 保存连接数据
foreach (var conn in network.Connections.Items)
{
var sourceNode = conn.Output?.Parent as AGVDockingNode;
var targetNode = conn.Input?.Parent as AGVDockingNode;
if (sourceNode != null && targetNode != null && conn.Output != null && conn.Input != null)
{
var sourceOutputs = sourceNode.Outputs.Items.ToList();
var targetInputs = targetNode.Inputs.Items.ToList();
graphData.Connections.Add(new ConnectionData
{
Id = Guid.NewGuid().ToString(),
SourceNodeId = sourceNode.NodeId,
SourcePortIndex = sourceOutputs.IndexOf(conn.Output),
TargetNodeId = targetNode.NodeId,
TargetPortIndex = targetInputs.IndexOf(conn.Input),
Weight = 1.0
});
}
}
var json = JsonSerializer.Serialize(graphData, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
/// <summary>
/// 从JSON文件加载节点图
/// </summary>
public NetworkViewModel LoadFromFile(string filePath)
{
var json = File.ReadAllText(filePath);
var graphData = JsonSerializer.Deserialize<NodeGraphData>(json) ?? new NodeGraphData();
var network = new NetworkViewModel();
var nodeDict = new Dictionary<string, AGVDockingNode>();
// 加载节点
foreach (var nodeData in graphData.Nodes)
{
var node = new AGVDockingNode
{
NodeId = nodeData.Id,
Name = nodeData.Name,
NodeType = nodeData.Type,
InputCount = nodeData.InputCount,
OutputCount = nodeData.OutputCount,
Position = new System.Windows.Point(nodeData.X, nodeData.Y)
};
network.Nodes.Edit(list => list.Add(node));
nodeDict[nodeData.Id] = node;
}
// 加载连接
foreach (var connData in graphData.Connections)
{
if (nodeDict.TryGetValue(connData.SourceNodeId, out var sourceNode) &&
nodeDict.TryGetValue(connData.TargetNodeId, out var targetNode))
{
var output = sourceNode.Outputs.Items.ElementAtOrDefault(connData.SourcePortIndex);
var input = targetNode.Inputs.Items.ElementAtOrDefault(connData.TargetPortIndex);
if (output != null && input != null)
{
var connection = new ConnectionViewModel(network, input, output);
network.Connections.Edit(list => list.Add(connection));
}
}
}
return network;
}
/// <summary>
/// 从JSON文件加载为数据模型供算法使用
/// </summary>
public NodeGraphData LoadDataFromFile(string filePath)
{
var json = File.ReadAllText(filePath);
return JsonSerializer.Deserialize<NodeGraphData>(json) ?? new NodeGraphData();
}
}

@ -0,0 +1,79 @@
using System.IO;
using System.Text.Json;
using WpfNodeTest.Models;
namespace WpfNodeTest.Services;
/// <summary>
/// 节点类型配置服务
/// </summary>
public class NodeTypeConfigService
{
private NodeTypeConfig? _config;
private readonly string _configPath = "Config/NodeTypes.json";
/// <summary>
/// 加载节点类型配置
/// </summary>
public NodeTypeConfig LoadConfig()
{
if (_config != null) return _config;
try
{
if (!File.Exists(_configPath))
{
// 配置文件不存在,返回默认配置
_config = CreateDefaultConfig();
return _config;
}
var json = File.ReadAllText(_configPath);
_config = JsonSerializer.Deserialize<NodeTypeConfig>(json) ?? CreateDefaultConfig();
return _config;
}
catch (Exception ex)
{
// 加载失败,返回默认配置
System.Diagnostics.Debug.WriteLine($"加载配置文件失败: {ex.Message}");
_config = CreateDefaultConfig();
return _config;
}
}
/// <summary>
/// 创建默认配置
/// </summary>
private NodeTypeConfig CreateDefaultConfig()
{
return new NodeTypeConfig
{
NodeTypes = new List<NodeTypeInfo>
{
new NodeTypeInfo { TypeName = "提升机", DefaultInputCount = 1, DefaultOutputCount = 1, Description = "垂直运输设备" },
new NodeTypeInfo { TypeName = "电梯", DefaultInputCount = 1, DefaultOutputCount = 1, Description = "楼层间运输" },
new NodeTypeInfo { TypeName = "工位", DefaultInputCount = 1, DefaultOutputCount = 0, Description = "作业工位" },
new NodeTypeInfo { TypeName = "物流接驳位", DefaultInputCount = 1, DefaultOutputCount = 1, Description = "物流中转点" },
new NodeTypeInfo { TypeName = "库位", DefaultInputCount = 1, DefaultOutputCount = 0, Description = "存储位置" }
}
};
}
/// <summary>
/// 获取所有节点类型名称
/// </summary>
public List<string> GetNodeTypeNames()
{
var config = LoadConfig();
return config.NodeTypes.Select(t => t.TypeName).ToList();
}
/// <summary>
/// 根据类型名称获取节点类型信息
/// </summary>
public NodeTypeInfo? GetNodeTypeInfo(string typeName)
{
var config = LoadConfig();
return config.NodeTypes.FirstOrDefault(t => t.TypeName == typeName);
}
}

@ -0,0 +1,110 @@
using WpfNodeTest.Models;
namespace WpfNodeTest.Services;
/// <summary>
/// 路径规划服务使用Dijkstra算法
/// </summary>
public class PathPlanningService
{
/// <summary>
/// 规划从起点到终点的路径
/// </summary>
/// <param name="graphData">节点图数据</param>
/// <param name="startNodeId">起点节点ID</param>
/// <param name="endNodeId">终点节点ID</param>
/// <returns>路径节点ID列表</returns>
public List<string> PlanPath(NodeGraphData graphData, string startNodeId, string endNodeId)
{
// 构建邻接表(双向图)
var adjacency = BuildAdjacency(graphData);
// Dijkstra算法
var distances = new Dictionary<string, double>();
var previous = new Dictionary<string, string>();
var unvisited = new HashSet<string>();
foreach (var node in graphData.Nodes)
{
distances[node.Id] = double.MaxValue;
unvisited.Add(node.Id);
}
distances[startNodeId] = 0;
while (unvisited.Count > 0)
{
var current = unvisited.OrderBy(n => distances[n]).First();
unvisited.Remove(current);
if (current == endNodeId) break;
if (adjacency.TryGetValue(current, out var neighbors))
{
foreach (var (neighbor, weight) in neighbors)
{
if (!unvisited.Contains(neighbor)) continue;
var alt = distances[current] + weight;
if (alt < distances[neighbor])
{
distances[neighbor] = alt;
previous[neighbor] = current;
}
}
}
}
// 回溯路径
var path = new List<string>();
var step = endNodeId;
while (previous.ContainsKey(step))
{
path.Add(step);
step = previous[step];
}
path.Add(startNodeId);
path.Reverse();
return path;
}
/// <summary>
/// 构建邻接表(双向图)
/// </summary>
private Dictionary<string, List<(string nodeId, double weight)>> BuildAdjacency(NodeGraphData graphData)
{
var adjacency = new Dictionary<string, List<(string, double)>>();
foreach (var node in graphData.Nodes)
{
adjacency[node.Id] = new List<(string, double)>();
}
foreach (var conn in graphData.Connections)
{
// 双向连接
adjacency[conn.SourceNodeId].Add((conn.TargetNodeId, conn.Weight));
adjacency[conn.TargetNodeId].Add((conn.SourceNodeId, conn.Weight));
}
return adjacency;
}
/// <summary>
/// 将路径拆分为子任务
/// </summary>
/// <param name="path">路径节点ID列表</param>
/// <param name="graphData">节点图数据</param>
/// <returns>子任务列表(起点->终点)</returns>
public List<(string from, string to)> SplitToSubTasks(List<string> path, NodeGraphData graphData)
{
var subTasks = new List<(string, string)>();
for (int i = 0; i < path.Count - 1; i++)
{
subTasks.Add((path[i], path[i + 1]));
}
return subTasks;
}
}

@ -0,0 +1,106 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using NodeNetwork.ViewModels;
using System.Collections.ObjectModel;
using System.Reactive.Linq;
using WpfNodeTest.Models;
using WpfNodeTest.Services;
namespace WpfNodeTest.ViewModels;
/// <summary>
/// 主窗口ViewModel
/// </summary>
public partial class MainWindowViewModel : ObservableObject
{
private readonly NodeGraphSerializer _serializer = new();
private readonly NodeTypeConfigService _configService = new();
private readonly PathPlanningService _pathPlanning = new();
[ObservableProperty]
private NetworkViewModel _network = new();
[ObservableProperty]
private ObservableCollection<string> _nodeTypes = new();
[ObservableProperty]
private ObservableCollection<NodeTypeInfo> _nodeTypeInfos = new();
[ObservableProperty]
private AGVDockingNode? _selectedNode;
public MainWindowViewModel()
{
LoadNodeTypes();
// 监听节点选择变化
Network.SelectedNodes.Connect().Subscribe(_ =>
{
SelectedNode = Network.SelectedNodes.Items.FirstOrDefault() as AGVDockingNode;
});
}
// 加载节点类型列表
private void LoadNodeTypes()
{
var config = _configService.LoadConfig();
NodeTypes = new ObservableCollection<string>(config.NodeTypes.Select(t => t.TypeName));
NodeTypeInfos = new ObservableCollection<NodeTypeInfo>(config.NodeTypes);
}
/// <summary>
/// 加载节点图文件
/// </summary>
[RelayCommand]
private void LoadFile()
{
var dialog = new OpenFileDialog
{
Filter = "JSON文件|*.json",
Title = "加载节点图"
};
if (dialog.ShowDialog() == true)
{
Network = _serializer.LoadFromFile(dialog.FileName);
}
}
/// <summary>
/// 保存节点图文件
/// </summary>
[RelayCommand]
private void SaveFile()
{
var dialog = new SaveFileDialog
{
Filter = "JSON文件|*.json",
Title = "保存节点图"
};
if (dialog.ShowDialog() == true)
{
_serializer.SaveToFile(Network, dialog.FileName);
}
}
/// <summary>
/// 添加新节点
/// </summary>
[RelayCommand]
private void AddNode(string nodeType)
{
var typeInfo = _configService.GetNodeTypeInfo(nodeType);
var node = new AGVDockingNode
{
Name = nodeType,
NodeType = nodeType,
InputCount = typeInfo?.DefaultInputCount ?? 1,
OutputCount = typeInfo?.DefaultOutputCount ?? 1,
Position = new System.Windows.Point(100, 100)
};
Network.Nodes.Edit(list => list.Add(node));
}
}

@ -0,0 +1,11 @@
<UserControl x:Class="WpfNodeTest.Views.NodeTemplateControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="250" Height="60" Margin="5">
<Border Background="#E0E0E0" BorderBrush="#999" BorderThickness="1" CornerRadius="5" Cursor="Hand">
<StackPanel Margin="10" VerticalAlignment="Center">
<TextBlock Text="{Binding TypeName}" FontWeight="Bold" FontSize="12"/>
<TextBlock Text="{Binding Description}" FontSize="10" Foreground="#666" Margin="0,2,0,0"/>
</StackPanel>
</Border>
</UserControl>

@ -0,0 +1,57 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using WpfNodeTest.Models;
namespace WpfNodeTest.Views
{
/// <summary>
/// 节点模板控件
/// </summary>
public partial class NodeTemplateControl : UserControl
{
private Point _startPoint;
private bool _isDragging;
public NodeTemplateControl()
{
InitializeComponent();
PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown;
PreviewMouseMove += OnPreviewMouseMove;
}
// 记录鼠标按下位置
private void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_startPoint = e.GetPosition(null);
_isDragging = false;
}
// 鼠标移动时启动拖放
private void OnPreviewMouseMove(object sender, MouseEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed && !_isDragging)
{
Point currentPosition = e.GetPosition(null);
Vector diff = _startPoint - currentPosition;
// 移动超过阈值才启动拖拽
if (Math.Abs(diff.X) > SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(diff.Y) > SystemParameters.MinimumVerticalDragDistance)
{
_isDragging = true;
if (DataContext is NodeTypeInfo nodeType)
{
System.Diagnostics.Debug.WriteLine($"启动拖拽: {nodeType.TypeName}");
DragDrop.DoDragDrop(this, nodeType, DragDropEffects.Copy);
}
else
{
System.Diagnostics.Debug.WriteLine("DataContext不是NodeTypeInfo类型");
}
}
}
}
}
}

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>False</UseWindowsForms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="NewLife.Core" Version="11.13.2026.308-beta0508" />
<PackageReference Include="NodeNetwork" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="Config\NodeTypes.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

@ -0,0 +1,66 @@
{
"Nodes": [
{
"Id": "node-1",
"Name": "工位A",
"Type": "工位",
"X": 100,
"Y": 100,
"InputCount": 1,
"OutputCount": 1
},
{
"Id": "node-2",
"Name": "提升机B",
"Type": "提升机",
"X": 300,
"Y": 100,
"InputCount": 2,
"OutputCount": 2
},
{
"Id": "node-3",
"Name": "接驳位C",
"Type": "物流接驳位",
"X": 500,
"Y": 100,
"InputCount": 2,
"OutputCount": 2
},
{
"Id": "node-4",
"Name": "库位D",
"Type": "库位",
"X": 700,
"Y": 100,
"InputCount": 1,
"OutputCount": 1
}
],
"Connections": [
{
"Id": "conn-1",
"SourceNodeId": "node-1",
"SourcePortIndex": 0,
"TargetNodeId": "node-2",
"TargetPortIndex": 0,
"Weight": 1.0
},
{
"Id": "conn-2",
"SourceNodeId": "node-2",
"SourcePortIndex": 0,
"TargetNodeId": "node-3",
"TargetPortIndex": 0,
"Weight": 1.0
},
{
"Id": "conn-3",
"SourceNodeId": "node-3",
"SourcePortIndex": 0,
"TargetNodeId": "node-4",
"TargetPortIndex": 0,
"Weight": 1.0
}
]
}
Loading…
Cancel
Save