diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..433e36a
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,20 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git:*)",
+ "Bash(printf '\\\\n---\\\\n')",
+ "Bash(dotnet new:*)",
+ "Bash(dotnet sln:*)",
+ "Bash(dotnet build:*)",
+ "Bash(dotnet add:*)",
+ "Bash(powershell -NoProfile -Command \"[Reflection.Assembly]::LoadFrom\\('C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll'\\).GetTypes\\(\\) | Where-Object { $_.Name -like '*Modbus*' -or $_.FullName -like '*Modbus*' } | Select-Object -ExpandProperty FullName\")",
+ "Bash(powershell -NoProfile -Command '[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\).GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\).GetMethods\\(\\) | Where-Object { $_.Name -in @\\(\"Connect\",\"ReadDiscrete\",\"Write\",\"ReadUInt16\"\\) } | ForEach-Object { $_.ToString\\(\\) }')",
+ "Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetMethods\\([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::Public -bor [System.Reflection.BindingFlags]::DeclaredOnly\\) | ForEach-Object { $_.ToString\\(\\) }')",
+ "Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetInterfaces\\(\\) | ForEach-Object { $_.FullName }')",
+ "Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); \"BASE: $\\($type.BaseType.FullName\\)\"; $type.GetMethods\\([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::Public\\) | Where-Object { $_.Name -match \"Connect|Close|Dispose|Disconnect|Open|Start|Stop|Release\" } | ForEach-Object { $_.DeclaringType.FullName + \" :: \" + $_.ToString\\(\\) }')",
+ "Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetConstructors\\(\\) | ForEach-Object { $_.ToString\\(\\) }')",
+ "Read(//d D:/Dev/Codes/MFD_Solution/Axi_Omron/src/**)",
+ "Bash(dotnet restore:*)"
+ ]
+ }
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..77086e6
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,58 @@
+
+[*.{c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cu,cuh,cxx,cxxm,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,ixx,mpp,mq4,mq5,mqh,mxx,tpp,usf,ush}]
+indent_style = tab
+indent_size = tab
+tab_width = 4
+
+[*.{asax,ascx,aspx,axaml,cshtml,htm,html,master,paml,razor,skin,vb,xaml,xamlx,xoml}]
+indent_style = space
+indent_size = 4
+tab_width = 4
+
+[*.{appxmanifest,axml,build,config,csproj,dbml,discomap,dtd,jsproj,lsproj,njsproj,nuspec,proj,props,resw,resx,StyleCop,targets,tasks,vbproj,xml,xsd}]
+indent_style = space
+indent_size = 2
+tab_width = 2
+
+[*.cs]
+indent_style = space
+indent_size = 4
+tab_width = 4
+resharper_csharp_keep_multiple_properties_on_single_line = false
+resharper_csharp_keep_multiple_accessors_on_single_line = false
+[*]
+
+# Microsoft .NET properties
+csharp_new_line_before_members_in_object_initializers = false
+csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+dotnet_style_qualification_for_event = false:suggestion
+dotnet_style_qualification_for_field = false:suggestion
+dotnet_style_qualification_for_method = false:suggestion
+dotnet_style_qualification_for_property = false:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
+
+# ReSharper inspection severities
+resharper_arrange_redundant_parentheses_highlighting = hint
+resharper_arrange_this_qualifier_highlighting = hint
+resharper_arrange_type_member_modifiers_highlighting = hint
+resharper_arrange_type_modifiers_highlighting = hint
+resharper_built_in_type_reference_style_for_member_access_highlighting = hint
+resharper_built_in_type_reference_style_highlighting = hint
+resharper_redundant_base_qualifier_highlighting = warning
+resharper_suggest_var_or_type_built_in_types_highlighting = hint
+resharper_suggest_var_or_type_elsewhere_highlighting = hint
+resharper_suggest_var_or_type_simple_types_highlighting = hint
+
+# ReSharper properties
+resharper_place_accessorholder_attribute_on_same_line = false
+resharper_place_accessor_attribute_on_same_line = false
+resharper_place_field_attribute_on_same_line = false
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b1e57c4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,19 @@
+# Normalize all text files to LF
+# Normalize common .NET text files to CRLF
+*.cs text eol=crlf
+*.xaml text eol=crlf
+*.csproj text eol=crlf
+*.sln text eol=crlf
+*.config text eol=crlf
+*.json text eol=crlf
+*.md text eol=crlf
+*.pubxml text eol=crlf
+*.targets text eol=crlf
+*.props text eol=crlf
+*.tt text eol=crlf
+
+# Default: leave unspecified types untouched
+* -text
+
+# Do not normalize binary DLLs
+*.dll binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b9a9d11
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,64 @@
+# .NET Core
+bin/
+obj/
+*.user
+*.userosscache
+*.suo
+*.userprefs
+
+# Visual Studio
+.vs/
+.vscode/
+*.swp
+*.*~
+project.lock.json
+.DS_Store
+*.pyc
+nupkg/
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio cache/options directory
+.vs/
+
+# SQLite database files
+*.db
+*.db-shm
+*.db-wal
+
+# Log files
+logs/
+*.log
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# NuGet
+*.nupkg
+*.snupkg
+**/packages/*
+!**/packages/build/
+*.nuget.props
+*.nuget.targets
+.packages/
+.dotnet/
+.worktrees/
+参考程序/*
+.superpowers/*
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..c93022f
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,248 @@
+# AGENTS – C# WPF 上位机项目初始化规范
+
+本文件为在本仓库中创建和维护 **新的 C# WPF 上位机项目** 的智能体提供统一操作规范,作用域覆盖整个仓库。
+若某个子目录存在更深层级的 `AGENTS.md`,则以更具体的规则为准。
+
+## 适用说明
+
+- 本仓库中的历史项目、样例项目、参考项目仅用于借鉴工程结构与协作方式,不视为新项目的默认业务实现。
+- 新项目初始化时,优先采用 **.NET 8 + WPF + MVVM Toolkit + Generic Host/DI + NLog** 这一默认技术基线。
+- 不要直接复制参考项目中的业务命名、设备协议、数据库模型、接口定义、配置项或目录命名,除非用户明确要求。
+- 若用户仅要求“初始化项目”或“生成上位机骨架”,默认理解为生成一个结构清晰、可扩展、适合工业桌面应用的 WPF 工程,而不是复制旧项目的业务代码。
+
+## 推荐技术基线
+
+- 平台:Windows
+- SDK:.NET 8 SDK
+- UI:WPF
+- 项目文件:SDK-style `.csproj`
+- 目标框架:`net8.0-windows`
+- 语言特性:`enable`、`enable`
+- MVVM:`CommunityToolkit.Mvvm`
+- 宿主与依赖注入:`Microsoft.Extensions.Hosting`
+- 日志:`NLog` + `NLog.Extensions.Logging`
+- 配置:`appConfig.json` + `appConfig.{Environment}.json`
+- 开发期敏感配置:`UserSecrets` 或环境变量
+- 测试:优先单独测试项目,推荐 xUnit;UI 难测逻辑应下沉到 ViewModel 或 Service
+
+## 新项目初始化骨架
+
+若无额外约束,推荐使用如下仓库结构:
+
+```text
+src/
+ /
+ .csproj
+ App.xaml
+ App.xaml.cs
+ MainWindow.xaml
+ MainWindow.xaml.cs
+ appConfig.json
+ appConfig.Development.json
+ NLog.config
+ Assets/
+ Models/
+ ViewModels/
+ Views/
+ Pages/
+ UserControls/
+ Services/
+ Interfaces/
+ Implementations/
+ Modules/
+ Utils/
+tests/
+ .Tests/
+```
+
+约束如下:
+
+- `App.xaml` / `App.xaml.cs` 负责应用启动、Host 创建、全局异常处理、配置与日志装配。
+- `MainWindow.xaml` / `.cs` 只负责主窗口装配,不承载核心业务逻辑。
+- `ViewModels/` 保存可绑定状态、命令和页面协调逻辑。
+- `Views/Pages/`、`Views/UserControls/` 保存页面和可复用视图组件。
+- `Services/Interfaces/` 与 `Services/Implementations/` 用于硬件通信、文件处理、MES/PLC/条码/图像处理等业务服务。
+- `Modules/` 用于较独立的能力模块,例如数据库、设备协议封装、审计上传、报表导出等。
+- `Utils/` 仅保留轻量、通用、无业务语义的辅助工具;不要把主要业务逻辑塞入工具类。
+- `tests/` 必须与应用项目分离,避免把测试代码混入正式程序集。
+
+若仓库当前只有单个应用项目,也可暂时不建 `src/` 目录,直接在仓库根下放置 `/`;但若无明确要求,优先采用 `src/ + tests/` 结构。
+
+## 项目初始化最低要求
+
+新建项目时,至少保证以下内容一次到位:
+
+- WPF 应用可以成功还原、编译、启动。
+- `App.xaml.cs` 中建立统一的 Host/DI 启动入口。
+- 主窗口与主要 ViewModel 通过依赖注入创建。
+- 日志系统已接入,且启动阶段异常可落日志。
+- 配置文件支持环境分层加载。
+- 输出目录包含 `appConfig.json`、`appConfig.Development.json`、`NLog.config`。
+- 为未来扩展预留 `Services`、`ViewModels`、`Views`、`Utils`、`Modules` 目录。
+- 若涉及设备通信、轮询、文件监控、批处理等后台任务,必须预先考虑取消、限流、异常记录与 UI 线程切换。
+
+## 构建 / 还原 / 运行 / 发布
+
+以下命令中的 `` 代表新建 WPF 项目名:
+
+- 创建解决方案:`dotnet new sln -n `
+- 创建 WPF 项目:`dotnet new wpf -n --framework net8.0`
+- 还原:`dotnet restore src//.csproj`
+- 调试构建:`dotnet build src//.csproj -c Debug`
+- 发布构建:`dotnet build src//.csproj -c Release`
+- 运行(UI):`dotnet run --project src//.csproj -c Debug`
+- 发布示例:`dotnet publish src//.csproj -c Release -r win-x64 --self-contained false`
+
+初始化或修改 `.csproj` 时,至少保持以下属性:
+
+- `true`
+- `net8.0-windows`
+- `enable`
+- `enable`
+
+若项目包含配置、日志、资源或字典文件,需要同步设置 `CopyToOutputDirectory`,确保运行目录完整。
+
+## 测试 / 验证
+
+- 新项目默认应预留独立测试项目:`tests/.Tests/`。
+- 优先测试 ViewModel、Service、配置装配、解析逻辑、队列/调度逻辑,不要把核心逻辑压在难以测试的 code-behind 中。
+- 若功能与 UI 强绑定,应先抽离为接口或服务,再进行单元测试或集成测试。
+- 若暂未建立测试项目,至少执行一次构建验证,并手动完成关键 UI 流程检查。
+- 单元测试示例:`dotnet test --filter FullyQualifiedName~命名空间.类名.方法名`
+- 提交前最低要求:`dotnet build` 成功;涉及关键逻辑时应补充对应测试。
+
+## Lint / 格式
+
+- 若仓库尚未提供统一格式化工具或 `.editorconfig`,保持现有风格一致,不擅自引入新的格式化方案。
+- 保持 using 简洁,移除未使用引用。
+- 使用文件作用域命名空间,除非现有项目明确不采用。
+- 避免只为“看起来更优雅”而大范围重排代码格式。
+
+## XML 文档注释规则
+
+- 所有 `public` / `internal` / `protected` 类、接口、记录、方法、属性必须有 **中文 XML 文档注释**。
+- 私有成员若逻辑不简单,或方法体超过 10 行,也应补充 XML 注释。
+- 方法注释必须包含:
+ - ``
+ - 每个参数的 ``
+ - 有返回值时的 ``
+ - 可能抛出异常时的 ``
+- 异步方法名必须以 `Async` 结尾。
+- 若方法接收 `CancellationToken`,需要在注释中写明取消行为及影响。
+- 涉及命令、绑定、Dispatcher、后台线程、Task.Run、定时器、轮询、文件监控时,注释必须明确线程模型和触发行为。
+- 已有 XML 注释不得删除;修改实现后必须同步更新注释内容。
+
+## 代码风格(通用 C#)
+
+- 命名空间使用文件作用域写法。
+- 可读性优先:类型不明显时使用显式类型,明显时可使用 `var`。
+- 字段优先使用 `private readonly`,字段命名采用 `_camelCase`。
+- 常量使用 `const PascalCase`。
+- 参数校验只放在系统边界:用户输入、外部接口、文件内容、网络返回、硬件返回等位置。
+- 不要为当前需求之外的场景做预留抽象。
+- 不要新增只被调用一次的帮助类、工具类或接口。
+- 不要为了兼容历史命名而保留无意义的中间层,确认无用即可删除。
+
+## MVVM / WPF 实践
+
+- 业务逻辑放在 ViewModel 或 Service,code-behind 仅做视图装配、事件桥接、生命周期对接。
+- 命令优先使用 `RelayCommand` / `AsyncRelayCommand`。
+- 可绑定属性优先使用 `[ObservableProperty]` 生成,不手写重复样板代码。
+- 长耗时操作必须使用 `async/await`,不得阻塞 UI 线程。
+- 后台线程不得直接更新绑定到 UI 的对象;涉及 UI 更新时必须切回 Dispatcher。
+- 不要在 View 中直接 `new` 业务服务。
+- 不要在 code-behind 中直接访问数据库、文件系统、MES、PLC、串口、相机或网络接口。
+- 页面切换、对话框协调、状态共享应优先通过 ViewModel 或应用级服务完成。
+- 若项目包含托盘、单实例、后台常驻、自动重连、设备轮询等能力,应将其建模为显式服务并通过 DI 管理生命周期。
+
+## 宿主 / DI / 启动约定
+
+- 在 `App.xaml.cs` 中集中创建 `HostApplicationBuilder` 或等价 Host 对象。
+- 所有服务、ViewModel、Window、Page 的注册集中管理,不要分散在多个随机文件中。
+- 优先使用构造函数注入,不使用服务定位器模式。
+- 应用启动顺序应清晰:配置加载 → 日志初始化 → Host 构建 → 服务注册 → 主窗口创建与显示。
+- 若涉及全局异常捕获、未观察任务异常、UI 线程异常,应在启动阶段完成统一挂接。
+- 若项目需要单实例约束,应采用明确、可维护的单实例方案,并保证启动顺序可追踪。
+
+## 日志 / 异常 / 用户提示
+
+- 注入 `ILogger` 进行日志记录,优先使用结构化日志。
+- 记录错误时使用包含异常对象的重载,例如:`_logger.LogError(ex, "消息 {上下文}", value)`。
+- 不要静默吞异常;至少按 Debug / Warning / Error 级别记录。
+- 对用户可感知的失败,要同时提供用户友好提示与详细日志。
+- 对启动失败、配置错误、设备离线、文件访问失败、网络错误等场景,应明确记录上下文。
+- 对于可恢复错误,应在日志中记录恢复动作;对于不可恢复错误,应阻止继续执行并给出清晰提示。
+
+## 配置与环境管理
+
+- 配置文件默认采用:
+ - `appConfig.json`
+ - `appConfig.Development.json`
+ - 如有必要再增加 `appConfig.Production.json`、`appConfig.Local.json` 等
+- 运行环境通过 `DOTNET_ENVIRONMENT` 或 `ASPNETCORE_ENVIRONMENT` 识别,默认 `Production`。
+- 不要硬编码密钥、口令、连接串、IP、账号。
+- 开发期敏感配置优先使用 `UserSecrets` 或环境变量。
+- 新增配置文件、资源文件、字典文件时,必须同步 `.csproj` 的输出复制规则。
+- 读取配置时优先建立强类型 Options 或清晰的数据模型,不散落魔法字符串。
+
+## 集合、并发与 I/O
+
+- 文件处理优先使用流式 API,如 `Directory.EnumerateFiles`,避免一次性加载大量文件。
+- 批处理、轮询、文件监控、设备通信等后台任务需具备可取消性。
+- 对共享状态使用明确的并发策略:`SemaphoreSlim`、`ConcurrentQueue`、`ConcurrentDictionary`、Channel 或串行队列。
+- 不要锁住 UI 线程等待后台结果。
+- 对缓冲区、日志列表、待处理队列要设置上限,避免内存无限增长。
+- 所有后台任务都必须定义“谁创建、谁取消、谁记录异常、谁负责收尾”。
+
+## 命名约定
+
+- 类 / 结构 / 记录 / 枚举:`PascalCase`
+- 接口:`I` 前缀 + `PascalCase`
+- 方法 / 属性 / 事件:`PascalCase`
+- 私有字段:`_camelCase`
+- 局部变量 / 参数:`camelCase`
+- 命令:`*Command`
+- 异步方法:`*Async`
+- 配置类型名应与配置节含义一致,不使用模糊命名如 `ConfigHelper`、`DataManager`、`CommonUtil`
+
+## 新项目初始化完成的判定标准
+
+一个新建 WPF 上位机项目,至少达到以下状态才可认为“初始化完成”:
+
+- 可以成功 `restore`、`build`、`run`
+- 主窗口可以正常显示
+- Host / DI 已接入,主窗口和主 ViewModel 不依赖手工拼装
+- 配置文件可被读取,环境分层生效
+- 日志文件或日志输出可验证
+- 核心目录结构已建立
+- 至少保留一个可扩展的 Service、一个可扩展的 ViewModel、一个基础页面或主窗口示例
+- 若已经接入设备/文件监听/后台任务,则具备最基本的取消与异常记录机制
+
+## 贡献检查清单
+
+- 是否遵循本文件规定的初始化骨架与技术基线。
+- 是否保持业务逻辑不进入 code-behind。
+- 是否为公开成员补齐中文 XML 注释。
+- 是否已接入 Host/DI、配置和日志。
+- 是否移除未使用 using、无意义抽象和一次性工具层。
+- 是否避免硬编码敏感信息。
+- 是否在涉及后台任务时明确线程切换、取消与异常处理。
+- 是否为新增配置/资源设置输出复制规则。
+- 是否在提交前至少执行一次 `dotnet build`。
+
+## 快速命令速查
+
+- 新建解决方案:`dotnet new sln -n `
+- 新建 WPF 项目:`dotnet new wpf -n --framework net8.0`
+- 添加到解决方案:`dotnet sln add src//.csproj`
+- 还原:`dotnet restore src//.csproj`
+- 调试构建:`dotnet build src//.csproj -c Debug`
+- 发布构建:`dotnet build src//.csproj -c Release`
+- 运行调试:`dotnet run --project src//.csproj -c Debug`
+- 发布示例:`dotnet publish src//.csproj -c Release -r win-x64 --self-contained false`
+- 单测示例:`dotnet test --filter FullyQualifiedName~命名空间.类名.方法名`
+
+## 额外说明
+
+- 如果后续在某个新项目目录下生成了更具体的 `AGENTS.md`,应把该项目特有的设备协议、数据库约束、部署方式、配置项和 UI 行为写入子级文档,而不是污染仓库级规则。
+- 仓库级 `AGENTS.md` 应保持“可复用、可初始化、不过度绑定具体业务”的定位。
diff --git a/AxiOmron.PcbCheck.slnx b/AxiOmron.PcbCheck.slnx
new file mode 100644
index 0000000..a32a5fc
--- /dev/null
+++ b/AxiOmron.PcbCheck.slnx
@@ -0,0 +1,2 @@
+
+
diff --git a/docs/2026-04-16-pcb-check-flow-design.md b/docs/2026-04-16-pcb-check-flow-design.md
new file mode 100644
index 0000000..1209665
--- /dev/null
+++ b/docs/2026-04-16-pcb-check-flow-design.md
@@ -0,0 +1,897 @@
+# PCB 目检软件需求与方案设计
+
+## 1. 文档目的
+
+本文档用于整理当前 PCB 简易目检上位机的软件需求、业务流程、通信接口、异常处理和 Modbus TCP 点位设计,作为后续开发、联调和现场验收的依据。
+
+当前版本仅覆盖以下能力:
+
+- PLC 到位信号接入
+- 串口触发扫码枪扫码
+- SFTP 文件存在性校验
+- PLC 放行信号回写
+- HTTP 安灯报警接口调用
+- 关键过程日志与状态记录
+
+本文档不包含以下能力:
+
+- 相机采图
+- 图像算法判定
+- 人工复判
+- 多工位并行处理
+- MES/WMS 深度集成
+
+---
+
+## 2. 系统范围与边界
+
+### 2.1 系统角色
+
+系统由以下四个外部对象和一个上位机应用组成:
+
+1. **PLC**
+ - 通过 Modbus TCP 与上位机通信
+ - 提供 PCB 到位、复位、运行允许等状态
+ - 接收上位机放行、流程完成、故障/报警等结果信号
+
+2. **扫码枪**
+ - 通过串口与上位机通信
+ - 接收上位机触发扫描指令
+ - 返回二维码内容或超时失败
+
+3. **SFTP 服务器**
+ - 用于存放与 PCB 二维码 ID 对应的判定文件或放行文件
+ - 上位机根据扫码结果访问指定目录并查找目标文件
+
+4. **安灯系统**
+ - 通过 HTTP 接口接收报警请求
+ - 用于扫码失败等异常场景的现场告警
+
+5. **上位机软件**
+ - 负责流程编排、设备通信、状态机控制、日志记录、异常处理和 PLC 结果回写
+
+### 2.2 运行约束
+
+- 运行模式为**单件串行处理**
+- 同一时刻只处理一块 PCB
+- 上位机作为 **Modbus TCP Client**
+- PLC 作为 **Modbus TCP Server**
+- 扫码最多尝试 **3 次(含首次触发)**
+- SFTP 采用 **1 次首次查询 + 最多 N 次重试** 的方式检查目标文件
+- 扫码失败 3 次后仍 **放行**,同时触发安灯报警
+- SFTP 文件最终未找到时仍 **放行**,默认仅记录日志,不强制安灯报警
+- SFTP 连接失败、认证失败、目录配置错误等**系统级异常**不等同于“文件不存在”,默认进入系统故障,不自动放行
+
+---
+
+## 3. 需求整理
+
+### 3.1 功能需求
+
+#### 3.1.1 到位触发
+
+- 当 PCB 到达指定工位后,PLC 通过 Modbus TCP 点位通知上位机“PCB 已到位”
+- 上位机在检测到到位信号后,启动本次单板处理流程
+- 上位机应避免同一块板被重复触发处理
+
+#### 3.1.2 扫码控制
+
+- 上位机通过串口向扫码枪发送触发命令
+- 扫码枪返回二维码字符串
+- 如果单次扫码失败,上位机允许继续重试
+- 当前版本最大尝试次数固定为 **3 次(含首次触发)**
+
+#### 3.1.3 扫码失败处理
+
+- 若连续 3 次扫码失败:
+ - 上位机调用 HTTP 安灯报警接口
+ - 上位机记录失败原因和报警结果
+ - 上位机仍向 PLC 发送放行信号
+ - 本次流程结果标记为“扫码失败放行”
+
+#### 3.1.4 SFTP 文件校验
+
+- 扫码成功后,上位机根据二维码 ID 到配置好的 SFTP 目录中查找对应文件
+- 当前版本采用 **首次查询 1 次 + 文件未命中后最多重试 N 次** 的规则,`N` 为可配置项
+- 若文件存在:
+ - 视为校验通过
+ - 上位机立即发送放行信号给 PLC
+- 若文件不存在:
+ - 等待 X 秒后再次检查
+ - 达到重试上限后按“文件未找到超时处理”执行
+
+#### 3.1.5 文件未找到超时处理
+
+- 若达到 SFTP 查询重试上限后仍未找到对应文件:
+ - 上位机记录“文件未找到超时放行”
+ - 上位机仍向 PLC 发送放行信号
+ - 默认不强制调用安灯接口,但应保留后续扩展为可配置报警策略的设计空间
+
+#### 3.1.6 二维码内容处理
+
+- 二维码字符串在用于 SFTP 查询前,应先执行基础清洗:
+ - 去除首尾空白字符
+ - 去除回车换行等控制字符
+- 清洗后若为空字符串,则视为本次扫码失败
+- 当前版本不增加更复杂的正则或长度业务校验,避免引入未确认的规则
+
+#### 3.1.7 PLC 交互
+
+- 上位机需向 PLC 提供以下反馈能力:
+ - 忙碌状态
+ - 当前流程完成状态
+ - 放行信号
+ - 扫码成功/失败状态
+ - 文件存在/不存在状态
+ - 故障或报警状态
+ - 异常代码/结果代码
+
+#### 3.1.8 日志与追溯
+
+- 上位机应记录以下关键日志:
+ - 到位触发时间
+ - 扫码每次尝试结果
+ - 最终二维码内容
+ - SFTP 连接与查找结果
+ - HTTP 报警调用结果
+ - PLC 点位写入动作
+ - 本次流程最终结果
+
+### 3.2 非功能需求
+
+- 支持稳定连续运行
+- 所有外部接口都应具备超时控制
+- 所有异常都必须记录日志,不允许静默吞掉
+- 同一时刻只能处理一块 PCB,避免并发混板
+- 配置项应集中管理,避免硬编码 IP、端口、账号、目录和重试参数
+
+---
+
+## 4. 推荐总体方案
+
+### 4.1 总体架构
+
+推荐采用**单工位串行状态机架构**。
+
+该方案由一个主流程控制器串联以下模块:
+
+- PLC 通信模块
+- 扫码枪控制模块
+- SFTP 校验模块
+- 安灯报警模块
+- 流程状态机模块
+- 日志与结果记录模块
+
+### 4.2 方案特点
+
+- 适合当前“单件串行”场景
+- 逻辑清晰,便于现场联调
+- 点位数量可控,便于 PLC 程序实现
+- 后续可在不推翻主体结构的前提下扩展更多结果码、报警策略和 UI 状态展示
+
+### 4.3 不推荐的当前方案
+
+当前阶段不建议直接采用多任务队列、多工位并行或由 PLC 承担大部分业务编排的重型架构,因为会显著增加联调复杂度,不符合“简单目检软件”的目标。
+
+---
+
+## 5. 软件模块设计
+
+### 5.1 流程控制模块
+
+负责:
+
+- 接收 PLC 到位信号
+- 驱动整个业务状态机流转
+- 管理当前板的处理上下文
+- 决定何时触发扫码、何时检查文件、何时报警、何时放行
+
+建议职责边界:
+
+- 一个流程控制器只负责一块当前 PCB
+- 所有外部服务都由流程控制器统一调度
+- 不允许 PLC、扫码、SFTP、报警模块彼此直接耦合调用
+
+### 5.2 PLC 通信模块
+
+负责:
+
+- 周期性读取 PLC 点位
+- 写入上位机结果点位
+- 做好写脉冲、保持位和复位逻辑
+- 管理断线重连和通信状态
+
+建议接口:
+
+- `ReadSignalsAsync()`:读取 PLC 输入状态
+- `WriteHandshakeAsync()`:写入握手和结果点位
+- `PulseReleaseAsync()`:发送放行脉冲
+- `ResetResultBitsAsync()`:清理本次流程结果位
+
+### 5.3 扫码枪控制模块
+
+负责:
+
+- 管理串口打开、关闭、重连
+- 发送扫码命令
+- 接收扫码返回值
+- 执行单次扫码超时控制
+- 将扫码结果以统一对象返回给流程控制器
+
+建议输出统一结果:
+
+- 是否成功
+- 扫码内容
+- 原始返回报文
+- 失败原因
+- 本次耗时
+
+### 5.4 SFTP 校验模块
+
+负责:
+
+- 根据配置建立 SFTP 连接
+- 按二维码 ID 查找目标文件
+- 支持按目录、文件名模板或通配方式查找
+- 执行重试、等待和超时控制
+- 输出最终查找结果
+
+建议支持的配置项:
+
+- 服务器地址
+- 端口
+- 用户名
+- 密码/密钥
+- 根目录
+- 文件名匹配规则
+- 单次连接超时
+- 查询等待秒数
+- 最大重试次数
+
+### 5.5 安灯报警模块
+
+负责:
+
+- 调用 HTTP 安灯接口
+- 发送报警编码、报警内容、工位号、二维码等参数
+- 处理响应结果并记录日志
+
+建议支持的配置项:
+
+- 接口 URL
+- 请求方法
+- 请求头
+- 超时时间
+- 报警编码
+- 工位名称
+- 是否启用扫码失败报警
+- 是否启用文件未找到报警(预留)
+
+### 5.6 日志与结果记录模块
+
+负责:
+
+- 写入运行日志
+- 保存每板处理结果摘要
+- 输出 UI 可显示的当前状态、结果和错误信息
+
+建议记录字段:
+
+- 触发时间
+- 完成时间
+- 条码内容
+- 扫码次数
+- SFTP 重试次数
+- 最终结果代码
+- 最终结果描述
+- 是否调用安灯
+- PLC 放行发送时间
+
+---
+
+## 6. 业务状态机设计
+
+### 6.1 状态定义
+
+建议定义以下流程状态:
+
+1. **Idle**:空闲,等待 PLC 到位
+2. **Triggered**:接收到到位信号,准备启动流程
+3. **Scanning**:正在触发扫码枪扫码
+4. **ScanRetrying**:扫码失败,等待下一次扫码尝试
+5. **ScanFailedReleased**:扫码失败 3 次,已报警并决定放行
+6. **CheckingSftp**:正在检查 SFTP 文件是否存在
+7. **WaitingSftpRetry**:文件未找到,等待下一次轮询
+8. **SftpPassed**:文件找到,允许放行
+9. **SftpTimeoutReleased**:文件未找到超时,决定放行
+10. **Releasing**:向 PLC 发送放行信号
+11. **Completed**:本次流程结束
+12. **Faulted**:出现系统级故障,如 PLC 通信异常、配置错误、串口不可用、SFTP 连接异常等
+
+### 6.2 状态流转
+
+#### 正常流转
+
+`Idle -> Triggered -> Scanning -> CheckingSftp -> SftpPassed -> Releasing -> Completed -> Idle`
+
+#### 扫码失败放行流转
+
+`Idle -> Triggered -> Scanning -> ScanRetrying -> Scanning -> ScanRetrying -> Scanning -> ScanFailedReleased -> Releasing -> Completed -> Idle`
+
+#### 文件未找到超时放行流转
+
+`Idle -> Triggered -> Scanning -> CheckingSftp -> WaitingSftpRetry -> CheckingSftp -> ... -> SftpTimeoutReleased -> Releasing -> Completed -> Idle`
+
+#### 系统故障流转
+
+`任意流程态 -> Faulted -> 人工复位/PLC复位 -> Idle`
+
+### 6.3 状态机约束
+
+- 只有 `Idle` 状态才允许接收新的 PCB 到位触发
+- 系统进入 `Faulted` 后,不允许自动接收下一块板,必须在故障恢复后由人工复位或 PLC 复位解除
+- 允许后台自动执行断线重连,但在 `Faulted` 未解除前不得恢复业务处理
+- 每次开始新流程前必须清理上一板的过程结果位
+- `Release` 动作必须具备防重复发送保护
+
+### 6.4 启动前置条件
+
+系统只有在以下条件同时满足时,才视为处于可接板的 `Idle` 状态:
+
+- PLC 通信已建立
+- `PlcReady = 1`
+- `AutoMode = 1`
+- `StationEnable = 1`
+- 上位机不存在未解除的 `SystemFault`
+
+若上述条件不满足,上位机仅保持监视,不启动新板流程。
+
+---
+
+## 7. 详细流程说明
+
+### 7.1 主流程
+
+1. 上位机轮询 PLC 到位点位
+2. 当检测到“到位”且当前状态为 `Idle` 时:
+ - 记录开始时间
+ - 置 Busy 位
+ - 清理上次结果位
+ - 写入当前流程状态码
+ - 进入扫码流程
+3. 扫码成功后,进入 SFTP 校验流程
+4. 根据 SFTP 结果决定立即放行或超时后放行
+5. 发送放行信号给 PLC
+6. 置流程完成位,并将最终结果码保持为稳定值
+7. 等待 PLC 应答或到位信号撤销
+8. 回到空闲状态
+
+### 7.2 扫码流程
+
+1. 发送扫码枪触发指令
+2. 等待扫码结果,单次扫码应设置超时
+3. 对扫码结果进行基础清洗:去掉空白和控制字符
+4. 若成功:
+ - 保存二维码内容
+ - 清除 `ScanNg`
+ - 置 `ScanOk`
+ - 写入当前流程状态码
+ - 进入 SFTP 校验流程
+5. 若失败:
+ - 累加尝试次数
+ - 记录日志
+ - 若未达到最大尝试次数,则进入下一轮扫码
+6. 达到 3 次后仍失败:
+ - 清除 `ScanOk`
+ - 置 `ScanNg`
+ - 调用安灯报警
+ - 写入结果代码“扫码失败放行”
+ - 进入放行流程
+
+说明:
+
+- `ScanNg` 表示**最终扫码失败**,不用于表示中间某一次扫码失败
+- 当前版本“3 次”定义为**总共最多 3 次尝试**,不是“首次 1 次 + 额外重试 3 次”
+
+### 7.3 SFTP 校验流程
+
+1. 按配置建立 SFTP 连接
+2. 根据二维码 ID 拼接目标文件名或查找规则
+3. 立即执行首次查询
+4. 若文件存在:
+ - 清除 `FileNotFound`
+ - 置 `FileFound`
+ - 写入结果代码“文件存在放行”
+ - 进入放行流程
+5. 若文件不存在:
+ - 记录当前查询未命中
+ - 若未达到重试上限,则等待 X 秒再次查询
+ - 若达到重试上限,则清除 `FileFound`、置 `FileNotFound`、写入结果代码“文件未找到超时放行”,进入放行流程
+6. 若出现 SFTP 连接失败、认证失败、目录不存在等系统级异常:
+ - 置 `SystemFault`
+ - 写入故障结果码
+ - 进入 `Faulted`
+
+说明:
+
+- 当前版本 `MaxRetryCount = N` 表示**首次未命中后的最多重试次数**
+- 因此总查询次数为 **1 + N**
+- `FileNotFound` 表示**最终未找到**,不用于表示中间某次查询未命中
+
+### 7.4 放行流程
+
+1. 上位机向 PLC 写入放行请求位 `ReleasePermit`
+2. 推荐采用**脉冲方式**输出放行信号,默认脉冲时长建议为 **500ms**,可配置
+3. 同时写入最终结果码、流程完成位和稳定的状态位
+4. 若 PLC 提供 `PlcAckRelease`:
+ - 上位机等待 PLC 应答
+ - 最长等待时间建议为 **2000ms**,超时则记录告警并自动清除放行脉冲
+5. 若 PLC 不提供 `PlcAckRelease`:
+ - 上位机保持 `ReleasePermit` 至配置脉冲时长结束后自动清除
+6. `ProcessDone = 1` 时,表示 `ResultCode`、`AlarmCode`、`ScanTryCount`、`SftpTryCount` 均已为最终稳定值
+
+---
+
+## 8. Modbus TCP 通信设计
+
+### 8.1 通信角色
+
+- 上位机:Modbus TCP Client
+- PLC:Modbus TCP Server
+- 轮询周期建议:100ms ~ 300ms
+- 布尔量优先使用位点位,结果码优先使用 Holding Register
+
+### 8.2 点位设计原则
+
+- 输入点和输出点职责分离
+- 过程状态位与最终结果码同时保留
+- 对 PLC 需要快速判断的信号,优先给单独布尔位
+- 对上位机 UI 和日志需要详细表达的结果,使用数值结果码补充
+- 放行信号使用脉冲,忙碌位和完成位可使用保持方式
+- 本文档采用**PLC -> 上位机为 Discrete Input、上位机 -> PLC 为 Coil** 的常规表达方式;若现场 PLC 地址区定义不同,可在实施阶段做地址映射,不改变信号语义
+
+### 8.3 PLC -> 上位机点位(建议)
+
+以下点位由 PLC 提供,上位机读取:
+
+| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| 1 | Discrete Input | 10001 | PlcReady | PLC -> PC | PLC 就绪,允许上位机参与流程 |
+| 2 | Discrete Input | 10002 | PcbArrived | PLC -> PC | PCB 已到位,请求上位机处理 |
+| 3 | Discrete Input | 10003 | PlcReset | PLC -> PC | PLC 请求上位机清状态/复位 |
+| 4 | Discrete Input | 10004 | PlcAckRelease | PLC -> PC | PLC 已接收到放行信号 |
+| 5 | Discrete Input | 10005 | AutoMode | PLC -> PC | 设备当前处于自动模式 |
+| 6 | Discrete Input | 10006 | StationEnable | PLC -> PC | 当前工位使能 |
+
+说明:
+
+- `PcbArrived` 为本流程主触发点
+- `PlcAckRelease` 为可选应答点,若 PLC 侧不需要可取消
+- `PlcReset` 用于人工清故障、恢复空闲态或清除保持位
+- `10007 ~ 10050` 预留给后续 PLC -> 上位机扩展信号
+
+### 8.4 上位机 -> PLC 点位(建议)
+
+以下点位由上位机写入,PLC 读取:
+
+| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| 1 | Coil | 00051 | PcOnline | PC -> PLC | 上位机在线心跳位 |
+| 2 | Coil | 00052 | PcBusy | PC -> PLC | 上位机正在处理当前 PCB |
+| 3 | Coil | 00053 | ScanOk | PC -> PLC | 本次最终扫码成功 |
+| 4 | Coil | 00054 | ScanNg | PC -> PLC | 本次最终扫码失败 |
+| 5 | Coil | 00055 | FileFound | PC -> PLC | 最终找到对应 SFTP 文件 |
+| 6 | Coil | 00056 | FileNotFound | PC -> PLC | 到达重试上限后仍未找到文件 |
+| 7 | Coil | 00057 | AlarmRaised | PC -> PLC | 本次流程已触发安灯报警 |
+| 8 | Coil | 00058 | ReleasePermit | PC -> PLC | 放行脉冲信号 |
+| 9 | Coil | 00059 | ProcessDone | PC -> PLC | 本次流程已结束,结果码稳定 |
+| 10 | Coil | 00060 | SystemFault | PC -> PLC | 上位机系统故障 |
+
+说明:
+
+- `ScanOk/ScanNg` 互斥
+- `FileFound/FileNotFound` 互斥
+- `ReleasePermit` 采用脉冲输出,不建议长时间保持
+- `ProcessDone = 1` 表示寄存器中的结果值已经稳定,PLC 可以在此时读取 `ResultCode`
+- `AlarmRaised` 建议保持到下一板开始或收到 `PlcReset` 后再清除
+- `SystemFault` 用于表示上位机自身流程无法继续,例如 PLC 断连、串口异常、配置缺失、SFTP 连接异常等系统级故障
+
+### 8.5 心跳建议
+
+- `PcOnline` 不建议常亮,建议采用**翻转心跳**方式
+- 上位机每 **500ms** 翻转一次 `PcOnline`
+- PLC 若在 **3 秒** 内未检测到该位变化,则可判定上位机离线或通信异常
+
+### 8.6 结果寄存器设计(建议)
+
+建议额外使用 Holding Register 表达结果码和统计值。
+
+| 序号 | 地址类型 | 地址 | 名称 | 说明 |
+| --- | --- | --- | --- | --- |
+| 1 | Holding Register | 40001 | ResultCode | 本次最终结果代码 |
+| 2 | Holding Register | 40002 | ScanTryCount | 本次扫码尝试次数 |
+| 3 | Holding Register | 40003 | SftpTryCount | 本次 SFTP 查询次数 |
+| 4 | Holding Register | 40004 | AlarmCode | 本次报警代码,未报警时为 0 |
+| 5 | Holding Register | 40005 | FlowStateCode | 当前流程状态码 |
+
+### 8.7 推荐结果代码定义
+
+| 代码 | 含义 |
+| --- | --- |
+| 0 | Idle / 无结果 |
+| 1 | 处理中 |
+| 10 | 扫码成功,文件存在,正常放行 |
+| 20 | 扫码失败 3 次后放行 |
+| 30 | 扫码成功,文件未找到超时放行 |
+| 40 | PLC 通信异常 |
+| 41 | 串口异常 |
+| 42 | SFTP 连接或认证异常 |
+| 43 | 安灯接口调用异常 |
+| 44 | 配置异常 |
+
+### 8.8 推荐流程状态码定义
+
+| 代码 | 含义 |
+| --- | --- |
+| 0 | Idle |
+| 1 | Triggered |
+| 2 | Scanning |
+| 3 | ScanRetrying |
+| 4 | CheckingSftp |
+| 5 | WaitingSftpRetry |
+| 6 | Releasing |
+| 7 | Completed |
+| 8 | Faulted |
+
+### 8.9 点位清理策略
+
+建议按如下规则清理点位:
+
+- 新板开始前清理:`ScanOk`、`ScanNg`、`FileFound`、`FileNotFound`、`AlarmRaised`、`ProcessDone`
+- 流程处理中保持:`PcBusy`
+- 放行时脉冲输出:`ReleasePermit`
+- 流程结束后清理:`PcBusy`
+- `PcOnline` 由心跳任务周期性翻转
+- `SystemFault` 在故障解除且收到人工复位或 `PlcReset` 后清除
+
+---
+
+## 9. 串口扫码设计建议
+
+### 9.1 配置项
+
+建议配置以下参数:
+
+- 串口号
+- 波特率
+- 数据位
+- 校验位
+- 停止位
+- 单次扫码超时毫秒数
+- 触发命令
+- 结束符/返回报文规则
+
+### 9.2 抽象接口建议
+
+扫码服务建议统一为:
+
+- `TriggerScanAsync()`
+- 返回 `ScanResult`
+
+`ScanResult` 建议包含:
+
+- `IsSuccess`
+- `Barcode`
+- `RawMessage`
+- `ErrorMessage`
+- `DurationMs`
+
+### 9.3 行为建议
+
+- 串口打开失败时立即记录系统故障
+- 每次扫码都必须单独设置超时
+- 返回空字符串、格式错误或无结束符都视为失败
+- 对扫码值仅做基础清洗,不额外加入未确认的业务规则
+
+---
+
+## 10. SFTP 文件查找设计建议
+
+### 10.1 查找策略
+
+建议支持以下任一方式,由配置指定:
+
+1. **完整文件名匹配**
+ - 例如:`${barcode}.txt`
+2. **前缀/后缀匹配**
+ - 例如:`${barcode}_OK.xml`
+3. **目录内模糊匹配**
+ - 例如:包含二维码 ID 的文件名即视为命中
+
+初版推荐采用**完整文件名匹配**,规则最清晰,误判概率最低。
+
+### 10.2 条码到文件名映射
+
+- 当前版本以**清洗后的二维码字符串**作为查询键
+- 推荐默认文件名规则为:`${barcode}.txt`
+- 实际文件名后缀或模板通过配置项 `FileNamePattern` 指定
+- 若后续现场规则变更,可通过配置调整,不修改流程主逻辑
+
+### 10.3 配置项
+
+- `Host`
+- `Port`
+- `Username`
+- `Password` 或私钥
+- `RootPath`
+- `FileNamePattern`
+- `RetryIntervalSeconds`
+- `MaxRetryCount`
+- `ConnectTimeoutMs`
+
+### 10.4 行为建议
+
+- 每次查询都要记录当前第几次尝试
+- 若 SFTP 连接失败,应区分“连接失败”和“文件不存在”
+- 对于连接失败、认证失败、目录错误,不按“文件不存在超时放行”处理,而按系统异常处理
+- 若目录配置错误,应作为配置异常处理
+
+---
+
+## 11. 安灯 HTTP 接口设计建议
+
+### 11.1 报警触发场景
+
+当前版本建议至少在以下场景调用安灯接口:
+
+- 扫码连续失败 3 次
+
+后续可扩展场景:
+
+- SFTP 文件未找到超时
+- PLC 通信异常
+- 串口设备离线
+
+### 11.2 请求内容建议
+
+建议请求体包含:
+
+- 工位编码
+- 工位名称
+- 报警类型
+- 报警编码
+- 报警描述
+- 二维码内容(若有)
+- 触发时间
+- 设备名/IP
+
+### 11.3 报警代码建议
+
+| 代码 | 含义 |
+| --- | --- |
+| 1001 | 扫码连续失败 3 次 |
+| 1002 | SFTP 文件超时未找到 |
+| 1003 | SFTP 连接异常 |
+| 1004 | 串口设备异常 |
+| 1005 | PLC 通信异常 |
+
+### 11.4 设计要求
+
+- HTTP 请求必须有超时控制
+- 请求失败必须记录日志
+- 若报警接口失败,不影响当前“放行”主流程,但需在日志和结果码中体现
+
+---
+
+## 12. 配置设计建议
+
+建议在配置文件中划分如下配置节:
+
+### 12.1 PlcOptions
+
+- IP 地址
+- 端口
+- 轮询周期
+- 连接超时
+- 点位地址映射
+- 放行脉冲时长
+- 放行应答超时时间
+
+### 12.2 ScannerOptions
+
+- 串口参数
+- 触发命令
+- 单次扫码超时
+- 最大扫码次数(当前固定建议值为 3)
+
+### 12.3 SftpOptions
+
+- 服务器连接参数
+- 根目录
+- 文件匹配规则
+- 重试等待秒数
+- 最大重试次数
+
+### 12.4 AndonOptions
+
+- 是否启用
+- 接口地址
+- 请求方式
+- 超时时间
+- 工位编码
+- 默认报警码映射
+
+### 12.5 WorkflowOptions
+
+- 启动前是否要求 `PlcReady = 1`
+- 启动前是否要求自动模式
+- 故障恢复后是否必须人工复位
+- 日志保留天数
+
+说明:
+
+- 当前版本“扫码失败 3 次后放行”和“文件未找到超时后放行”属于**固定业务规则**,不作为普通配置开关
+- 若未来需要改成拦截模式,应通过需求变更单独评审
+
+---
+
+## 13. UI 展示建议
+
+当前系统虽为简化版,仍建议主界面至少展示以下信息:
+
+- PLC 连接状态
+- 扫码枪连接状态
+- SFTP 连接状态
+- 安灯接口状态
+- 当前流程状态
+- 当前二维码内容
+- 扫码次数
+- SFTP 查询次数
+- 当前结果描述
+- 最近若干条运行日志
+
+建议增加以下按钮:
+
+- 手动复位
+- 手动重连 PLC
+- 手动重连扫码枪
+- 测试安灯接口
+- 打开配置
+
+---
+
+## 14. 异常处理策略
+
+### 14.1 业务异常
+
+业务异常指当前板流程内的可接受结果:
+
+- 扫码失败 3 次
+- 文件未找到超时
+
+处理原则:
+
+- 记录日志
+- 写入明确结果码
+- 当前版本仍放行
+
+### 14.2 系统异常
+
+系统异常指上位机自身能力不可用:
+
+- PLC 无法连接
+- 串口打开失败
+- SFTP 配置缺失或无法认证
+- SFTP 根目录不存在
+- 安灯接口配置错误
+
+处理原则:
+
+- 置 `SystemFault`
+- 写入故障结果码
+- 日志详细记录异常上下文
+- 进入 `Faulted`
+- 故障恢复后需人工复位或 PLC 复位后方可重新接板
+
+### 14.3 防重入要求
+
+- 流程执行中必须禁止重复触发新板流程
+- 放行写入必须有一次性保护
+- SFTP 查询不得并发重复执行
+- 报警接口同一事件只发送一次
+
+---
+
+## 15. 日志与追溯要求
+
+### 15.1 日志级别建议
+
+- `Info`:流程开始、扫码结果、SFTP命中、放行完成
+- `Warning`:扫码重试、文件未找到等待重试、报警接口失败、放行应答超时
+- `Error`:PLC/串口/SFTP/HTTP 调用异常、配置异常
+
+### 15.2 单板结果摘要建议
+
+每块 PCB 建议形成一条结果记录,包含:
+
+- 流水时间戳
+- 条码
+- 扫码总次数
+- SFTP 查询次数
+- 最终结果码
+- 最终结果描述
+- 是否放行
+- 是否报警
+- 异常摘要
+
+---
+
+## 16. 联调与验收建议
+
+### 16.1 联调顺序建议
+
+1. 先打通 PLC 基础读写
+2. 再联调扫码枪串口触发
+3. 再联调 SFTP 文件查找
+4. 再联调安灯 HTTP 接口
+5. 最后做整流程串联测试
+
+### 16.2 关键测试场景
+
+1. PLC 到位,扫码一次成功,SFTP 一次命中,正常放行
+2. PLC 到位,扫码前两次失败,第三次成功,SFTP 命中,正常放行
+3. PLC 到位,扫码三次失败,安灯报警成功,最终放行
+4. PLC 到位,扫码成功,SFTP 多次未命中,超时后放行
+5. PLC 到位,扫码成功,但 SFTP 连接异常,系统进入故障,禁止自动放行
+6. 安灯接口调用失败,但扫码失败场景仍按既定规则放行,并有异常日志
+7. 流程处理中重复收到到位信号,不允许重复触发
+8. PLC 复位后,上位机状态位正确清除
+9. PLC 未进入自动模式或未给 `PlcReady` 时,上位机不接板
+
+### 16.3 验收重点
+
+- 是否稳定识别到位触发
+- 是否正确限制扫码 3 次总尝试
+- 是否按配置进行 SFTP 首次查询和后续 N 次重试
+- 是否在规定场景下正确放行
+- 是否正确触发安灯报警
+- 是否正确区分“文件未找到”和“SFTP 系统异常”
+- 是否具备清晰日志和结果码追溯能力
+
+---
+
+## 17. 后续扩展建议
+
+当前设计已为以下扩展预留空间:
+
+- 将扫码失败放行改为配置策略
+- 将文件未找到是否报警改为配置策略
+- 增加工位编号和多站点配置
+- 增加结果明细落库
+- 增加人工复位与权限控制
+- 增加多工位或多缓存位流程
+- 增加 MES 校验或工单绑定
+
+---
+
+## 18. 最终结论
+
+当前项目适合采用**单工位串行状态机 + Modbus TCP + 串口扫码 + SFTP 文件校验 + HTTP 安灯报警**的轻量化上位机方案。
+
+该方案具备以下特点:
+
+- 与当前需求完全匹配
+- 软件结构简单清晰
+- PLC 点位数量少,易联调
+- 能满足“扫码失败放行”和“文件未找到超时放行”的业务要求
+- 对 SFTP 系统异常与普通文件未命中进行了明确区分
+- 具备后续扩展为更完整目检系统的演进空间
+
+建议后续开发时优先落地:
+
+1. 配置模型
+2. PLC 点位通信服务
+3. 扫码服务
+4. SFTP 校验服务
+5. 主流程状态机
+6. 主界面状态展示
diff --git a/docs/superpowers/specs/2026-04-16-axi-omron-ui-design.md b/docs/superpowers/specs/2026-04-16-axi-omron-ui-design.md
index f12e03f..b6a285c 100644
--- a/docs/superpowers/specs/2026-04-16-axi-omron-ui-design.md
+++ b/docs/superpowers/specs/2026-04-16-axi-omron-ui-design.md
@@ -39,6 +39,64 @@
- 列宽与绑定不变。
- **流程状态区**:将“流程状态”卡片改为 `hc:Card` 高亮展示,错误信息使用红色文字(不变)。
+#### Dashboard 下半区布局方案确认
+
+- 已确认采用 **方案 A:双栏中枢布局**。
+- 设计目标:
+ - 消除当前页面下半区的大面积空白。
+ - 强化“当前板状态”和“过程追踪”两个核心视觉中心。
+ - 将运行日志降为辅助信息,而不是和主业务区争抢注意力。
+- 布局原则:
+ - 左侧承担“当前状态 + 运行日志”主视角,右侧承担“处理记录 + 追踪摘要”副视角。
+ - 下半区不再平均切成多个小卡片,而是改为“少块、大面、强层级”。
+ - 通过不同卡片高度、标题层级、留白和分组,建立更清晰的信息优先级。
+
+#### Dashboard 下半区具体布局
+
+- **整体分栏**:
+ - 下半区采用 `1.3 : 0.9` 左右双栏。
+ - 左栏为主工作区,右栏为过程追踪区。
+- **左栏上部**:
+ - 使用两个并排摘要卡片。
+ - 卡片 1:`当前二维码 + 结果码 / 报警码`
+ - 卡片 2:`关键标志 + 扫码次数 + SFTP 次数`
+ - 当前二维码信息作为左栏上部的第一视觉重点,字体明显大于普通统计值。
+- **左栏下部**:
+ - 放置 `最近运行日志`,占据左栏主要高度。
+ - DataGrid 保留,但应提升表头、行高、内边距和空状态表现,避免“开发态表格感”过强。
+- **右栏上部**:
+ - 放置 `最近处理记录摘要`,展示:
+ - 最近触发时间
+ - 最近完成时间
+ - 最后刷新时间
+ - 该区域应做成信息摘要卡,而不是散落的三行文字。
+- **右栏下部**:
+ - 放置 `最近处理记录列表`。
+ - 列表可以保留 DataGrid,但视觉上应更轻,重点突出时间、条码、结果三列。
+ - 若 HandyControl 样式允许,可适当弱化网格线,提升卡片式整洁感。
+
+#### Dashboard 下半区视觉风格约束
+
+- 不新增花哨装饰,不改为互联网运营后台风格,保持工业桌面应用的稳重感。
+- 卡片层次应依靠以下手段建立,而不是依赖过多边框:
+ - 标题字号差异
+ - 数值字号差异
+ - 卡片阴影和圆角
+ - 区块留白
+ - 轻量分隔
+- `关键标志` 建议改为更有秩序的纵向状态列表,保留圆点语义,但需统一间距、字号、对齐。
+- `结果码 / 报警码` 不再作为独立弱卡片存在,应并入主摘要区,提高信息密度。
+- `最近处理记录` 与 `最近运行日志` 必须形成主次关系:
+ - 主日志区更宽,更适合排查问题。
+ - 处理记录区更紧凑,更适合回看节拍与结果。
+
+#### 本次改造范围
+
+- 仅重构 `DashboardPage.xaml` 的下半区布局与视觉层级。
+- 不修改 ViewModel 属性命名、命令绑定和业务逻辑。
+- 不新增复杂动画、主题切换、图表控件或第三方可视化组件。
+- 若需要新增局部样式或页面资源,应优先放在页面内,避免污染全局资源。
+
### 4. SystemSettingsPage.xaml
- **TabControl**:使用 HandyControl 的 `TabControl` 样式(默认已覆盖),或显式使用 `hc:TabControl`。
- **表单项**:
@@ -66,3 +124,4 @@
- 不改写业务逻辑、ViewModel、服务层。
- 不引入 HandyControl 的高级控件(Growl、Timeline、Pagination 等),避免过度设计。
- 不替换为其他 UI 库(MahApps、MaterialDesign、WPFUI 等)。
+
diff --git a/src/AxiOmron.PcbCheck/App.xaml b/src/AxiOmron.PcbCheck/App.xaml
new file mode 100644
index 0000000..2a4400f
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/App.xaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxiOmron.PcbCheck/App.xaml.cs b/src/AxiOmron.PcbCheck/App.xaml.cs
new file mode 100644
index 0000000..39df095
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/App.xaml.cs
@@ -0,0 +1,130 @@
+using System.IO;
+using System.Text;
+using System.Windows;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Implementations;
+using AxiOmron.PcbCheck.Services.Interfaces;
+using AxiOmron.PcbCheck.ViewModels;
+using AxiOmron.PcbCheck.Views.Pages;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using NLog.Extensions.Logging;
+
+namespace AxiOmron.PcbCheck;
+
+///
+/// 表示 WPF 应用入口,负责 Host/DI、配置和日志初始化。
+///
+public partial class App : Application
+{
+ private IHost? _host;
+
+ ///
+ /// 获取当前应用服务容器。
+ ///
+ public static IServiceProvider Services { get; private set; } = null!;
+
+ ///
+ /// 应用启动入口。
+ ///
+ /// 启动事件参数。
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+ try
+ {
+ Console.OutputEncoding = Encoding.UTF8;
+ }
+ catch (IOException)
+ {
+ // WinExe 在没有控制台句柄时忽略
+ }
+
+ try
+ {
+ _host = BuildHost();
+ await _host.StartAsync().ConfigureAwait(true);
+ Services = _host.Services;
+ var mainWindow = Services.GetRequiredService();
+ MainWindow = mainWindow;
+ mainWindow.Show();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"应用启动失败: {ex.Message}", "Axi Omron PCB Check", MessageBoxButton.OK, MessageBoxImage.Error);
+ Shutdown(-1);
+ }
+ }
+
+ ///
+ /// 应用退出入口。
+ ///
+ /// 退出事件参数。
+ protected override async void OnExit(ExitEventArgs e)
+ {
+ if (_host is not null)
+ {
+ try
+ {
+ await _host.StopAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
+ }
+ finally
+ {
+ _host.Dispose();
+ }
+ }
+
+ base.OnExit(e);
+ }
+
+ ///
+ /// 构建应用 Host 与依赖注入容器。
+ ///
+ /// 已构建的 Host 实例。
+ private static IHost BuildHost()
+ {
+ return Host.CreateDefaultBuilder()
+ .ConfigureAppConfiguration((context, configurationBuilder) =>
+ {
+ configurationBuilder.SetBasePath(AppContext.BaseDirectory);
+ configurationBuilder.AddJsonFile("appConfig.json", optional: true, reloadOnChange: false);
+ configurationBuilder.AddJsonFile($"appConfig.{context.HostingEnvironment.EnvironmentName}.json", optional: true, reloadOnChange: false);
+ })
+ .ConfigureLogging(logging =>
+ {
+ logging.ClearProviders();
+ logging.SetMinimumLevel(LogLevel.Information);
+ logging.AddNLog();
+ })
+ .ConfigureServices((context, services) =>
+ {
+ var appConfig = new AppConfig();
+ context.Configuration.Bind(appConfig);
+
+ services.AddSingleton(context.Configuration);
+ services.AddSingleton();
+ services.AddSingleton(appConfig);
+ services.AddSingleton();
+ services.AddSingleton(typeof(IAppLogger<>), typeof(AppLogger<>));
+ services.AddSingleton();
+ services.AddHttpClient(nameof(AndonService));
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddHostedService(sp => sp.GetRequiredService());
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ })
+ .Build();
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/AssemblyInfo.cs b/src/AxiOmron.PcbCheck/AssemblyInfo.cs
new file mode 100644
index 0000000..c73865b
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/AssemblyInfo.cs
@@ -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)
+)]
diff --git a/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj b/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj
new file mode 100644
index 0000000..adaf74a
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj
@@ -0,0 +1,33 @@
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+ PreserveNewest
+
+
+ Always
+
+
+
diff --git a/src/AxiOmron.PcbCheck/Converters/BooleanToBrushConverter.cs b/src/AxiOmron.PcbCheck/Converters/BooleanToBrushConverter.cs
new file mode 100644
index 0000000..2c7276e
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Converters/BooleanToBrushConverter.cs
@@ -0,0 +1,61 @@
+using System.Globalization;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace AxiOmron.PcbCheck.Converters;
+
+///
+/// 将布尔状态转换为界面画刷,颜色与设计色板保持一致。
+///
+public sealed class BooleanToBrushConverter : IValueConverter
+{
+ ///
+ /// true 状态使用的柔和绿色画刷(对应 Tag 前景色 #15803D)。
+ ///
+ private static readonly SolidColorBrush TrueBrush = CreateFrozen(0x15, 0x80, 0x3D);
+
+ ///
+ /// false 状态使用的柔和红色画刷(对应 Tag 前景色 #B91C1C)。
+ ///
+ private static readonly SolidColorBrush FalseBrush = CreateFrozen(0xB9, 0x1C, 0x1C);
+
+ ///
+ /// 将布尔值转换为画刷。
+ ///
+ /// 源值。
+ /// 目标类型。
+ /// 扩展参数。
+ /// 当前区域信息。
+ /// 状态画刷。
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value is true ? TrueBrush : FalseBrush;
+ }
+
+ ///
+ /// 创建并冻结指定颜色的画刷,便于跨线程复用。
+ ///
+ /// 红通道。
+ /// 绿通道。
+ /// 蓝通道。
+ /// 已冻结的画刷实例。
+ private static SolidColorBrush CreateFrozen(byte r, byte g, byte b)
+ {
+ SolidColorBrush brush = new(Color.FromRgb(r, g, b));
+ brush.Freeze();
+ return brush;
+ }
+
+ ///
+ /// 不支持反向转换。
+ ///
+ /// 源值。
+ /// 目标类型。
+ /// 扩展参数。
+ /// 当前区域信息。
+ /// 抛出不支持异常。
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Converters/BooleanToVisibilityConverter.cs b/src/AxiOmron.PcbCheck/Converters/BooleanToVisibilityConverter.cs
new file mode 100644
index 0000000..605dedb
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Converters/BooleanToVisibilityConverter.cs
@@ -0,0 +1,67 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace AxiOmron.PcbCheck.Converters;
+
+///
+/// 将布尔值转换为 ,支持通过 ConverterParameter 反转判定。
+///
+///
+/// 当 ConverterParameter 为 "Invert"、"Inverse" 或 "!" 时,布尔值的真假意义取反。
+///
+public sealed class BooleanToVisibilityConverter : IValueConverter
+{
+ ///
+ /// 将布尔值转换为可见性。
+ ///
+ /// 源布尔值。
+ /// 目标类型。
+ /// 若为 "Invert"/"!" 则反转判定。
+ /// 当前区域信息。
+ /// 或 。
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ bool flag = value is bool b && b;
+ if (IsInvert(parameter))
+ {
+ flag = !flag;
+ }
+ return flag ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ ///
+ /// 将可见性反向转换为布尔值。
+ ///
+ /// 目标可见性。
+ /// 目标类型。
+ /// 若为 "Invert"/"!" 则反转判定。
+ /// 当前区域信息。
+ /// 布尔值。
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ bool flag = value is Visibility v && v == Visibility.Visible;
+ if (IsInvert(parameter))
+ {
+ flag = !flag;
+ }
+ return flag;
+ }
+
+ ///
+ /// 判断参数是否要求反转判定。
+ ///
+ /// 参数值。
+ /// 是否反转。
+ private static bool IsInvert(object? parameter)
+ {
+ if (parameter is null)
+ {
+ return false;
+ }
+ string token = parameter.ToString() ?? string.Empty;
+ return token.Equals("Invert", StringComparison.OrdinalIgnoreCase)
+ || token.Equals("Inverse", StringComparison.OrdinalIgnoreCase)
+ || token.Equals("!", StringComparison.Ordinal);
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs b/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs
new file mode 100644
index 0000000..51bdde0
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs
@@ -0,0 +1,54 @@
+using System.Globalization;
+using System.Windows.Data;
+using AxiOmron.PcbCheck.Models;
+
+namespace AxiOmron.PcbCheck.Converters;
+
+///
+/// 将 (以 存储)映射为简短 Tag 文本。
+///
+///
+/// 映射规则:Passed → "OK";Processing → "处理中";None → "-";其余视为 "NG"。
+///
+public sealed class ResultCodeToTagTextConverter : IValueConverter
+{
+ ///
+ /// 将结果码转换为 Tag 文本。
+ ///
+ /// 结果码值。
+ /// 目标类型。
+ /// 扩展参数。
+ /// 当前区域信息。
+ /// 简短标签文本。
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ ushort code = value switch
+ {
+ ushort u => u,
+ int i => (ushort)i,
+ WorkflowResultCode rc => (ushort)rc,
+ _ => (ushort)0
+ };
+
+ return code switch
+ {
+ (ushort)WorkflowResultCode.Passed => "OK",
+ (ushort)WorkflowResultCode.Processing => "处理中",
+ (ushort)WorkflowResultCode.None => "-",
+ _ => "NG"
+ };
+ }
+
+ ///
+ /// 不支持反向转换。
+ ///
+ /// 源值。
+ /// 目标类型。
+ /// 扩展参数。
+ /// 当前区域信息。
+ /// 抛出不支持异常。
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs
new file mode 100644
index 0000000..02a7851
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs
@@ -0,0 +1,120 @@
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.DesignTime;
+
+///
+/// 提供设计时配置服务,返回固定的示例配置数据。
+///
+public sealed class DesignTimeAppConfigService : IAppConfigService
+{
+ private readonly AppConfig _config;
+
+ ///
+ /// 初始化设计时配置服务。
+ ///
+ public DesignTimeAppConfigService()
+ {
+ _config = CreateSampleConfig();
+ }
+
+ ///
+ /// 读取设计时配置副本。
+ ///
+ /// 示例根配置对象。
+ public AppConfig Load()
+ {
+ return CreateSampleConfig();
+ }
+
+ ///
+ /// 保存设计时配置,占位实现,仅更新内存中的副本。
+ ///
+ /// 待保存的配置对象。
+ /// 当 为 时抛出。
+ public void Save(AppConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ _config.Plc = config.Plc;
+ _config.Scanner = config.Scanner;
+ _config.Sftp = config.Sftp;
+ _config.Andon = config.Andon;
+ _config.Workflow = config.Workflow;
+ }
+
+ ///
+ /// 获取设计时展示用的示例配置路径。
+ ///
+ /// 固定的设计时配置路径文本。
+ public string GetConfigPath()
+ {
+ return @"D:\DesignTime\appConfig.Development.json";
+ }
+
+ ///
+ /// 创建设计器使用的示例配置对象。
+ ///
+ /// 填充默认值后的配置对象。
+ private static AppConfig CreateSampleConfig()
+ {
+ return new AppConfig
+ {
+ Plc = new PlcOptions
+ {
+ Host = "192.168.10.25",
+ Port = 502,
+ UnitId = 1,
+ PollIntervalMs = 200,
+ ConnectTimeoutMs = 3000,
+ HeartbeatIntervalMs = 500,
+ ReleasePulseMs = 450,
+ ReleaseAckTimeoutMs = 2500
+ },
+ Scanner = new ScannerOptions
+ {
+ PortName = "COM3",
+ BaudRate = 9600,
+ DataBits = 8,
+ Parity = "None",
+ StopBits = "One",
+ ReadTimeoutMs = 2500,
+ TriggerCommand = "SCAN\\r",
+ ResponseTerminator = "\\r",
+ MaxScanAttempts = 3
+ },
+ Sftp = new SftpOptions
+ {
+ Host = "10.10.20.35",
+ Port = 22,
+ Username = "pcb_user",
+ Password = "******",
+ PrivateKeyPath = @"C:\Keys\pcb-check.ppk",
+ RootPath = "/data/pcb",
+ FileNamePattern = "${barcode}.txt",
+ RetryIntervalSeconds = 2,
+ MaxRetryCount = 3,
+ ConnectTimeoutMs = 3000
+ },
+ Andon = new AndonOptions
+ {
+ Enable = true,
+ Url = "http://10.10.20.50/api/andon/alarm",
+ Method = "POST",
+ TimeoutMs = 3000,
+ StationCode = "OMRON-L01",
+ StationName = "欧姆龙 PCB 检测",
+ EnableScanFailAlarm = true,
+ EnableFileNotFoundAlarm = true
+ },
+ Workflow = new WorkflowOptions
+ {
+ RequirePlcReady = true,
+ RequireAutoMode = true,
+ RequireStationEnable = true,
+ RequireManualResetAfterFault = true,
+ MaxUiLogEntries = 200,
+ MaxBoardRecords = 100
+ }
+ };
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs
new file mode 100644
index 0000000..8ee4c70
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs
@@ -0,0 +1,206 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.DesignTime;
+
+///
+/// 提供设计时运行态存储,向真实 ViewModel 回放固定的演示数据。
+///
+public sealed class DesignTimeAppStateStore : IAppStateStore
+{
+ private readonly RuntimeSnapshot _snapshot;
+ private readonly IReadOnlyList _logs;
+ private readonly IReadOnlyList _records;
+ private EventHandler? _snapshotChanged;
+ private EventHandler? _logAdded;
+ private EventHandler? _recordAdded;
+
+ ///
+ /// 初始化设计时运行态存储。
+ ///
+ public DesignTimeAppStateStore()
+ {
+ DateTimeOffset now = DateTimeOffset.Now;
+ _snapshot = new RuntimeSnapshot
+ {
+ PlcStatus = "已连接",
+ ScannerStatus = "在线",
+ SftpStatus = "可访问",
+ AndonStatus = "接口正常",
+ WorkflowState = WorkflowState.CheckingSftp,
+ WorkflowStateText = WorkflowState.CheckingSftp.ToDisplayText(),
+ CurrentBarcode = "PCB240417000128",
+ ResultDescription = "已扫码,等待 SFTP 文件确认",
+ FaultMessage = string.Empty,
+ ScanTryCount = 1,
+ SftpTryCount = 2,
+ ResultCode = (ushort)WorkflowResultCode.Processing,
+ AlarmCode = (ushort)AlarmCode.None,
+ LastTriggeredAt = now.AddSeconds(-18),
+ LastCompletedAt = now.AddMinutes(-2),
+ IsBusy = true,
+ ProcessDone = false,
+ SystemFault = false,
+ AlarmRaised = false,
+ LastUpdatedAt = now
+ };
+
+ _logs = new List
+ {
+ new() { Timestamp = now.AddSeconds(-2), Level = "Info", Message = "SFTP 第 2 次查询:正在检查目标文件。" },
+ new() { Timestamp = now.AddSeconds(-9), Level = "Warning", Message = "第 1 次 SFTP 查询未命中,准备重试。" },
+ new() { Timestamp = now.AddSeconds(-16), Level = "Info", Message = "扫码成功,条码=PCB240417000128" },
+ new() { Timestamp = now.AddSeconds(-21), Level = "Info", Message = "检测到 PCB 到位,流程开始执行。" },
+ new() { Timestamp = now.AddMinutes(-1), Level = "Error", Message = "上一片文件查询超时,已按规则放行。" }
+ };
+
+ _records = new List
+ {
+ new()
+ {
+ StartedAt = now.AddMinutes(-4),
+ CompletedAt = now.AddMinutes(-3).AddSeconds(-18),
+ Barcode = "PCB240417000125",
+ ScanTryCount = 1,
+ SftpTryCount = 1,
+ ResultCode = (ushort)WorkflowResultCode.Passed,
+ ResultDescription = "OK 放行",
+ ReleaseSent = true,
+ AlarmRaised = false,
+ ExceptionSummary = string.Empty
+ },
+ new()
+ {
+ StartedAt = now.AddMinutes(-3),
+ CompletedAt = now.AddMinutes(-2).AddSeconds(-12),
+ Barcode = "PCB240417000126",
+ ScanTryCount = 3,
+ SftpTryCount = 0,
+ ResultCode = (ushort)WorkflowResultCode.ScanFailedReleased,
+ ResultDescription = "扫码失败后放行",
+ ReleaseSent = true,
+ AlarmRaised = true,
+ ExceptionSummary = "扫码连续失败三次"
+ },
+ new()
+ {
+ StartedAt = now.AddMinutes(-2),
+ CompletedAt = now.AddMinutes(-1).AddSeconds(-25),
+ Barcode = "PCB240417000127",
+ ScanTryCount = 1,
+ SftpTryCount = 3,
+ ResultCode = (ushort)WorkflowResultCode.FileNotFoundReleased,
+ ResultDescription = "文件超时未找到后放行",
+ ReleaseSent = true,
+ AlarmRaised = true,
+ ExceptionSummary = "SFTP 文件查询超时"
+ }
+ };
+ }
+
+ ///
+ /// 当运行态快照发生变化时触发。
+ ///
+ public event EventHandler? SnapshotChanged
+ {
+ add
+ {
+ _snapshotChanged += value;
+ }
+ remove
+ {
+ _snapshotChanged -= value;
+ }
+ }
+
+ ///
+ /// 当新增日志时触发;订阅时会立即回放现有设计时日志。
+ ///
+ public event EventHandler? LogAdded
+ {
+ add
+ {
+ if (value is null)
+ {
+ return;
+ }
+
+ _logAdded += value;
+ foreach (UiLogEntry entry in _logs)
+ {
+ value(this, entry);
+ }
+ }
+ remove
+ {
+ _logAdded -= value;
+ }
+ }
+
+ ///
+ /// 当新增单板记录时触发;订阅时会立即回放现有设计时记录。
+ ///
+ public event EventHandler? RecordAdded
+ {
+ add
+ {
+ if (value is null)
+ {
+ return;
+ }
+
+ _recordAdded += value;
+ foreach (BoardProcessRecord record in _records)
+ {
+ value(this, record);
+ }
+ }
+ remove
+ {
+ _recordAdded -= value;
+ }
+ }
+
+ ///
+ /// 获取当前设计时快照副本。
+ ///
+ /// 当前快照副本。
+ public RuntimeSnapshot GetSnapshot()
+ {
+ return _snapshot.Clone();
+ }
+
+ ///
+ /// 更新设计时快照并通知订阅者。
+ ///
+ /// 用于修改快照的委托。
+ /// 当 为 时抛出。
+ public void UpdateSnapshot(Action updateAction)
+ {
+ ArgumentNullException.ThrowIfNull(updateAction);
+ updateAction(_snapshot);
+ _snapshotChanged?.Invoke(this, _snapshot.Clone());
+ }
+
+ ///
+ /// 追加一条设计时日志并通知订阅者。
+ ///
+ /// 待追加的日志对象。
+ /// 当 为 时抛出。
+ public void AddLog(UiLogEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ _logAdded?.Invoke(this, entry);
+ }
+
+ ///
+ /// 追加一条设计时处理记录并通知订阅者。
+ ///
+ /// 待追加的记录对象。
+ /// 当 为 时抛出。
+ public void AddRecord(BoardProcessRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ _recordAdded?.Invoke(this, record);
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeDispatcherService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeDispatcherService.cs
new file mode 100644
index 0000000..fd4e29a
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeDispatcherService.cs
@@ -0,0 +1,22 @@
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.DesignTime;
+
+///
+/// 提供设计时 Dispatcher 调度能力,直接在当前线程执行委托。
+///
+public sealed class DesignTimeDispatcherService : IDispatcherService
+{
+ ///
+ /// 在当前线程中立即执行指定动作。
+ ///
+ /// 待执行的动作。
+ /// 表示执行完成的任务。
+ /// 当 为 时抛出。
+ public Task InvokeAsync(Action action)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+ action();
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs
new file mode 100644
index 0000000..4d90a26
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs
@@ -0,0 +1,39 @@
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.ViewModels;
+
+namespace AxiOmron.PcbCheck.DesignTime;
+
+///
+/// 为 XAML 设计器提供基于真实 ViewModel 的设计时定位器。
+///
+public sealed class DesignTimeViewModelLocator
+{
+ private readonly DesignTimeAppStateStore _appStateStore = new();
+ private readonly DesignTimeDispatcherService _dispatcherService = new();
+ private readonly DesignTimeWorkflowControlService _workflowControlService = new();
+ private readonly DesignTimeAppConfigService _appConfigService = new();
+ private MainWindowViewModel? _mainWindowViewModel;
+ private SystemSettingViewModel? _systemSettingViewModel;
+
+ ///
+ /// 获取首页设计时视图模型。
+ ///
+ public MainWindowViewModel MainWindowViewModel
+ => _mainWindowViewModel ??= CreateMainWindowViewModel();
+
+ ///
+ /// 获取系统设置设计时视图模型。
+ ///
+ public SystemSettingViewModel SystemSettingViewModel
+ => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService);
+
+ ///
+ /// 创建首页设计时视图模型。
+ ///
+ /// 填充了设计时演示数据的真实视图模型实例。
+ private MainWindowViewModel CreateMainWindowViewModel()
+ {
+ AppConfig config = _appConfigService.Load();
+ return new MainWindowViewModel(_appStateStore, _dispatcherService, _workflowControlService, config);
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeWorkflowControlService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeWorkflowControlService.cs
new file mode 100644
index 0000000..76d1c4e
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeWorkflowControlService.cs
@@ -0,0 +1,57 @@
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.DesignTime;
+
+///
+/// 提供设计时流程控制服务,所有命令均为无副作用占位实现。
+///
+public sealed class DesignTimeWorkflowControlService : IWorkflowControlService
+{
+ ///
+ /// 模拟手动复位流程命令,不执行实际业务操作。
+ ///
+ /// 取消令牌;若已取消则立即返回已取消任务。
+ /// 表示命令已完成的任务。
+ public Task ResetAsync(CancellationToken cancellationToken)
+ {
+ return cancellationToken.IsCancellationRequested
+ ? Task.FromCanceled(cancellationToken)
+ : Task.CompletedTask;
+ }
+
+ ///
+ /// 模拟 PLC 重连命令,不执行实际设备通信。
+ ///
+ /// 取消令牌;若已取消则立即返回已取消任务。
+ /// 表示命令已完成的任务。
+ public Task ReconnectPlcAsync(CancellationToken cancellationToken)
+ {
+ return cancellationToken.IsCancellationRequested
+ ? Task.FromCanceled(cancellationToken)
+ : Task.CompletedTask;
+ }
+
+ ///
+ /// 模拟扫码枪重连命令,不执行实际设备通信。
+ ///
+ /// 取消令牌;若已取消则立即返回已取消任务。
+ /// 表示命令已完成的任务。
+ public Task ReconnectScannerAsync(CancellationToken cancellationToken)
+ {
+ return cancellationToken.IsCancellationRequested
+ ? Task.FromCanceled(cancellationToken)
+ : Task.CompletedTask;
+ }
+
+ ///
+ /// 模拟安灯测试命令,不执行实际网络请求。
+ ///
+ /// 取消令牌;若已取消则立即返回已取消任务。
+ /// 表示命令已完成的任务。
+ public Task TestAndonAsync(CancellationToken cancellationToken)
+ {
+ return cancellationToken.IsCancellationRequested
+ ? Task.FromCanceled(cancellationToken)
+ : Task.CompletedTask;
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/MainWindow.xaml b/src/AxiOmron.PcbCheck/MainWindow.xaml
new file mode 100644
index 0000000..14538d0
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/MainWindow.xaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxiOmron.PcbCheck/MainWindow.xaml.cs b/src/AxiOmron.PcbCheck/MainWindow.xaml.cs
new file mode 100644
index 0000000..6046355
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/MainWindow.xaml.cs
@@ -0,0 +1,46 @@
+using System.Windows;
+using AxiOmron.PcbCheck.Views.Pages;
+
+namespace AxiOmron.PcbCheck;
+
+///
+/// 表示主窗口,负责页面导航装配。
+///
+public partial class MainWindow : Window
+{
+ private readonly DashboardPage _dashboardPage;
+ private readonly SystemSettingsPage _systemSettingsPage;
+
+ ///
+ /// 初始化主窗口。
+ ///
+ /// 首页页面。
+ /// 系统设置页面。
+ public MainWindow(DashboardPage dashboardPage, SystemSettingsPage systemSettingsPage)
+ {
+ _dashboardPage = dashboardPage ?? throw new ArgumentNullException(nameof(dashboardPage));
+ _systemSettingsPage = systemSettingsPage ?? throw new ArgumentNullException(nameof(systemSettingsPage));
+ InitializeComponent();
+ MainFrame.Navigate(_dashboardPage);
+ }
+
+ ///
+ /// 导航到首页。
+ ///
+ /// 事件源。
+ /// 事件参数。
+ private void DashboardButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ MainFrame.Navigate(_dashboardPage);
+ }
+
+ ///
+ /// 导航到系统设置页。
+ ///
+ /// 事件源。
+ /// 事件参数。
+ private void SettingsButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ MainFrame.Navigate(_systemSettingsPage);
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs
new file mode 100644
index 0000000..7c90952
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs
@@ -0,0 +1,138 @@
+namespace AxiOmron.PcbCheck.Models;
+
+///
+/// 表示当前应用运行态快照,用于界面展示与后台状态同步。
+///
+public sealed class RuntimeSnapshot
+{
+ ///
+ /// 获取或设置 PLC 连接状态文本。
+ ///
+ public string PlcStatus { get; set; } = "未连接";
+
+ ///
+ /// 获取或设置扫码枪连接状态文本。
+ ///
+ public string ScannerStatus { get; set; } = "未验证";
+
+ ///
+ /// 获取或设置 SFTP 连接状态文本。
+ ///
+ public string SftpStatus { get; set; } = "未验证";
+
+ ///
+ /// 获取或设置安灯接口状态文本。
+ ///
+ public string AndonStatus { get; set; } = "未验证";
+
+ ///
+ /// 获取或设置当前流程状态。
+ ///
+ public WorkflowState WorkflowState { get; set; } = WorkflowState.Idle;
+
+ ///
+ /// 获取或设置当前流程状态文本。
+ ///
+ public string WorkflowStateText { get; set; } = WorkflowState.Idle.ToDisplayText();
+
+ ///
+ /// 获取或设置当前条码。
+ ///
+ public string CurrentBarcode { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置当前结果描述。
+ ///
+ public string ResultDescription { get; set; } = "等待触发";
+
+ ///
+ /// 获取或设置当前故障信息。
+ ///
+ public string FaultMessage { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置扫码次数。
+ ///
+ public int ScanTryCount { get; set; }
+
+ ///
+ /// 获取或设置 SFTP 查询次数。
+ ///
+ public int SftpTryCount { get; set; }
+
+ ///
+ /// 获取或设置结果代码。
+ ///
+ public ushort ResultCode { get; set; }
+
+ ///
+ /// 获取或设置报警代码。
+ ///
+ public ushort AlarmCode { get; set; }
+
+ ///
+ /// 获取或设置上次触发时间。
+ ///
+ public DateTimeOffset? LastTriggeredAt { get; set; }
+
+ ///
+ /// 获取或设置上次完成时间。
+ ///
+ public DateTimeOffset? LastCompletedAt { get; set; }
+
+ ///
+ /// 获取或设置 PLC 是否忙碌。
+ ///
+ public bool IsBusy { get; set; }
+
+ ///
+ /// 获取或设置是否已完成。
+ ///
+ public bool ProcessDone { get; set; }
+
+ ///
+ /// 获取或设置是否存在系统故障。
+ ///
+ public bool SystemFault { get; set; }
+
+ ///
+ /// 获取或设置是否已触发报警。
+ ///
+ public bool AlarmRaised { get; set; }
+
+ ///
+ /// 获取或设置上次状态刷新时间。
+ ///
+ public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
+
+ ///
+ /// 创建当前快照的副本。
+ ///
+ /// 新的运行态快照副本。
+ public RuntimeSnapshot Clone()
+ {
+ return new RuntimeSnapshot
+ {
+ PlcStatus = PlcStatus,
+ ScannerStatus = ScannerStatus,
+ SftpStatus = SftpStatus,
+ AndonStatus = AndonStatus,
+ WorkflowState = WorkflowState,
+ WorkflowStateText = WorkflowStateText,
+ CurrentBarcode = CurrentBarcode,
+ ResultDescription = ResultDescription,
+ FaultMessage = FaultMessage,
+ ScanTryCount = ScanTryCount,
+ SftpTryCount = SftpTryCount,
+ ResultCode = ResultCode,
+ AlarmCode = AlarmCode,
+ LastTriggeredAt = LastTriggeredAt,
+ LastCompletedAt = LastCompletedAt,
+ IsBusy = IsBusy,
+ ProcessDone = ProcessDone,
+ SystemFault = SystemFault,
+ AlarmRaised = AlarmRaised,
+ LastUpdatedAt = LastUpdatedAt
+ };
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs
new file mode 100644
index 0000000..4ab4dbe
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs
@@ -0,0 +1,579 @@
+namespace AxiOmron.PcbCheck.Models;
+
+///
+/// 表示流程状态机中的业务状态。
+///
+public enum WorkflowState
+{
+ ///
+ /// 空闲状态。
+ ///
+ Idle = 0,
+
+ ///
+ /// 已收到触发信号。
+ ///
+ Triggered = 1,
+
+ ///
+ /// 正在扫码。
+ ///
+ Scanning = 2,
+
+ ///
+ /// 扫码重试中。
+ ///
+ ScanRetrying = 3,
+
+ ///
+ /// 扫码失败后放行。
+ ///
+ ScanFailedReleased = 4,
+
+ ///
+ /// 正在检查 SFTP。
+ ///
+ CheckingSftp = 5,
+
+ ///
+ /// 正在等待 SFTP 重试。
+ ///
+ WaitingSftpRetry = 6,
+
+ ///
+ /// SFTP 校验通过。
+ ///
+ SftpPassed = 7,
+
+ ///
+ /// SFTP 超时后放行。
+ ///
+ SftpTimeoutReleased = 8,
+
+ ///
+ /// 正在放行。
+ ///
+ Releasing = 9,
+
+ ///
+ /// 流程已完成。
+ ///
+ Completed = 10,
+
+ ///
+ /// 系统故障。
+ ///
+ Faulted = 11
+}
+
+///
+/// 表示最终结果代码定义。
+///
+public enum WorkflowResultCode : ushort
+{
+ ///
+ /// 无结果。
+ ///
+ None = 0,
+
+ ///
+ /// 处理中。
+ ///
+ Processing = 1,
+
+ ///
+ /// 正常放行。
+ ///
+ Passed = 10,
+
+ ///
+ /// 扫码失败后放行。
+ ///
+ ScanFailedReleased = 20,
+
+ ///
+ /// 文件未找到超时后放行。
+ ///
+ FileNotFoundReleased = 30,
+
+ ///
+ /// PLC 通信异常。
+ ///
+ PlcCommunicationFault = 40,
+
+ ///
+ /// 串口异常。
+ ///
+ ScannerFault = 41,
+
+ ///
+ /// SFTP 连接或认证异常。
+ ///
+ SftpFault = 42,
+
+ ///
+ /// 安灯接口调用异常。
+ ///
+ AndonFault = 43,
+
+ ///
+ /// 配置异常。
+ ///
+ ConfigurationFault = 44
+}
+
+///
+/// 表示报警代码定义。
+///
+public enum AlarmCode : ushort
+{
+ ///
+ /// 未报警。
+ ///
+ None = 0,
+
+ ///
+ /// 扫码连续失败三次。
+ ///
+ ScanFailed = 1001,
+
+ ///
+ /// SFTP 文件超时未找到。
+ ///
+ FileNotFound = 1002,
+
+ ///
+ /// SFTP 连接异常。
+ ///
+ SftpFault = 1003,
+
+ ///
+ /// 串口设备异常。
+ ///
+ ScannerFault = 1004,
+
+ ///
+ /// PLC 通信异常。
+ ///
+ PlcFault = 1005
+}
+
+///
+/// 表示 PLC 读取到的输入信号快照。
+///
+public sealed class PlcSignalSnapshot
+{
+ ///
+ /// 获取或设置 PLC 是否就绪。
+ ///
+ public bool PlcReady { get; set; }
+
+ ///
+ /// 获取或设置 PCB 是否到位。
+ ///
+ public bool PcbArrived { get; set; }
+
+ ///
+ /// 获取或设置 PLC 是否请求复位。
+ ///
+ public bool PlcReset { get; set; }
+
+ ///
+ /// 获取或设置 PLC 是否已应答放行。
+ ///
+ public bool PlcAckRelease { get; set; }
+
+ ///
+ /// 获取或设置是否为自动模式。
+ ///
+ public bool AutoMode { get; set; }
+
+ ///
+ /// 获取或设置工位是否使能。
+ ///
+ public bool StationEnable { get; set; }
+
+ ///
+ /// 获取或设置本次快照采集时间。
+ ///
+ public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.Now;
+}
+
+///
+/// 表示上位机要写入 PLC 的输出状态与寄存器数据。
+///
+public sealed class PlcProcessState
+{
+ ///
+ /// 获取或设置 PC 在线位。
+ ///
+ public bool PcOnline { get; set; }
+
+ ///
+ /// 获取或设置 PC 忙碌位。
+ ///
+ public bool PcBusy { get; set; }
+
+ ///
+ /// 获取或设置扫码成功位。
+ ///
+ public bool ScanOk { get; set; }
+
+ ///
+ /// 获取或设置扫码失败位。
+ ///
+ public bool ScanNg { get; set; }
+
+ ///
+ /// 获取或设置文件找到位。
+ ///
+ public bool FileFound { get; set; }
+
+ ///
+ /// 获取或设置文件未找到位。
+ ///
+ public bool FileNotFound { get; set; }
+
+ ///
+ /// 获取或设置报警位。
+ ///
+ public bool AlarmRaised { get; set; }
+
+ ///
+ /// 获取或设置放行位。
+ ///
+ public bool ReleasePermit { get; set; }
+
+ ///
+ /// 获取或设置流程完成位。
+ ///
+ public bool ProcessDone { get; set; }
+
+ ///
+ /// 获取或设置系统故障位。
+ ///
+ public bool SystemFault { get; set; }
+
+ ///
+ /// 获取或设置结果代码寄存器值。
+ ///
+ public ushort ResultCode { get; set; }
+
+ ///
+ /// 获取或设置扫码次数寄存器值。
+ ///
+ public ushort ScanTryCount { get; set; }
+
+ ///
+ /// 获取或设置 SFTP 查询次数寄存器值。
+ ///
+ public ushort SftpTryCount { get; set; }
+
+ ///
+ /// 获取或设置报警代码寄存器值。
+ ///
+ public ushort AlarmCode { get; set; }
+
+ ///
+ /// 获取或设置流程状态代码寄存器值。
+ ///
+ public ushort FlowStateCode { get; set; }
+
+ ///
+ /// 创建当前状态对象的浅拷贝。
+ ///
+ /// 新的 PLC 输出状态对象。
+ public PlcProcessState Clone()
+ {
+ return new PlcProcessState
+ {
+ PcOnline = PcOnline,
+ PcBusy = PcBusy,
+ ScanOk = ScanOk,
+ ScanNg = ScanNg,
+ FileFound = FileFound,
+ FileNotFound = FileNotFound,
+ AlarmRaised = AlarmRaised,
+ ReleasePermit = ReleasePermit,
+ ProcessDone = ProcessDone,
+ SystemFault = SystemFault,
+ ResultCode = ResultCode,
+ ScanTryCount = ScanTryCount,
+ SftpTryCount = SftpTryCount,
+ AlarmCode = AlarmCode,
+ FlowStateCode = FlowStateCode
+ };
+ }
+}
+
+///
+/// 表示扫码执行结果。
+///
+public sealed class ScanOperationResult
+{
+ ///
+ /// 获取或设置扫码是否成功。
+ ///
+ public bool IsSuccess { get; set; }
+
+ ///
+ /// 获取或设置是否为系统级异常。
+ ///
+ public bool IsSystemError { get; set; }
+
+ ///
+ /// 获取或设置设备连接是否正常。
+ ///
+ public bool DeviceConnected { get; set; }
+
+ ///
+ /// 获取或设置清洗后的条码值。
+ ///
+ public string Barcode { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置原始报文。
+ ///
+ public string RawMessage { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置错误描述。
+ ///
+ public string ErrorMessage { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置耗时,单位为毫秒。
+ ///
+ public long DurationMs { get; set; }
+}
+
+///
+/// 表示一次 SFTP 文件校验结果。
+///
+public sealed class SftpCheckOutcome
+{
+ ///
+ /// 获取或设置目标文件是否存在。
+ ///
+ public bool Exists { get; set; }
+
+ ///
+ /// 获取或设置是否为系统级异常。
+ ///
+ public bool IsSystemError { get; set; }
+
+ ///
+ /// 获取或设置是否为配置级异常。
+ ///
+ public bool IsConfigurationError { get; set; }
+
+ ///
+ /// 获取或设置本次连接是否成功建立。
+ ///
+ public bool ConnectionSucceeded { get; set; }
+
+ ///
+ /// 获取或设置命中的文件路径。
+ ///
+ public string MatchedFilePath { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置错误描述。
+ ///
+ public string ErrorMessage { get; set; } = string.Empty;
+}
+
+///
+/// 表示一次安灯请求。
+///
+public sealed class AndonAlarmRequest
+{
+ ///
+ /// 获取或设置报警类型。
+ ///
+ public string AlarmType { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置报警代码。
+ ///
+ public ushort AlarmCode { get; set; }
+
+ ///
+ /// 获取或设置报警描述。
+ ///
+ public string AlarmMessage { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置条码。
+ ///
+ public string Barcode { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置触发时间。
+ ///
+ public DateTimeOffset TriggeredAt { get; set; } = DateTimeOffset.Now;
+}
+
+///
+/// 表示一次安灯接口调用结果。
+///
+public sealed class AndonOperationResult
+{
+ ///
+ /// 获取或设置调用是否成功。
+ ///
+ public bool IsSuccess { get; set; }
+
+ ///
+ /// 获取或设置终端是否成功到达。
+ ///
+ public bool EndpointReached { get; set; }
+
+ ///
+ /// 获取或设置 HTTP 状态码。
+ ///
+ public int StatusCode { get; set; }
+
+ ///
+ /// 获取或设置响应报文。
+ ///
+ public string ResponseBody { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置错误描述。
+ ///
+ public string ErrorMessage { get; set; } = string.Empty;
+}
+
+///
+/// 表示 UI 中的一条运行日志。
+///
+public sealed class UiLogEntry
+{
+ ///
+ /// 获取或设置日志时间。
+ ///
+ public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.Now;
+
+ ///
+ /// 获取或设置日志级别。
+ ///
+ public string Level { get; set; } = "Info";
+
+ ///
+ /// 获取或设置日志消息。
+ ///
+ public string Message { get; set; } = string.Empty;
+}
+
+///
+/// 表示单板处理结果摘要。
+///
+public sealed class BoardProcessRecord
+{
+ ///
+ /// 获取或设置开始时间。
+ ///
+ public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.Now;
+
+ ///
+ /// 获取或设置完成时间。
+ ///
+ public DateTimeOffset CompletedAt { get; set; } = DateTimeOffset.Now;
+
+ ///
+ /// 获取或设置条码。
+ ///
+ public string Barcode { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置扫码次数。
+ ///
+ public int ScanTryCount { get; set; }
+
+ ///
+ /// 获取或设置 SFTP 查询次数。
+ ///
+ public int SftpTryCount { get; set; }
+
+ ///
+ /// 获取或设置结果代码。
+ ///
+ public ushort ResultCode { get; set; }
+
+ ///
+ /// 获取或设置结果描述。
+ ///
+ public string ResultDescription { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置是否已发送放行。
+ ///
+ public bool ReleaseSent { get; set; }
+
+ ///
+ /// 获取或设置是否已触发报警。
+ ///
+ public bool AlarmRaised { get; set; }
+
+ ///
+ /// 获取或设置异常摘要。
+ ///
+ public string ExceptionSummary { get; set; } = string.Empty;
+}
+
+///
+/// 提供流程状态与 PLC 流程代码之间的映射方法。
+///
+internal static class WorkflowStateExtensions
+{
+ ///
+ /// 将流程状态映射为 PLC 流程状态码。
+ ///
+ /// 待映射的流程状态。
+ /// 对应的流程状态码。
+ public static ushort ToFlowStateCode(this WorkflowState state)
+ {
+ return state switch
+ {
+ WorkflowState.Idle => 0,
+ WorkflowState.Triggered => 1,
+ WorkflowState.Scanning => 2,
+ WorkflowState.ScanRetrying => 3,
+ WorkflowState.CheckingSftp => 4,
+ WorkflowState.WaitingSftpRetry => 5,
+ WorkflowState.Releasing => 6,
+ WorkflowState.Completed => 7,
+ WorkflowState.Faulted => 8,
+ WorkflowState.ScanFailedReleased => 6,
+ WorkflowState.SftpPassed => 6,
+ WorkflowState.SftpTimeoutReleased => 6,
+ _ => 0
+ };
+ }
+
+ ///
+ /// 将流程状态转换为界面显示文本。
+ ///
+ /// 待转换的流程状态。
+ /// 中文状态描述。
+ public static string ToDisplayText(this WorkflowState state)
+ {
+ return state switch
+ {
+ WorkflowState.Idle => "空闲等待",
+ WorkflowState.Triggered => "已触发,准备启动流程",
+ WorkflowState.Scanning => "正在扫码",
+ WorkflowState.ScanRetrying => "扫码失败,等待重试",
+ WorkflowState.ScanFailedReleased => "扫码失败放行",
+ WorkflowState.CheckingSftp => "正在检查 SFTP 文件",
+ WorkflowState.WaitingSftpRetry => "文件未命中,等待重试",
+ WorkflowState.SftpPassed => "文件已找到,准备放行",
+ WorkflowState.SftpTimeoutReleased => "文件未找到超时放行",
+ WorkflowState.Releasing => "正在向 PLC 发送放行",
+ WorkflowState.Completed => "流程已完成",
+ WorkflowState.Faulted => "系统故障",
+ _ => "未知状态"
+ };
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/NLog.config b/src/AxiOmron.PcbCheck/NLog.config
new file mode 100644
index 0000000..5c8d92b
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/NLog.config
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxiOmron.PcbCheck/Options/AppConfig.cs b/src/AxiOmron.PcbCheck/Options/AppConfig.cs
new file mode 100644
index 0000000..8720be7
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Options/AppConfig.cs
@@ -0,0 +1,415 @@
+namespace AxiOmron.PcbCheck.Options;
+
+///
+/// 表示 PCB 目检上位机的根配置对象。
+///
+public sealed class AppConfig
+{
+ ///
+ /// 获取或设置 PLC 通信配置。
+ ///
+ public PlcOptions Plc { get; set; } = new();
+
+ ///
+ /// 获取或设置扫码枪配置。
+ ///
+ public ScannerOptions Scanner { get; set; } = new();
+
+ ///
+ /// 获取或设置 SFTP 校验配置。
+ ///
+ public SftpOptions Sftp { get; set; } = new();
+
+ ///
+ /// 获取或设置安灯接口配置。
+ ///
+ public AndonOptions Andon { get; set; } = new();
+
+ ///
+ /// 获取或设置流程控制配置。
+ ///
+ public WorkflowOptions Workflow { get; set; } = new();
+}
+
+///
+/// 表示 PLC 通信参数与点位映射配置。
+///
+public sealed class PlcOptions
+{
+ ///
+ /// 获取或设置 PLC 主机地址。
+ ///
+ public string Host { get; set; } = "127.0.0.1";
+
+ ///
+ /// 获取或设置 PLC 端口。
+ ///
+ public int Port { get; set; } = 502;
+
+ ///
+ /// 获取或设置 Modbus 从站号。
+ ///
+ public byte UnitId { get; set; } = 1;
+
+ ///
+ /// 获取或设置轮询周期,单位为毫秒。
+ ///
+ public int PollIntervalMs { get; set; } = 200;
+
+ ///
+ /// 获取或设置连接超时,单位为毫秒。
+ ///
+ public int ConnectTimeoutMs { get; set; } = 3000;
+
+ ///
+ /// 获取或设置 PC 在线心跳翻转周期,单位为毫秒。
+ ///
+ public int HeartbeatIntervalMs { get; set; } = 500;
+
+ ///
+ /// 获取或设置放行脉冲持续时间,单位为毫秒。
+ ///
+ public int ReleasePulseMs { get; set; } = 500;
+
+ ///
+ /// 获取或设置放行应答超时,单位为毫秒。
+ ///
+ public int ReleaseAckTimeoutMs { get; set; } = 2000;
+
+ ///
+ /// 获取或设置 PLC 输入点位配置。
+ ///
+ public PlcInputAddressOptions Inputs { get; set; } = new();
+
+ ///
+ /// 获取或设置 PLC 输出点位配置。
+ ///
+ public PlcOutputAddressOptions Outputs { get; set; } = new();
+
+ ///
+ /// 获取或设置 PLC 结果寄存器配置。
+ ///
+ public PlcRegisterAddressOptions Registers { get; set; } = new();
+}
+
+///
+/// 表示扫码枪串口参数配置。
+///
+public sealed class ScannerOptions
+{
+ ///
+ /// 获取或设置串口号。
+ ///
+ public string PortName { get; set; } = "COM1";
+
+ ///
+ /// 获取或设置波特率。
+ ///
+ public int BaudRate { get; set; } = 9600;
+
+ ///
+ /// 获取或设置数据位。
+ ///
+ public int DataBits { get; set; } = 8;
+
+ ///
+ /// 获取或设置校验位名称。
+ ///
+ public string Parity { get; set; } = "None";
+
+ ///
+ /// 获取或设置停止位名称。
+ ///
+ public string StopBits { get; set; } = "One";
+
+ ///
+ /// 获取或设置单次扫码超时,单位为毫秒。
+ ///
+ public int ReadTimeoutMs { get; set; } = 3000;
+
+ ///
+ /// 获取或设置触发命令,支持转义字符。
+ ///
+ public string TriggerCommand { get; set; } = "SCAN\\r";
+
+ ///
+ /// 获取或设置返回报文结束符,支持转义字符。
+ ///
+ public string ResponseTerminator { get; set; } = "\\r";
+
+ ///
+ /// 获取或设置最大扫码尝试次数。
+ ///
+ public int MaxScanAttempts { get; set; } = 3;
+}
+
+///
+/// 表示 SFTP 文件查找配置。
+///
+public sealed class SftpOptions
+{
+ ///
+ /// 获取或设置 SFTP 主机地址。
+ ///
+ public string Host { get; set; } = "127.0.0.1";
+
+ ///
+ /// 获取或设置 SFTP 端口。
+ ///
+ public int Port { get; set; } = 22;
+
+ ///
+ /// 获取或设置登录用户名。
+ ///
+ public string Username { get; set; } = "user";
+
+ ///
+ /// 获取或设置登录密码。
+ ///
+ public string Password { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置私钥文件路径。
+ ///
+ public string PrivateKeyPath { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置私钥口令。
+ ///
+ public string PrivateKeyPassphrase { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置根目录。
+ ///
+ public string RootPath { get; set; } = "/pcb";
+
+ ///
+ /// 获取或设置文件名匹配模板。
+ ///
+ public string FileNamePattern { get; set; } = "${barcode}.txt";
+
+ ///
+ /// 获取或设置首次未命中后的重试间隔,单位为秒。
+ ///
+ public int RetryIntervalSeconds { get; set; } = 2;
+
+ ///
+ /// 获取或设置首次未命中后的最大重试次数。
+ ///
+ public int MaxRetryCount { get; set; } = 3;
+
+ ///
+ /// 获取或设置连接超时,单位为毫秒。
+ ///
+ public int ConnectTimeoutMs { get; set; } = 3000;
+}
+
+///
+/// 表示安灯 HTTP 接口配置。
+///
+public sealed class AndonOptions
+{
+ ///
+ /// 获取或设置是否启用安灯接口。
+ ///
+ public bool Enable { get; set; } = true;
+
+ ///
+ /// 获取或设置安灯接口地址。
+ ///
+ public string Url { get; set; } = "http://127.0.0.1:5000/api/andon";
+
+ ///
+ /// 获取或设置请求方法。
+ ///
+ public string Method { get; set; } = "POST";
+
+ ///
+ /// 获取或设置请求超时,单位为毫秒。
+ ///
+ public int TimeoutMs { get; set; } = 3000;
+
+ ///
+ /// 获取或设置工位编码。
+ ///
+ public string StationCode { get; set; } = "OMRON-01";
+
+ ///
+ /// 获取或设置工位名称。
+ ///
+ public string StationName { get; set; } = "PCB 目检工位";
+
+ ///
+ /// 获取或设置扫码失败报警是否启用。
+ ///
+ public bool EnableScanFailAlarm { get; set; } = true;
+
+ ///
+ /// 获取或设置文件未找到报警是否启用。
+ ///
+ public bool EnableFileNotFoundAlarm { get; set; } = false;
+
+ ///
+ /// 获取或设置附加请求头。
+ ///
+ public Dictionary Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+}
+
+///
+/// 表示流程控制公共配置。
+///
+public sealed class WorkflowOptions
+{
+ ///
+ /// 获取或设置启动流程前是否要求 PLC 就绪。
+ ///
+ public bool RequirePlcReady { get; set; } = true;
+
+ ///
+ /// 获取或设置启动流程前是否要求自动模式。
+ ///
+ public bool RequireAutoMode { get; set; } = true;
+
+ ///
+ /// 获取或设置启动流程前是否要求工位使能。
+ ///
+ public bool RequireStationEnable { get; set; } = true;
+
+ ///
+ /// 获取或设置故障后是否必须人工复位。
+ ///
+ public bool RequireManualResetAfterFault { get; set; } = true;
+
+ ///
+ /// 获取或设置 UI 日志最大保留条数。
+ ///
+ public int MaxUiLogEntries { get; set; } = 200;
+
+ ///
+ /// 获取或设置最近处理记录最大保留条数。
+ ///
+ public int MaxBoardRecords { get; set; } = 100;
+}
+
+///
+/// 表示 PLC 输入点位地址配置。
+///
+public sealed class PlcInputAddressOptions
+{
+ ///
+ /// 获取或设置 PLC 就绪点位地址。
+ ///
+ public int PlcReady { get; set; } = 10001;
+
+ ///
+ /// 获取或设置 PCB 到位点位地址。
+ ///
+ public int PcbArrived { get; set; } = 10002;
+
+ ///
+ /// 获取或设置 PLC 复位点位地址。
+ ///
+ public int PlcReset { get; set; } = 10003;
+
+ ///
+ /// 获取或设置 PLC 放行应答点位地址。
+ ///
+ public int PlcAckRelease { get; set; } = 10004;
+
+ ///
+ /// 获取或设置自动模式点位地址。
+ ///
+ public int AutoMode { get; set; } = 10005;
+
+ ///
+ /// 获取或设置工位使能点位地址。
+ ///
+ public int StationEnable { get; set; } = 10006;
+}
+
+///
+/// 表示 PLC 输出线圈地址配置。
+///
+public sealed class PlcOutputAddressOptions
+{
+ ///
+ /// 获取或设置 PC 在线心跳位地址。
+ ///
+ public int PcOnline { get; set; } = 51;
+
+ ///
+ /// 获取或设置 PC 忙碌位地址。
+ ///
+ public int PcBusy { get; set; } = 52;
+
+ ///
+ /// 获取或设置扫码成功位地址。
+ ///
+ public int ScanOk { get; set; } = 53;
+
+ ///
+ /// 获取或设置扫码失败位地址。
+ ///
+ public int ScanNg { get; set; } = 54;
+
+ ///
+ /// 获取或设置文件存在位地址。
+ ///
+ public int FileFound { get; set; } = 55;
+
+ ///
+ /// 获取或设置文件未找到位地址。
+ ///
+ public int FileNotFound { get; set; } = 56;
+
+ ///
+ /// 获取或设置报警位地址。
+ ///
+ public int AlarmRaised { get; set; } = 57;
+
+ ///
+ /// 获取或设置放行位地址。
+ ///
+ public int ReleasePermit { get; set; } = 58;
+
+ ///
+ /// 获取或设置流程完成位地址。
+ ///
+ public int ProcessDone { get; set; } = 59;
+
+ ///
+ /// 获取或设置系统故障位地址。
+ ///
+ public int SystemFault { get; set; } = 60;
+}
+
+///
+/// 表示 PLC 寄存器地址配置。
+///
+public sealed class PlcRegisterAddressOptions
+{
+ ///
+ /// 获取或设置结果代码寄存器地址。
+ ///
+ public int ResultCode { get; set; } = 40001;
+
+ ///
+ /// 获取或设置扫码次数寄存器地址。
+ ///
+ public int ScanTryCount { get; set; } = 40002;
+
+ ///
+ /// 获取或设置 SFTP 查询次数寄存器地址。
+ ///
+ public int SftpTryCount { get; set; } = 40003;
+
+ ///
+ /// 获取或设置报警代码寄存器地址。
+ ///
+ public int AlarmCode { get; set; } = 40004;
+
+ ///
+ /// 获取或设置流程状态代码寄存器地址。
+ ///
+ public int FlowStateCode { get; set; } = 40005;
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs
new file mode 100644
index 0000000..42f2127
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs
@@ -0,0 +1,118 @@
+using System.Net.Http;
+using System.Net.Http.Json;
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供安灯 HTTP 接口调用能力。
+///
+public sealed class AndonService : IAndonService
+{
+ private readonly AndonOptions _options;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IAppLogger _logger;
+
+ ///
+ /// 初始化安灯服务。
+ ///
+ /// 应用根配置。
+ /// HttpClient 工厂。
+ /// 日志记录器。
+ public AndonService(AppConfig config, IHttpClientFactory httpClientFactory, IAppLogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ _options = config.Andon;
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// 发送安灯报警。
+ ///
+ /// 报警请求对象。
+ /// 取消令牌。
+ /// 报警调用结果。
+ public async Task RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (!_options.Enable || string.IsNullOrWhiteSpace(_options.Url))
+ {
+ return new AndonOperationResult
+ {
+ IsSuccess = false,
+ EndpointReached = false,
+ ErrorMessage = "安灯接口未启用或 URL 未配置。"
+ };
+ }
+
+ try
+ {
+ using var client = _httpClientFactory.CreateClient(nameof(AndonService));
+ client.Timeout = TimeSpan.FromMilliseconds(_options.TimeoutMs);
+ using var message = new HttpRequestMessage(new HttpMethod(_options.Method), _options.Url)
+ {
+ Content = JsonContent.Create(new
+ {
+ stationCode = _options.StationCode,
+ stationName = _options.StationName,
+ alarmType = request.AlarmType,
+ alarmCode = request.AlarmCode,
+ alarmMessage = request.AlarmMessage,
+ barcode = request.Barcode,
+ triggeredAt = request.TriggeredAt,
+ machineName = Environment.MachineName
+ })
+ };
+
+ foreach (var header in _options.Headers)
+ {
+ message.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ return new AndonOperationResult
+ {
+ IsSuccess = response.IsSuccessStatusCode,
+ EndpointReached = true,
+ StatusCode = (int)response.StatusCode,
+ ResponseBody = body,
+ ErrorMessage = response.IsSuccessStatusCode ? string.Empty : $"HTTP {(int)response.StatusCode}: {body}"
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "安灯接口调用失败");
+ return new AndonOperationResult
+ {
+ IsSuccess = false,
+ EndpointReached = false,
+ ErrorMessage = ex.Message
+ };
+ }
+ }
+
+ ///
+ /// 发送一次测试报警请求。
+ ///
+ /// 取消令牌。
+ /// 测试调用结果。
+ public Task TestAsync(CancellationToken cancellationToken)
+ {
+ return RaiseAlarmAsync(new AndonAlarmRequest
+ {
+ AlarmType = "ManualTest",
+ AlarmCode = (ushort)AlarmCode.ScanFailed,
+ AlarmMessage = "手动测试安灯接口",
+ Barcode = string.Empty,
+ TriggeredAt = DateTimeOffset.Now
+ }, cancellationToken);
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AppConfigService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AppConfigService.cs
new file mode 100644
index 0000000..8fc88a4
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/AppConfigService.cs
@@ -0,0 +1,65 @@
+using System.IO;
+using System.Text.Json;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供应用配置文件的读取与保存能力。
+///
+public sealed class AppConfigService : IAppConfigService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true
+ };
+
+ ///
+ /// 读取当前应用配置。
+ ///
+ /// 根配置对象。
+ public AppConfig Load()
+ {
+ var configPath = GetConfigPath();
+ if (!File.Exists(configPath))
+ {
+ var defaultConfig = new AppConfig();
+ Save(defaultConfig);
+ return defaultConfig;
+ }
+
+ var json = File.ReadAllText(configPath);
+ var config = JsonSerializer.Deserialize(json, JsonOptions);
+ return config ?? new AppConfig();
+ }
+
+ ///
+ /// 保存当前应用配置。
+ ///
+ /// 待保存的配置对象。
+ public void Save(AppConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+
+ var configPath = GetConfigPath();
+ var directory = Path.GetDirectoryName(configPath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var json = JsonSerializer.Serialize(config, JsonOptions);
+ File.WriteAllText(configPath, json);
+ }
+
+ ///
+ /// 获取主配置文件路径。
+ ///
+ /// 配置文件绝对路径。
+ public string GetConfigPath()
+ {
+ return Path.Combine(AppContext.BaseDirectory, "appConfig.json");
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs
new file mode 100644
index 0000000..9a41415
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs
@@ -0,0 +1,154 @@
+using System.Globalization;
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Services.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供应用统一日志能力,并在需要时同步前台 UI 日志。
+///
+/// 日志分类类型。
+public sealed class AppLogger : IAppLogger
+{
+ private readonly ILogger _logger;
+ private readonly IAppStateStore _stateStore;
+
+ ///
+ /// 初始化统一日志服务。
+ ///
+ /// 底层标准日志记录器。
+ /// 运行态存储。
+ public AppLogger(ILogger logger, IAppStateStore stateStore)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
+ }
+
+ ///
+ /// 记录一条信息日志。
+ ///
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。
+ /// 日志模板参数。
+ public void LogInformation(string message, bool showInUi = false, params object?[] args)
+ {
+ _logger.LogInformation(message, args);
+ PublishUiLog(LogLevel.Information, message, null, showInUi, args);
+ }
+
+ ///
+ /// 记录一条警告日志。
+ ///
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。
+ /// 日志模板参数。
+ public void LogWarning(string message, bool showInUi = false, params object?[] args)
+ {
+ _logger.LogWarning(message, args);
+ PublishUiLog(LogLevel.Warning, message, null, showInUi, args);
+ }
+
+ ///
+ /// 记录一条带异常的警告日志。
+ ///
+ /// 异常对象。
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。
+ /// 日志模板参数。
+ /// 当 为 时抛出。
+ public void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ _logger.LogWarning(exception, message, args);
+ PublishUiLog(LogLevel.Warning, message, exception, showInUi, args);
+ }
+
+ ///
+ /// 记录一条错误日志。
+ ///
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。
+ /// 日志模板参数。
+ public void LogError(string message, bool showInUi = false, params object?[] args)
+ {
+ _logger.LogError(message, args);
+ PublishUiLog(LogLevel.Error, message, null, showInUi, args);
+ }
+
+ ///
+ /// 记录一条带异常的错误日志。
+ ///
+ /// 异常对象。
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。
+ /// 日志模板参数。
+ /// 当 为 时抛出。
+ public void LogError(Exception exception, string message, bool showInUi = false, params object?[] args)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ _logger.LogError(exception, message, args);
+ PublishUiLog(LogLevel.Error, message, exception, showInUi, args);
+ }
+
+ ///
+ /// 按需向前台运行态发布日志。
+ ///
+ /// 日志级别。
+ /// 日志消息模板或文本。
+ /// 异常对象。
+ /// 是否显示到前台。
+ /// 日志模板参数。
+ private void PublishUiLog(LogLevel logLevel, string message, Exception? exception, bool showInUi, params object?[] args)
+ {
+ if (!showInUi)
+ {
+ return;
+ }
+
+ var formattedMessage = FormatMessage(message, args);
+ if (exception is not null)
+ {
+ formattedMessage = string.IsNullOrWhiteSpace(formattedMessage)
+ ? exception.Message
+ : $"{formattedMessage}: {exception.Message}";
+ }
+
+ _stateStore.AddLog(new UiLogEntry
+ {
+ Level = logLevel.ToString(),
+ Message = formattedMessage,
+ Timestamp = DateTimeOffset.Now
+ });
+ }
+
+ ///
+ /// 将日志模板与参数格式化为可展示文本。
+ ///
+ /// 日志消息模板或文本。
+ /// 日志模板参数。
+ /// 格式化后的文本。
+ private static string FormatMessage(string message, params object?[] args)
+ {
+ if (args.Length == 0)
+ {
+ return message;
+ }
+
+ var formattedMessage = message;
+ for (var index = 0; index < args.Length; index++)
+ {
+ var replacement = Convert.ToString(args[index], CultureInfo.InvariantCulture) ?? string.Empty;
+ var tokenStart = formattedMessage.IndexOf('{', StringComparison.Ordinal);
+ var tokenEnd = tokenStart >= 0 ? formattedMessage.IndexOf('}', tokenStart + 1) : -1;
+ if (tokenStart < 0 || tokenEnd <= tokenStart)
+ {
+ break;
+ }
+
+ formattedMessage = formattedMessage.Remove(tokenStart, tokenEnd - tokenStart + 1).Insert(tokenStart, replacement);
+ }
+
+ return formattedMessage;
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs
new file mode 100644
index 0000000..8ef5643
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs
@@ -0,0 +1,79 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供运行态快照、日志与单板记录的线程安全存储能力。
+///
+public sealed class AppStateStore : IAppStateStore
+{
+ private readonly object _syncRoot = new();
+ private RuntimeSnapshot _snapshot = new();
+
+ ///
+ /// 当运行态快照变化时触发。
+ ///
+ public event EventHandler? SnapshotChanged;
+
+ ///
+ /// 当新增日志时触发。
+ ///
+ public event EventHandler? LogAdded;
+
+ ///
+ /// 当新增单板记录时触发。
+ ///
+ public event EventHandler? RecordAdded;
+
+ ///
+ /// 获取当前运行态快照副本。
+ ///
+ /// 当前快照副本。
+ public RuntimeSnapshot GetSnapshot()
+ {
+ lock (_syncRoot)
+ {
+ return _snapshot.Clone();
+ }
+ }
+
+ ///
+ /// 更新当前运行态快照。
+ ///
+ /// 用于修改快照的更新委托。
+ public void UpdateSnapshot(Action updateAction)
+ {
+ ArgumentNullException.ThrowIfNull(updateAction);
+
+ RuntimeSnapshot clonedSnapshot;
+ lock (_syncRoot)
+ {
+ updateAction(_snapshot);
+ _snapshot.LastUpdatedAt = DateTimeOffset.Now;
+ clonedSnapshot = _snapshot.Clone();
+ }
+
+ SnapshotChanged?.Invoke(this, clonedSnapshot);
+ }
+
+ ///
+ /// 追加一条 UI 日志。
+ ///
+ /// 待追加的日志对象。
+ public void AddLog(UiLogEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ LogAdded?.Invoke(this, entry);
+ }
+
+ ///
+ /// 追加一条单板结果记录。
+ ///
+ /// 待追加的记录对象。
+ public void AddRecord(BoardProcessRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ RecordAdded?.Invoke(this, record);
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/DispatcherService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/DispatcherService.cs
new file mode 100644
index 0000000..fe58df8
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/DispatcherService.cs
@@ -0,0 +1,23 @@
+using System.Windows.Threading;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供切回 WPF UI 线程的调度能力。
+///
+public sealed class DispatcherService : IDispatcherService
+{
+ ///
+ /// 在 UI 线程中执行指定动作。
+ ///
+ /// 待执行的动作。
+ /// 表示调度完成的任务。
+ public Task InvokeAsync(Action action)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+
+ var dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
+ return dispatcher.InvokeAsync(action).Task;
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs
new file mode 100644
index 0000000..2d79565
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs
@@ -0,0 +1,257 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+using IoTClient.Clients.Modbus;
+using IoTClient.Enums;
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供基于 IoTClient ModbusTcpClient 的 PLC 读写能力。
+///
+public sealed class ModbusTcpPlcService : IPlcService, IDisposable
+{
+ private readonly PlcOptions _options;
+ private readonly IAppLogger _logger;
+ private readonly SemaphoreSlim _ioLock = new(1, 1);
+ private ModbusTcpClient? _client;
+ private PlcProcessState? _lastWrittenState;
+ private bool _disposed;
+
+ ///
+ /// 初始化 PLC 通信服务。
+ ///
+ /// 应用根配置。
+ /// 日志记录器。
+ public ModbusTcpPlcService(AppConfig config, IAppLogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ _options = config.Plc;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// 读取 PLC 输入信号快照。
+ ///
+ /// 取消令牌。
+ /// 输入信号快照。
+ public async Task ReadSignalsAsync(CancellationToken cancellationToken)
+ {
+ await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ EnsureConnected();
+
+ return new PlcSignalSnapshot
+ {
+ PlcReady = ReadDiscrete(_options.Inputs.PlcReady),
+ PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived),
+ PlcReset = ReadDiscrete(_options.Inputs.PlcReset),
+ PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease),
+ AutoMode = ReadDiscrete(_options.Inputs.AutoMode),
+ StationEnable = ReadDiscrete(_options.Inputs.StationEnable),
+ CapturedAt = DateTimeOffset.Now
+ };
+ }
+ catch
+ {
+ DisconnectUnsafe();
+ throw;
+ }
+ finally
+ {
+ _ioLock.Release();
+ }
+ }
+
+ ///
+ /// 写入 PLC 输出状态与寄存器值。
+ ///
+ /// 待写入的输出状态。
+ /// 取消令牌。
+ /// 表示写入完成的任务。
+ public async Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+
+ await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ EnsureConnected();
+ WriteChangedState(state);
+ _lastWrittenState = state.Clone();
+ }
+ catch
+ {
+ DisconnectUnsafe();
+ throw;
+ }
+ finally
+ {
+ _ioLock.Release();
+ }
+ }
+
+ ///
+ /// 主动断开并重建 PLC 连接。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ public async Task ForceReconnectAsync(CancellationToken cancellationToken)
+ {
+ await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DisconnectUnsafe();
+ _lastWrittenState = null;
+ EnsureConnected();
+ }
+ finally
+ {
+ _ioLock.Release();
+ }
+ }
+
+ ///
+ /// 释放 PLC 通信资源。
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ DisconnectUnsafe();
+ _ioLock.Dispose();
+ }
+
+ ///
+ /// 确保 IoTClient Modbus TCP 客户端已连接。
+ ///
+ private void EnsureConnected()
+ {
+ if (_client is { Connected: true })
+ {
+ return;
+ }
+
+ DisconnectUnsafe();
+ var client = new ModbusTcpClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD, false);
+ var openResult = client.Open();
+ EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 PLC 失败");
+ _client = client;
+ _logger.LogInformation("已通过 IoTClient 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port);
+ }
+
+ ///
+ /// 读取单个离散输入位。
+ ///
+ /// 离散输入地址。
+ /// 读取到的布尔值。
+ private bool ReadDiscrete(int address)
+ {
+ var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
+ var result = client.ReadDiscrete(address, _options.UnitId, 2);
+ EnsureSuccess(result.IsSucceed, result.Err, $"读取离散输入失败,地址={address}");
+ return result.Value;
+ }
+
+ ///
+ /// 写入所有发生变化的线圈位与寄存器。
+ ///
+ /// 目标状态。
+ private void WriteChangedState(PlcProcessState state)
+ {
+ var previous = _lastWrittenState;
+
+ WriteSingleCoilIfChanged(previous?.PcOnline, state.PcOnline, _options.Outputs.PcOnline);
+ WriteSingleCoilIfChanged(previous?.PcBusy, state.PcBusy, _options.Outputs.PcBusy);
+ WriteSingleCoilIfChanged(previous?.ScanOk, state.ScanOk, _options.Outputs.ScanOk);
+ WriteSingleCoilIfChanged(previous?.ScanNg, state.ScanNg, _options.Outputs.ScanNg);
+ WriteSingleCoilIfChanged(previous?.FileFound, state.FileFound, _options.Outputs.FileFound);
+ WriteSingleCoilIfChanged(previous?.FileNotFound, state.FileNotFound, _options.Outputs.FileNotFound);
+ WriteSingleCoilIfChanged(previous?.AlarmRaised, state.AlarmRaised, _options.Outputs.AlarmRaised);
+ WriteSingleCoilIfChanged(previous?.ReleasePermit, state.ReleasePermit, _options.Outputs.ReleasePermit);
+ WriteSingleCoilIfChanged(previous?.ProcessDone, state.ProcessDone, _options.Outputs.ProcessDone);
+ WriteSingleCoilIfChanged(previous?.SystemFault, state.SystemFault, _options.Outputs.SystemFault);
+
+ WriteSingleRegisterIfChanged(previous?.ResultCode, state.ResultCode, _options.Registers.ResultCode);
+ WriteSingleRegisterIfChanged(previous?.ScanTryCount, state.ScanTryCount, _options.Registers.ScanTryCount);
+ WriteSingleRegisterIfChanged(previous?.SftpTryCount, state.SftpTryCount, _options.Registers.SftpTryCount);
+ WriteSingleRegisterIfChanged(previous?.AlarmCode, state.AlarmCode, _options.Registers.AlarmCode);
+ WriteSingleRegisterIfChanged(previous?.FlowStateCode, state.FlowStateCode, _options.Registers.FlowStateCode);
+ }
+
+ ///
+ /// 仅当值发生变化时写入单个线圈位。
+ ///
+ /// 上一值。
+ /// 当前值。
+ /// 线圈地址。
+ private void WriteSingleCoilIfChanged(bool? previous, bool current, int address)
+ {
+ if (previous.HasValue && previous.Value == current)
+ {
+ return;
+ }
+
+ var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
+ var result = client.Write(address.ToString(), current, _options.UnitId, 5);
+ EnsureSuccess(result.IsSucceed, result.Err, $"写入线圈失败,地址={address},值={current}");
+ }
+
+ ///
+ /// 仅当值发生变化时写入单个保持寄存器。
+ ///
+ /// 上一值。
+ /// 当前值。
+ /// 保持寄存器地址。
+ private void WriteSingleRegisterIfChanged(ushort? previous, ushort current, int address)
+ {
+ if (previous.HasValue && previous.Value == current)
+ {
+ return;
+ }
+
+ var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
+ var result = client.Write(address.ToString(), current, _options.UnitId, 6);
+ EnsureSuccess(result.IsSucceed, result.Err, $"写入保持寄存器失败,地址={address},值={current}");
+ }
+
+ ///
+ /// 校验 IoTClient 调用结果是否成功。
+ ///
+ /// 调用是否成功。
+ /// 错误消息。
+ /// 异常消息前缀。
+ private static void EnsureSuccess(bool isSucceed, string? error, string message)
+ {
+ if (!isSucceed)
+ {
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? message : $"{message}: {error}");
+ }
+ }
+
+ ///
+ /// 断开当前 PLC 客户端连接。
+ ///
+ private void DisconnectUnsafe()
+ {
+ try
+ {
+ _client?.Close();
+ }
+ catch
+ {
+ // 忽略关闭异常。
+ }
+ finally
+ {
+ _client = null;
+ }
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs
new file mode 100644
index 0000000..80d3972
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs
@@ -0,0 +1,319 @@
+using System.Diagnostics;
+using System.IO;
+using System.IO.Ports;
+using System.Text;
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+using AxiOmron.PcbCheck.Utils;
+
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供串口扫码枪的触发与读取能力。
+///
+public sealed class SerialScannerService : IScannerService, IDisposable
+{
+ private readonly ScannerOptions _options;
+ private readonly IAppLogger _logger;
+ private readonly SemaphoreSlim _ioLock = new(1, 1);
+ private SerialPort? _serialPort;
+ private bool _disposed;
+
+ ///
+ /// 初始化扫码枪服务。
+ ///
+ /// 应用根配置。
+ /// 日志记录器。
+ public SerialScannerService(AppConfig config, IAppLogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ _options = config.Scanner;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// 触发一次扫码。
+ ///
+ /// 取消令牌。
+ /// 扫码结果。
+ public async Task TriggerScanAsync(CancellationToken cancellationToken)
+ {
+ var lockTaken = false;
+ try
+ {
+ await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ lockTaken = true;
+ return await TriggerScanInternalAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "扫码操作失败");
+ return new ScanOperationResult
+ {
+ IsSuccess = false,
+ IsSystemError = ex is not OperationCanceledException,
+ DeviceConnected = false,
+ ErrorMessage = ex.Message
+ };
+ }
+ finally
+ {
+ if (lockTaken)
+ {
+ _ioLock.Release();
+ }
+ }
+ }
+
+ ///
+ /// 测试扫码枪连接。
+ ///
+ /// 取消令牌。
+ /// 连接正常返回 ;否则返回 。
+ public async Task TestConnectionAsync(CancellationToken cancellationToken)
+ {
+ var lockTaken = false;
+ try
+ {
+ await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ lockTaken = true;
+ cancellationToken.ThrowIfCancellationRequested();
+ EnsurePortOpen();
+ return _serialPort is { IsOpen: true };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "扫码枪连接测试失败");
+ ClosePortUnsafe();
+ return false;
+ }
+ finally
+ {
+ if (lockTaken)
+ {
+ _ioLock.Release();
+ }
+ }
+ }
+
+ ///
+ /// 主动断开并重建扫码枪连接。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ public async Task ForceReconnectAsync(CancellationToken cancellationToken)
+ {
+ var lockTaken = false;
+ try
+ {
+ await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ lockTaken = true;
+ cancellationToken.ThrowIfCancellationRequested();
+ ClosePortUnsafe();
+ EnsurePortOpen();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "扫码枪强制重连失败");
+ ClosePortUnsafe();
+ }
+ finally
+ {
+ if (lockTaken)
+ {
+ _ioLock.Release();
+ }
+ }
+ }
+
+ ///
+ /// 释放扫码枪串口资源。
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ ClosePortUnsafe();
+ _ioLock.Dispose();
+ }
+
+ ///
+ /// 在异步上下文中执行一次扫码流程。
+ ///
+ /// 取消令牌。
+ /// 扫码结果。
+ private async Task TriggerScanInternalAsync(CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ try
+ {
+ EnsurePortOpen();
+ var port = _serialPort ?? throw new InvalidOperationException("扫码枪串口尚未初始化。");
+ port.DiscardInBuffer();
+ port.DiscardOutBuffer();
+ port.Write(StringEscapeHelper.Unescape(_options.TriggerCommand));
+
+ var rawMessage = await ReadUntilTerminatorAsync(
+ port,
+ StringEscapeHelper.Unescape(_options.ResponseTerminator),
+ _options.ReadTimeoutMs,
+ cancellationToken).ConfigureAwait(false);
+ var barcode = BarcodeCleaner.Clean(rawMessage);
+ if (string.IsNullOrEmpty(barcode))
+ {
+ return new ScanOperationResult
+ {
+ IsSuccess = false,
+ DeviceConnected = true,
+ RawMessage = rawMessage,
+ ErrorMessage = "扫码返回空字符串或仅包含控制字符。",
+ DurationMs = stopwatch.ElapsedMilliseconds
+ };
+ }
+
+ return new ScanOperationResult
+ {
+ IsSuccess = true,
+ DeviceConnected = true,
+ Barcode = barcode,
+ RawMessage = rawMessage,
+ DurationMs = stopwatch.ElapsedMilliseconds
+ };
+ }
+ catch (TimeoutException ex)
+ {
+ _logger.LogWarning(ex, "扫码等待超时");
+ return new ScanOperationResult
+ {
+ IsSuccess = false,
+ DeviceConnected = true,
+ ErrorMessage = "扫码超时。",
+ DurationMs = stopwatch.ElapsedMilliseconds
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "扫码枪执行失败");
+ ClosePortUnsafe();
+ return new ScanOperationResult
+ {
+ IsSuccess = false,
+ IsSystemError = true,
+ DeviceConnected = false,
+ ErrorMessage = ex.Message,
+ DurationMs = stopwatch.ElapsedMilliseconds
+ };
+ }
+ }
+
+ ///
+ /// 确保串口已打开并按当前配置初始化。
+ ///
+ private void EnsurePortOpen()
+ {
+ if (_serialPort is { IsOpen: true })
+ {
+ return;
+ }
+
+ ClosePortUnsafe();
+
+ var availablePorts = SerialPort.GetPortNames();
+ if (!availablePorts.Contains(_options.PortName, StringComparer.OrdinalIgnoreCase))
+ {
+ _logger.LogWarning("本地不存在串口 {PortName},可用串口: {AvailablePorts}", false, _options.PortName, string.Join(", ", availablePorts));
+ throw new IOException($"串口 {_options.PortName} 不存在。可用串口: {string.Join(", ", availablePorts)}");
+ }
+
+ var parity = Enum.Parse(_options.Parity, true);
+ var stopBits = Enum.Parse(_options.StopBits, true);
+ var serialPort = new SerialPort(_options.PortName, _options.BaudRate, parity, _options.DataBits, stopBits)
+ {
+ ReadTimeout = 200,
+ WriteTimeout = 1000,
+ Encoding = Encoding.ASCII,
+ DtrEnable = true,
+ RtsEnable = true,
+ NewLine = StringEscapeHelper.Unescape(_options.ResponseTerminator)
+ };
+
+ try
+ {
+ serialPort.Open();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "打开串口 {PortName} 失败", false, _options.PortName);
+ serialPort.Dispose();
+ throw;
+ }
+
+ _serialPort = serialPort;
+ _logger.LogInformation("已连接扫码枪串口 {PortName}", false, _options.PortName);
+ }
+
+ ///
+ /// 从串口读取直到遇到终止符或超时。
+ ///
+ /// 串口实例。
+ /// 终止符。
+ /// 总超时时间,单位为毫秒。
+ /// 取消令牌。
+ /// 读取到的原始字符串。
+ private static async Task ReadUntilTerminatorAsync(SerialPort port, string terminator, int timeoutMs, CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var buffer = new StringBuilder();
+
+ while (stopwatch.ElapsedMilliseconds < timeoutMs)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var fragment = port.ReadExisting();
+ if (!string.IsNullOrEmpty(fragment))
+ {
+ buffer.Append(fragment);
+ if (string.IsNullOrEmpty(terminator) || buffer.ToString().Contains(terminator, StringComparison.Ordinal))
+ {
+ return buffer.ToString();
+ }
+ }
+
+ await Task.Delay(20, cancellationToken).ConfigureAwait(false);
+ }
+
+ throw new TimeoutException("扫码枪在规定时间内未返回完整报文。");
+ }
+
+ ///
+ /// 关闭当前串口并释放资源。
+ ///
+ private void ClosePortUnsafe()
+ {
+ try
+ {
+ if (_serialPort?.IsOpen == true)
+ {
+ _serialPort.Close();
+ }
+ }
+ catch
+ {
+ // 忽略关闭异常。
+ }
+ finally
+ {
+ _serialPort?.Dispose();
+ _serialPort = null;
+ }
+ }
+}
+
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs
new file mode 100644
index 0000000..03239a4
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs
@@ -0,0 +1,200 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+using AxiOmron.PcbCheck.Utils;
+using Renci.SshNet;
+using Renci.SshNet.Common;
+
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供 SFTP 文件存在性校验能力。
+///
+public sealed class SftpLookupService : ISftpLookupService
+{
+ private readonly SftpOptions _options;
+ private readonly IAppLogger _logger;
+
+ ///
+ /// 初始化 SFTP 校验服务。
+ ///
+ /// 应用根配置。
+ /// 日志记录器。
+ public SftpLookupService(AppConfig config, IAppLogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ _options = config.Sftp;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// 按条码检查目标文件是否存在。
+ ///
+ /// 条码内容。
+ /// 取消令牌。
+ /// 文件校验结果。
+ public async Task CheckFileAsync(string barcode, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(barcode))
+ {
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ IsConfigurationError = true,
+ ErrorMessage = "条码为空,无法执行 SFTP 查询。"
+ };
+ }
+
+ if (string.IsNullOrWhiteSpace(_options.Host) || string.IsNullOrWhiteSpace(_options.RootPath))
+ {
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ IsConfigurationError = true,
+ ErrorMessage = "SFTP 配置缺失 Host 或 RootPath。"
+ };
+ }
+
+ return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// 在同步上下文中执行 SFTP 查询。
+ ///
+ /// 条码。
+ /// 取消令牌。
+ /// 查询结果。
+ private SftpCheckOutcome CheckInternal(string barcode, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ using var client = CreateClient();
+ client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs);
+ client.Connect();
+
+ if (!client.IsConnected)
+ {
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ IsSystemError = true,
+ ErrorMessage = "SFTP 未能建立连接。"
+ };
+ }
+
+ var candidateName = BuildExpectedFileName(barcode);
+ var rootPath = NormalizeDirectory(_options.RootPath);
+ if (!client.Exists(rootPath))
+ {
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ IsConfigurationError = true,
+ ConnectionSucceeded = true,
+ ErrorMessage = $"SFTP 根目录不存在: {rootPath}"
+ };
+ }
+
+ var matched = client.ListDirectory(rootPath)
+ .Where(entry => !entry.IsDirectory && !entry.IsSymbolicLink)
+ .FirstOrDefault(entry =>
+ entry.Name.Equals(candidateName, StringComparison.OrdinalIgnoreCase)
+ || WildcardMatcher.IsMatch(entry.Name, candidateName)
+ || entry.Name.Contains(barcode, StringComparison.OrdinalIgnoreCase));
+
+ if (matched is null)
+ {
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ ConnectionSucceeded = true,
+ ErrorMessage = $"未找到与条码 {barcode} 匹配的文件。"
+ };
+ }
+
+ return new SftpCheckOutcome
+ {
+ Exists = true,
+ ConnectionSucceeded = true,
+ MatchedFilePath = matched.FullName
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (SshAuthenticationException ex)
+ {
+ _logger.LogError(ex, "SFTP 认证失败");
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ IsSystemError = true,
+ ErrorMessage = $"SFTP 认证失败: {ex.Message}"
+ };
+ }
+ catch (SshConnectionException ex)
+ {
+ _logger.LogError(ex, "SFTP 连接失败");
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ IsSystemError = true,
+ ErrorMessage = $"SFTP 连接失败: {ex.Message}"
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "SFTP 查询异常");
+ return new SftpCheckOutcome
+ {
+ Exists = false,
+ IsSystemError = true,
+ ErrorMessage = ex.Message
+ };
+ }
+ }
+
+ ///
+ /// 根据当前配置创建 SFTP 客户端。
+ ///
+ /// SFTP 客户端实例。
+ private SftpClient CreateClient()
+ {
+ if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPath))
+ {
+ var privateKeyFile = string.IsNullOrWhiteSpace(_options.PrivateKeyPassphrase)
+ ? new PrivateKeyFile(_options.PrivateKeyPath)
+ : new PrivateKeyFile(_options.PrivateKeyPath, _options.PrivateKeyPassphrase);
+
+ var keyAuth = new PrivateKeyAuthenticationMethod(_options.Username, privateKeyFile);
+ var connectionInfo = new ConnectionInfo(_options.Host, _options.Port, _options.Username, keyAuth);
+ return new SftpClient(connectionInfo);
+ }
+
+ return new SftpClient(_options.Host, _options.Port, _options.Username, _options.Password);
+ }
+
+ ///
+ /// 根据条码和模板构建预期文件名。
+ ///
+ /// 条码。
+ /// 预期文件名或匹配模式。
+ private string BuildExpectedFileName(string barcode)
+ {
+ var pattern = string.IsNullOrWhiteSpace(_options.FileNamePattern) ? "${barcode}.txt" : _options.FileNamePattern;
+ return pattern.Replace("${barcode}", barcode, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// 统一目录路径格式。
+ ///
+ /// 原始目录路径。
+ /// 标准化目录路径。
+ private static string NormalizeDirectory(string path)
+ {
+ return path.Replace('\\', '/').TrimEnd('/');
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs
new file mode 100644
index 0000000..55c09ef
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs
@@ -0,0 +1,1047 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+using Microsoft.Extensions.Hosting;
+
+namespace AxiOmron.PcbCheck.Services.Implementations;
+
+///
+/// 提供单板串行流程状态机与后台轮询能力。
+///
+public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlService
+{
+ private readonly IPlcService _plcService;
+ private readonly IScannerService _scannerService;
+ private readonly ISftpLookupService _sftpLookupService;
+ private readonly IAndonService _andonService;
+ private readonly IAppStateStore _stateStore;
+ private readonly AppConfig _config;
+ private readonly IAppLogger _appLogger;
+ private readonly SemaphoreSlim _workflowSemaphore = new(1, 1);
+ private readonly SemaphoreSlim _manualActionLock = new(1, 1);
+ private readonly SemaphoreSlim _plcWriteLock = new(1, 1);
+ private readonly object _stateSyncRoot = new();
+ private PlcProcessState _plcState = new();
+ private bool _heartbeatState;
+ private DateTimeOffset _lastHeartbeatToggleAt = DateTimeOffset.MinValue;
+ private bool _faultLatched;
+ private bool _processingActive;
+ private bool _lastPcbArrived;
+ private bool _releaseSent;
+ private DateTimeOffset? _currentBoardStartedAt;
+ private string _currentBarcode = string.Empty;
+ private int _scanTryCount;
+ private int _sftpTryCount;
+ private WorkflowResultCode _resultCode = WorkflowResultCode.None;
+ private AlarmCode _alarmCode = AlarmCode.None;
+ private CancellationTokenSource? _activeWorkflowCts;
+ private Task? _activeWorkflowTask;
+
+ ///
+ /// 初始化流程后台服务。
+ ///
+ /// PLC 服务。
+ /// 扫码枪服务。
+ /// SFTP 校验服务。
+ /// 安灯服务。
+ /// 运行态存储。
+ /// 应用配置。
+ /// 日志记录器。
+ public WorkflowHostedService(
+ IPlcService plcService,
+ IScannerService scannerService,
+ ISftpLookupService sftpLookupService,
+ IAndonService andonService,
+ IAppStateStore stateStore,
+ AppConfig config,
+ IAppLogger appLogger)
+ {
+ _plcService = plcService ?? throw new ArgumentNullException(nameof(plcService));
+ _scannerService = scannerService ?? throw new ArgumentNullException(nameof(scannerService));
+ _sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService));
+ _andonService = andonService ?? throw new ArgumentNullException(nameof(andonService));
+ _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
+ _config = config ?? throw new ArgumentNullException(nameof(config));
+ _appLogger = appLogger ?? throw new ArgumentNullException(nameof(appLogger));
+ }
+
+ ///
+ /// 执行后台轮询主循环。
+ ///
+ /// 停止令牌。
+ /// 表示后台循环结束的任务。
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
+ PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await UpdateHeartbeatIfDueAsync(stoppingToken).ConfigureAwait(false);
+ var signals = await _plcService.ReadSignalsAsync(stoppingToken).ConfigureAwait(false);
+ HandleSignalSnapshot(signals);
+
+ if (signals.PlcReset)
+ {
+ await ResetAsync(stoppingToken).ConfigureAwait(false);
+ }
+ else if (ShouldStartWorkflow(signals))
+ {
+ StartWorkflowInBackground(stoppingToken);
+ }
+
+ lock (_stateSyncRoot)
+ {
+ _lastPcbArrived = signals.PcbArrived;
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _appLogger.LogError(ex, "后台流程轮询异常");
+ await EnterFaultedAsync(WorkflowResultCode.PlcCommunicationFault, AlarmCode.PlcFault, $"后台轮询异常: {ex.Message}", CancellationToken.None).ConfigureAwait(false);
+ }
+
+ await Task.Delay(_config.Plc.PollIntervalMs, stoppingToken).ConfigureAwait(false);
+ }
+
+ await CancelActiveWorkflowAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ ///
+ /// 手动复位流程状态。
+ ///
+ /// 取消令牌。
+ /// 表示复位完成的任务。
+ public async Task ResetAsync(CancellationToken cancellationToken)
+ {
+ await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false);
+ ResetProcessStateCore();
+ await WritePlcStateAsync(state =>
+ {
+ ResetResultBits(state);
+ state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
+ }, cancellationToken).ConfigureAwait(false);
+ PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位");
+ _appLogger.LogInformation("已执行流程复位。", true);
+ }
+ finally
+ {
+ _manualActionLock.Release();
+ }
+ }
+
+ ///
+ /// 手动重连 PLC。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ public async Task ReconnectPlcAsync(CancellationToken cancellationToken)
+ {
+ await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _plcService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
+ UpdateSnapshot(snapshot => snapshot.PlcStatus = "已重连");
+ _appLogger.LogInformation("PLC 已手动重连。", true);
+ }
+ finally
+ {
+ _manualActionLock.Release();
+ }
+ }
+
+ ///
+ /// 手动重连扫码枪。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ public async Task ReconnectScannerAsync(CancellationToken cancellationToken)
+ {
+ await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _scannerService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
+ UpdateSnapshot(snapshot => snapshot.ScannerStatus = "已重连");
+ _appLogger.LogInformation("扫码枪已手动重连。", true);
+ }
+ finally
+ {
+ _manualActionLock.Release();
+ }
+ }
+
+ ///
+ /// 手动测试安灯接口。
+ ///
+ /// 取消令牌。
+ /// 表示测试完成的任务。
+ public async Task TestAndonAsync(CancellationToken cancellationToken)
+ {
+ await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var result = await _andonService.TestAsync(cancellationToken).ConfigureAwait(false);
+ var status = result.IsSuccess ? "测试成功" : $"测试失败: {result.ErrorMessage}";
+ UpdateSnapshot(snapshot => snapshot.AndonStatus = status);
+ _appLogger.LogInformation($"安灯接口手动测试结果: {status}", true);
+ }
+ finally
+ {
+ _manualActionLock.Release();
+ }
+ }
+
+ ///
+ /// 根据输入快照刷新 UI 状态。
+ ///
+ /// PLC 输入快照。
+ private void HandleSignalSnapshot(PlcSignalSnapshot signals)
+ {
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.PlcStatus = signals.PlcReady ? "已连接 / PLC 就绪" : "已连接 / PLC 未就绪";
+ if (!_processingActive && signals.AutoMode && signals.StationEnable)
+ {
+ snapshot.ResultDescription = "满足接板条件";
+ }
+ });
+ }
+
+ ///
+ /// 判断当前是否允许启动新一块板的处理流程。
+ ///
+ /// PLC 输入快照。
+ /// 满足启动条件返回 ;否则返回 。
+ private bool ShouldStartWorkflow(PlcSignalSnapshot signals)
+ {
+ lock (_stateSyncRoot)
+ {
+ if (_processingActive || _faultLatched)
+ {
+ return false;
+ }
+
+ if (!signals.PcbArrived || _lastPcbArrived)
+ {
+ return false;
+ }
+
+ if (_config.Workflow.RequirePlcReady && !signals.PlcReady)
+ {
+ return false;
+ }
+
+ if (_config.Workflow.RequireAutoMode && !signals.AutoMode)
+ {
+ return false;
+ }
+
+ if (_config.Workflow.RequireStationEnable && !signals.StationEnable)
+ {
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ ///
+ /// 在后台启动单板流程而不阻塞主轮询。
+ ///
+ /// 应用停止令牌。
+ private void StartWorkflowInBackground(CancellationToken stoppingToken)
+ {
+ CancellationTokenSource workflowCts;
+ lock (_stateSyncRoot)
+ {
+ if (_processingActive || _faultLatched)
+ {
+ return;
+ }
+
+ _processingActive = true;
+ workflowCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
+ _activeWorkflowCts = workflowCts;
+ }
+
+ var workflowTask = RunWorkflowOnceAsync(workflowCts.Token);
+ lock (_stateSyncRoot)
+ {
+ _activeWorkflowTask = workflowTask;
+ }
+
+ _ = ObserveWorkflowTaskAsync(workflowTask, workflowCts, stoppingToken);
+ }
+
+ ///
+ /// 观察后台流程任务并统一处理收尾逻辑。
+ ///
+ /// 流程任务。
+ /// 流程取消源。
+ /// 应用停止令牌。
+ /// 表示观察完成的任务。
+ private async Task ObserveWorkflowTaskAsync(Task workflowTask, CancellationTokenSource workflowCts, CancellationToken stoppingToken)
+ {
+ try
+ {
+ await workflowTask.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (workflowCts.IsCancellationRequested)
+ {
+ if (!stoppingToken.IsCancellationRequested)
+ {
+ _appLogger.LogInformation("当前流程已取消。", true);
+ }
+ }
+ catch (Exception ex)
+ {
+ _appLogger.LogError(ex, "后台流程执行异常");
+ if (!stoppingToken.IsCancellationRequested)
+ {
+ await EnterFaultedAsync(WorkflowResultCode.PlcCommunicationFault, AlarmCode.PlcFault, $"后台流程执行异常: {ex.Message}", CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ workflowCts.Dispose();
+ lock (_stateSyncRoot)
+ {
+ if (ReferenceEquals(_activeWorkflowTask, workflowTask))
+ {
+ _activeWorkflowTask = null;
+ }
+
+ if (ReferenceEquals(_activeWorkflowCts, workflowCts))
+ {
+ _activeWorkflowCts = null;
+ }
+
+ _processingActive = false;
+ }
+ }
+ }
+
+ ///
+ /// 取消当前正在执行的流程并等待其结束。
+ ///
+ /// 等待流程结束时使用的取消令牌。
+ /// 表示取消等待完成的任务。
+ private async Task CancelActiveWorkflowAsync(CancellationToken cancellationToken)
+ {
+ CancellationTokenSource? workflowCts;
+ Task? workflowTask;
+ lock (_stateSyncRoot)
+ {
+ workflowCts = _activeWorkflowCts;
+ workflowTask = _activeWorkflowTask;
+ }
+
+ if (workflowCts is null || workflowTask is null || workflowTask.IsCompleted)
+ {
+ return;
+ }
+
+ workflowCts.Cancel();
+ try
+ {
+ await workflowTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (workflowCts.IsCancellationRequested)
+ {
+ // 流程被主动取消,属于预期行为。
+ }
+ }
+
+ ///
+ /// 执行一块板的完整状态机流程。
+ ///
+ /// 取消令牌。
+ /// 表示流程完成的任务。
+ private async Task RunWorkflowOnceAsync(CancellationToken cancellationToken)
+ {
+ await _workflowSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ InitializeBoardState();
+ await ApplyProcessStateAsync(
+ WorkflowState.Triggered,
+ "检测到 PCB 到位,准备启动流程",
+ state =>
+ {
+ ResetResultBits(state);
+ state.PcBusy = true;
+ },
+ cancellationToken).ConfigureAwait(false);
+ _appLogger.LogInformation("检测到 PCB 到位,开始单板流程。", true);
+
+ var barcode = await ExecuteScanFlowAsync(cancellationToken).ConfigureAwait(false);
+ if (IsFaultLatched())
+ {
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(barcode))
+ {
+ await ExecuteSftpFlowAsync(barcode, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (IsFaultLatched())
+ {
+ return;
+ }
+
+ await ReleaseAndCompleteAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _workflowSemaphore.Release();
+ }
+ }
+
+ ///
+ /// 执行扫码流程。
+ ///
+ /// 取消令牌。
+ /// 扫码成功则返回条码;否则返回空字符串。
+ private async Task ExecuteScanFlowAsync(CancellationToken cancellationToken)
+ {
+ for (var attempt = 1; attempt <= _config.Scanner.MaxScanAttempts; attempt++)
+ {
+ lock (_stateSyncRoot)
+ {
+ _scanTryCount = attempt;
+ }
+
+ await ApplyProcessStateAsync(
+ WorkflowState.Scanning,
+ $"正在执行第 {attempt} 次扫码",
+ _ => { },
+ cancellationToken).ConfigureAwait(false);
+
+ var scanResult = await _scannerService.TriggerScanAsync(cancellationToken).ConfigureAwait(false);
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.ScannerStatus = scanResult.DeviceConnected ? "在线" : "离线";
+ snapshot.ScanTryCount = attempt;
+ });
+
+ if (scanResult.IsSystemError)
+ {
+ await EnterFaultedAsync(WorkflowResultCode.ScannerFault, AlarmCode.ScannerFault, scanResult.ErrorMessage, cancellationToken).ConfigureAwait(false);
+ return string.Empty;
+ }
+
+ if (scanResult.IsSuccess)
+ {
+ lock (_stateSyncRoot)
+ {
+ _currentBarcode = scanResult.Barcode;
+ _alarmCode = AlarmCode.None;
+ }
+
+ await ApplyProcessStateAsync(
+ WorkflowState.CheckingSftp,
+ $"扫码成功: {scanResult.Barcode}",
+ state =>
+ {
+ state.ScanOk = true;
+ state.ScanNg = false;
+ },
+ cancellationToken).ConfigureAwait(false);
+ _appLogger.LogInformation($"第 {attempt} 次扫码成功,条码={scanResult.Barcode}", true);
+ return scanResult.Barcode;
+ }
+
+ _appLogger.LogWarning($"第 {attempt} 次扫码失败: {scanResult.ErrorMessage}", true);
+ if (attempt < _config.Scanner.MaxScanAttempts)
+ {
+ await ApplyProcessStateAsync(
+ WorkflowState.ScanRetrying,
+ $"扫码失败,等待第 {attempt + 1} 次重试",
+ state =>
+ {
+ state.ScanOk = false;
+ state.ScanNg = false;
+ },
+ cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+
+ lock (_stateSyncRoot)
+ {
+ _resultCode = WorkflowResultCode.ScanFailedReleased;
+ _alarmCode = _config.Andon.EnableScanFailAlarm ? AlarmCode.ScanFailed : AlarmCode.None;
+ }
+
+ await ApplyProcessStateAsync(
+ WorkflowState.ScanFailedReleased,
+ "扫码连续失败 3 次,进入报警放行",
+ state =>
+ {
+ state.ScanOk = false;
+ state.ScanNg = true;
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ await RaiseAlarmIfNeededAsync(
+ new AndonAlarmRequest
+ {
+ AlarmType = "ScanFailed",
+ AlarmCode = (ushort)AlarmCode.ScanFailed,
+ AlarmMessage = "扫码连续失败 3 次",
+ Barcode = string.Empty,
+ TriggeredAt = DateTimeOffset.Now
+ },
+ _config.Andon.EnableScanFailAlarm,
+ cancellationToken).ConfigureAwait(false);
+ return string.Empty;
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// 执行 SFTP 文件校验流程。
+ ///
+ /// 条码内容。
+ /// 取消令牌。
+ /// 表示流程完成的任务。
+ private async Task ExecuteSftpFlowAsync(string barcode, CancellationToken cancellationToken)
+ {
+ for (var attempt = 0; attempt <= _config.Sftp.MaxRetryCount; attempt++)
+ {
+ lock (_stateSyncRoot)
+ {
+ _sftpTryCount = attempt + 1;
+ }
+
+ await ApplyProcessStateAsync(
+ WorkflowState.CheckingSftp,
+ $"正在执行第 {_sftpTryCount} 次 SFTP 校验",
+ _ => { },
+ cancellationToken).ConfigureAwait(false);
+
+ var outcome = await _sftpLookupService.CheckFileAsync(barcode, cancellationToken).ConfigureAwait(false);
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.SftpStatus = outcome.ConnectionSucceeded ? "在线" : (outcome.IsSystemError ? "异常" : "未验证");
+ snapshot.SftpTryCount = _sftpTryCount;
+ });
+
+ if (outcome.Exists)
+ {
+ lock (_stateSyncRoot)
+ {
+ _resultCode = WorkflowResultCode.Passed;
+ _alarmCode = AlarmCode.None;
+ }
+
+ await ApplyProcessStateAsync(
+ WorkflowState.SftpPassed,
+ $"文件已找到: {outcome.MatchedFilePath}",
+ state =>
+ {
+ state.FileFound = true;
+ state.FileNotFound = false;
+ },
+ cancellationToken).ConfigureAwait(false);
+ _appLogger.LogInformation($"SFTP 文件命中成功: {outcome.MatchedFilePath}", true);
+ return;
+ }
+
+ if (outcome.IsConfigurationError)
+ {
+ await EnterFaultedAsync(WorkflowResultCode.ConfigurationFault, AlarmCode.SftpFault, outcome.ErrorMessage, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ if (outcome.IsSystemError)
+ {
+ await EnterFaultedAsync(WorkflowResultCode.SftpFault, AlarmCode.SftpFault, outcome.ErrorMessage, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ if (attempt < _config.Sftp.MaxRetryCount)
+ {
+ _appLogger.LogWarning($"第 {_sftpTryCount} 次 SFTP 未命中: {outcome.ErrorMessage}", true);
+ await ApplyProcessStateAsync(
+ WorkflowState.WaitingSftpRetry,
+ $"文件未命中,{_config.Sftp.RetryIntervalSeconds} 秒后重试",
+ _ => { },
+ cancellationToken).ConfigureAwait(false);
+ await Task.Delay(TimeSpan.FromSeconds(_config.Sftp.RetryIntervalSeconds), cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+
+ lock (_stateSyncRoot)
+ {
+ _resultCode = WorkflowResultCode.FileNotFoundReleased;
+ _alarmCode = _config.Andon.EnableFileNotFoundAlarm ? AlarmCode.FileNotFound : AlarmCode.None;
+ }
+
+ await ApplyProcessStateAsync(
+ WorkflowState.SftpTimeoutReleased,
+ "文件未找到超时,按规则放行",
+ state =>
+ {
+ state.FileFound = false;
+ state.FileNotFound = true;
+ },
+ cancellationToken).ConfigureAwait(false);
+ _appLogger.LogWarning($"SFTP 文件未命中达到上限,条码={barcode}", true);
+
+ if (_config.Andon.EnableFileNotFoundAlarm)
+ {
+ await RaiseAlarmIfNeededAsync(
+ new AndonAlarmRequest
+ {
+ AlarmType = "FileNotFound",
+ AlarmCode = (ushort)AlarmCode.FileNotFound,
+ AlarmMessage = "SFTP 文件未找到超时",
+ Barcode = barcode,
+ TriggeredAt = DateTimeOffset.Now
+ },
+ true,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ return;
+ }
+ }
+
+ ///
+ /// 执行放行与完成处理。
+ ///
+ /// 取消令牌。
+ /// 表示处理完成的任务。
+ private async Task ReleaseAndCompleteAsync(CancellationToken cancellationToken)
+ {
+ await ApplyProcessStateAsync(
+ WorkflowState.Releasing,
+ "正在发送放行信号",
+ state =>
+ {
+ state.ReleasePermit = true;
+ state.PcBusy = true;
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ lock (_stateSyncRoot)
+ {
+ _releaseSent = true;
+ }
+
+ _appLogger.LogInformation("已向 PLC 发送放行信号。", true);
+
+ var ackReceived = false;
+ using (var ackTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
+ {
+ ackTimeoutCts.CancelAfter(_config.Plc.ReleaseAckTimeoutMs);
+ try
+ {
+ while (!ackTimeoutCts.Token.IsCancellationRequested)
+ {
+ var snapshot = await _plcService.ReadSignalsAsync(ackTimeoutCts.Token).ConfigureAwait(false);
+ if (snapshot.PlcAckRelease)
+ {
+ ackReceived = true;
+ break;
+ }
+
+ await Task.Delay(100, ackTimeoutCts.Token).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // 应答等待超时,后续按脉冲结束流程。
+ }
+ }
+
+ if (!ackReceived)
+ {
+ _appLogger.LogWarning("放行应答等待超时,按脉冲策略自动清除放行位。", true);
+ }
+
+ await Task.Delay(_config.Plc.ReleasePulseMs, cancellationToken).ConfigureAwait(false);
+ await ApplyProcessStateAsync(
+ WorkflowState.Completed,
+ GetResultDescription(GetCurrentResultCode()),
+ state =>
+ {
+ state.ReleasePermit = false;
+ state.ProcessDone = true;
+ state.PcBusy = false;
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ var completedAt = DateTimeOffset.Now;
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.ProcessDone = true;
+ snapshot.IsBusy = false;
+ snapshot.LastCompletedAt = completedAt;
+ });
+
+ BoardProcessRecord record;
+ lock (_stateSyncRoot)
+ {
+ record = new BoardProcessRecord
+ {
+ StartedAt = _currentBoardStartedAt ?? completedAt,
+ CompletedAt = completedAt,
+ Barcode = _currentBarcode,
+ ScanTryCount = _scanTryCount,
+ SftpTryCount = _sftpTryCount,
+ ResultCode = (ushort)_resultCode,
+ ResultDescription = GetResultDescription(_resultCode),
+ ReleaseSent = _releaseSent,
+ AlarmRaised = _plcState.AlarmRaised,
+ ExceptionSummary = _faultLatched ? "流程故障" : string.Empty
+ };
+ }
+
+ _stateStore.AddRecord(record);
+ _appLogger.LogInformation($"单板流程完成,结果={record.ResultDescription}", true);
+ }
+
+ ///
+ /// 在发生系统级异常时进入故障状态。
+ ///
+ /// 结果代码。
+ /// 报警代码。
+ /// 故障信息。
+ /// 取消令牌。
+ /// 表示故障状态写入完成的任务。
+ private async Task EnterFaultedAsync(WorkflowResultCode resultCode, AlarmCode alarmCode, string faultMessage, CancellationToken cancellationToken)
+ {
+ lock (_stateSyncRoot)
+ {
+ _faultLatched = true;
+ _resultCode = resultCode;
+ _alarmCode = alarmCode;
+ }
+
+ try
+ {
+ await ApplyProcessStateAsync(
+ WorkflowState.Faulted,
+ faultMessage,
+ state =>
+ {
+ state.SystemFault = true;
+ state.PcBusy = false;
+ state.ReleasePermit = false;
+ state.ProcessDone = false;
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _appLogger.LogError(ex, "写入故障状态到 PLC 失败");
+ }
+
+ _appLogger.LogError($"系统进入故障状态: {faultMessage}", true);
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.SystemFault = true;
+ snapshot.FaultMessage = faultMessage;
+ snapshot.ResultDescription = faultMessage;
+ });
+ }
+
+ ///
+ /// 根据条件发送安灯报警。
+ ///
+ /// 报警请求。
+ /// 是否启用。
+ /// 取消令牌。
+ /// 表示处理完成的任务。
+ private async Task RaiseAlarmIfNeededAsync(AndonAlarmRequest request, bool enabled, CancellationToken cancellationToken)
+ {
+ if (!enabled)
+ {
+ await WritePlcStateAsync(state => state.AlarmRaised = false, cancellationToken).ConfigureAwait(false);
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.AlarmRaised = false;
+ snapshot.AlarmCode = 0;
+ });
+ return;
+ }
+
+ var result = await _andonService.RaiseAlarmAsync(request, cancellationToken).ConfigureAwait(false);
+ await WritePlcStateAsync(state => state.AlarmRaised = result.IsSuccess, cancellationToken).ConfigureAwait(false);
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.AndonStatus = result.IsSuccess ? "调用成功" : $"调用失败: {result.ErrorMessage}";
+ snapshot.AlarmRaised = result.IsSuccess;
+ snapshot.AlarmCode = request.AlarmCode;
+ });
+
+ if (result.IsSuccess)
+ {
+ _appLogger.LogInformation($"安灯报警成功,代码={request.AlarmCode}", true);
+ }
+ else
+ {
+ _appLogger.LogWarning($"安灯报警失败: {result.ErrorMessage}", true);
+ }
+ }
+
+ ///
+ /// 初始化新板处理上下文。
+ ///
+ private void InitializeBoardState()
+ {
+ lock (_stateSyncRoot)
+ {
+ _currentBoardStartedAt = DateTimeOffset.Now;
+ _releaseSent = false;
+ _currentBarcode = string.Empty;
+ _scanTryCount = 0;
+ _sftpTryCount = 0;
+ _resultCode = WorkflowResultCode.Processing;
+ _alarmCode = AlarmCode.None;
+ _faultLatched = false;
+ }
+ }
+
+ ///
+ /// 重置完整流程状态。
+ ///
+ private void ResetProcessStateCore()
+ {
+ lock (_stateSyncRoot)
+ {
+ _faultLatched = false;
+ _processingActive = false;
+ _lastPcbArrived = false;
+ _releaseSent = false;
+ _currentBoardStartedAt = null;
+ _currentBarcode = string.Empty;
+ _scanTryCount = 0;
+ _sftpTryCount = 0;
+ _resultCode = WorkflowResultCode.None;
+ _alarmCode = AlarmCode.None;
+ ResetResultBits(_plcState);
+ _plcState.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
+ }
+ }
+
+ ///
+ /// 清理过程结果位与寄存器。
+ ///
+ /// 待清理的 PLC 状态对象。
+ private static void ResetResultBits(PlcProcessState state)
+ {
+ state.PcBusy = false;
+ state.ScanOk = false;
+ state.ScanNg = false;
+ state.FileFound = false;
+ state.FileNotFound = false;
+ state.AlarmRaised = false;
+ state.ReleasePermit = false;
+ state.ProcessDone = false;
+ state.SystemFault = false;
+ state.ResultCode = 0;
+ state.ScanTryCount = 0;
+ state.SftpTryCount = 0;
+ state.AlarmCode = 0;
+ }
+
+ ///
+ /// 更新流程状态并写入 PLC。
+ ///
+ /// 流程状态。
+ /// 状态说明。
+ /// 附加 PLC 输出更新逻辑。
+ /// 取消令牌。
+ /// 表示写入完成的任务。
+ private async Task ApplyProcessStateAsync(WorkflowState state, string description, Action extraUpdate, CancellationToken cancellationToken)
+ {
+ await WritePlcStateAsync(plcState =>
+ {
+ plcState.FlowStateCode = state.ToFlowStateCode();
+ extraUpdate(plcState);
+ }, cancellationToken).ConfigureAwait(false);
+ PublishRuntimeState(state, description);
+ }
+
+ ///
+ /// 按当前缓存状态写入 PLC 输出与寄存器。
+ ///
+ /// 对 PLC 状态对象的附加更新委托。
+ /// 取消令牌。
+ /// 表示写入完成的任务。
+ private async Task WritePlcStateAsync(Action updateAction, CancellationToken cancellationToken)
+ {
+ await _plcWriteLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ PlcProcessState snapshot;
+ lock (_stateSyncRoot)
+ {
+ updateAction(_plcState);
+ _plcState.ResultCode = (ushort)_resultCode;
+ _plcState.ScanTryCount = checked((ushort)_scanTryCount);
+ _plcState.SftpTryCount = checked((ushort)_sftpTryCount);
+ _plcState.AlarmCode = (ushort)_alarmCode;
+ snapshot = _plcState.Clone();
+ }
+
+ await _plcService.WriteStateAsync(snapshot, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _plcWriteLock.Release();
+ }
+ }
+
+ ///
+ /// 在达到设定周期时翻转 PLC 在线心跳位。
+ ///
+ /// 取消令牌。
+ /// 表示更新完成的任务。
+ private async Task UpdateHeartbeatIfDueAsync(CancellationToken cancellationToken)
+ {
+ var now = DateTimeOffset.Now;
+ var shouldToggle = false;
+ lock (_stateSyncRoot)
+ {
+ if (now - _lastHeartbeatToggleAt >= TimeSpan.FromMilliseconds(_config.Plc.HeartbeatIntervalMs))
+ {
+ _heartbeatState = !_heartbeatState;
+ _lastHeartbeatToggleAt = now;
+ shouldToggle = true;
+ }
+ }
+
+ if (!shouldToggle)
+ {
+ return;
+ }
+
+ await WritePlcStateAsync(state => state.PcOnline = _heartbeatState, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// 发布当前运行态快照到 UI 存储。
+ ///
+ /// 流程状态。
+ /// 流程描述。
+ private void PublishRuntimeState(WorkflowState state, string description)
+ {
+ DateTimeOffset? startedAt;
+ string barcode;
+ int scanTryCount;
+ int sftpTryCount;
+ ushort resultCode;
+ ushort alarmCode;
+ bool isBusy;
+ bool processDone;
+ bool systemFault;
+ bool alarmRaised;
+ string faultMessage;
+
+ lock (_stateSyncRoot)
+ {
+ startedAt = _currentBoardStartedAt;
+ barcode = _currentBarcode;
+ scanTryCount = _scanTryCount;
+ sftpTryCount = _sftpTryCount;
+ resultCode = (ushort)_resultCode;
+ alarmCode = (ushort)_alarmCode;
+ isBusy = _plcState.PcBusy;
+ processDone = _plcState.ProcessDone;
+ systemFault = _plcState.SystemFault;
+ alarmRaised = _plcState.AlarmRaised;
+ faultMessage = systemFault ? description : string.Empty;
+ }
+
+ UpdateSnapshot(snapshot =>
+ {
+ snapshot.WorkflowState = state;
+ snapshot.WorkflowStateText = state.ToDisplayText();
+ snapshot.CurrentBarcode = barcode;
+ snapshot.ResultDescription = description;
+ snapshot.FaultMessage = faultMessage;
+ snapshot.ScanTryCount = scanTryCount;
+ snapshot.SftpTryCount = sftpTryCount;
+ snapshot.ResultCode = resultCode;
+ snapshot.AlarmCode = alarmCode;
+ snapshot.IsBusy = isBusy;
+ snapshot.ProcessDone = processDone;
+ snapshot.SystemFault = systemFault;
+ snapshot.AlarmRaised = alarmRaised;
+ if (startedAt.HasValue)
+ {
+ snapshot.LastTriggeredAt = startedAt;
+ }
+ });
+ }
+
+ ///
+ /// 读取当前结果代码。
+ ///
+ /// 当前结果代码。
+ private WorkflowResultCode GetCurrentResultCode()
+ {
+ lock (_stateSyncRoot)
+ {
+ return _resultCode;
+ }
+ }
+
+ ///
+ /// 判断当前是否处于故障锁存状态。
+ ///
+ /// 故障锁存返回 ;否则返回 。
+ private bool IsFaultLatched()
+ {
+ lock (_stateSyncRoot)
+ {
+ return _faultLatched;
+ }
+ }
+
+ ///
+ /// 统一更新运行态快照。
+ ///
+ /// 更新委托。
+ private void UpdateSnapshot(Action action)
+ {
+ _stateStore.UpdateSnapshot(action);
+ }
+
+ ///
+ /// 根据结果代码获取中文描述。
+ ///
+ /// 结果代码。
+ /// 中文结果描述。
+ private static string GetResultDescription(WorkflowResultCode resultCode)
+ {
+ return resultCode switch
+ {
+ WorkflowResultCode.None => "空闲 / 无结果",
+ WorkflowResultCode.Processing => "处理中",
+ WorkflowResultCode.Passed => "扫码成功,文件存在,正常放行",
+ WorkflowResultCode.ScanFailedReleased => "扫码失败 3 次后放行",
+ WorkflowResultCode.FileNotFoundReleased => "扫码成功,文件未找到超时放行",
+ WorkflowResultCode.PlcCommunicationFault => "PLC 通信异常",
+ WorkflowResultCode.ScannerFault => "串口异常",
+ WorkflowResultCode.SftpFault => "SFTP 连接或认证异常",
+ WorkflowResultCode.AndonFault => "安灯接口调用异常",
+ WorkflowResultCode.ConfigurationFault => "配置异常",
+ _ => "未知结果"
+ };
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs
new file mode 100644
index 0000000..13e5ac4
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs
@@ -0,0 +1,261 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+
+namespace AxiOmron.PcbCheck.Services.Interfaces;
+
+///
+/// 定义应用配置文件读写能力。
+///
+public interface IAppConfigService
+{
+ ///
+ /// 读取当前应用配置。
+ ///
+ /// 根配置对象。
+ AppConfig Load();
+
+ ///
+ /// 保存当前应用配置。
+ ///
+ /// 待保存的配置对象。
+ void Save(AppConfig config);
+
+ ///
+ /// 获取主配置文件路径。
+ ///
+ /// 配置文件绝对路径。
+ string GetConfigPath();
+}
+
+///
+/// 定义 WPF Dispatcher 调度能力。
+///
+public interface IDispatcherService
+{
+ ///
+ /// 在 UI 线程中执行指定动作。
+ ///
+ /// 待执行的动作。
+ /// 表示调度完成的任务。
+ Task InvokeAsync(Action action);
+}
+
+///
+/// 定义应用统一日志能力,同时兼容持久化日志与前台 UI 日志分发。
+///
+/// 日志分类类型。
+public interface IAppLogger
+{
+ ///
+ /// 记录一条信息日志。
+ ///
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。默认不显示。
+ /// 日志模板参数。
+ void LogInformation(string message, bool showInUi = false, params object?[] args);
+
+ ///
+ /// 记录一条警告日志。
+ ///
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。默认不显示。
+ /// 日志模板参数。
+ void LogWarning(string message, bool showInUi = false, params object?[] args);
+
+ ///
+ /// 记录一条带异常的警告日志。
+ ///
+ /// 异常对象。
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。默认不显示。
+ /// 日志模板参数。
+ /// 当 为 时抛出。
+ void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args);
+
+ ///
+ /// 记录一条错误日志。
+ ///
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。默认不显示。
+ /// 日志模板参数。
+ void LogError(string message, bool showInUi = false, params object?[] args);
+
+ ///
+ /// 记录一条带异常的错误日志。
+ ///
+ /// 异常对象。
+ /// 日志消息模板或文本。
+ /// 是否同步显示到前台日志区域。默认不显示。
+ /// 日志模板参数。
+ /// 当 为 时抛出。
+ void LogError(Exception exception, string message, bool showInUi = false, params object?[] args);
+}
+
+///
+/// 定义 PLC 通信能力。
+///
+public interface IPlcService
+{
+ ///
+ /// 读取 PLC 输入信号快照。
+ ///
+ /// 取消令牌。
+ /// 输入信号快照。
+ Task ReadSignalsAsync(CancellationToken cancellationToken);
+
+ ///
+ /// 写入 PLC 输出状态与寄存器值。
+ ///
+ /// 待写入的输出状态。
+ /// 取消令牌。
+ /// 表示写入完成的任务。
+ Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken);
+
+ ///
+ /// 主动断开并重建 PLC 连接。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ Task ForceReconnectAsync(CancellationToken cancellationToken);
+}
+
+///
+/// 定义扫码枪服务能力。
+///
+public interface IScannerService
+{
+ ///
+ /// 触发一次扫码。
+ ///
+ /// 取消令牌。
+ /// 扫码结果。
+ Task TriggerScanAsync(CancellationToken cancellationToken);
+
+ ///
+ /// 测试扫码枪连接。
+ ///
+ /// 取消令牌。
+ /// 连接正常返回 ;否则返回 。
+ Task TestConnectionAsync(CancellationToken cancellationToken);
+
+ ///
+ /// 主动断开并重建扫码枪连接。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ Task ForceReconnectAsync(CancellationToken cancellationToken);
+}
+
+///
+/// 定义 SFTP 校验能力。
+///
+public interface ISftpLookupService
+{
+ ///
+ /// 按条码检查目标文件是否存在。
+ ///
+ /// 条码内容。
+ /// 取消令牌。
+ /// 文件校验结果。
+ Task CheckFileAsync(string barcode, CancellationToken cancellationToken);
+}
+
+///
+/// 定义安灯接口调用能力。
+///
+public interface IAndonService
+{
+ ///
+ /// 发送安灯报警。
+ ///
+ /// 报警请求对象。
+ /// 取消令牌。
+ /// 报警调用结果。
+ Task RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken);
+
+ ///
+ /// 发送一次测试报警请求。
+ ///
+ /// 取消令牌。
+ /// 测试调用结果。
+ Task TestAsync(CancellationToken cancellationToken);
+}
+
+///
+/// 定义流程控制手动操作能力。
+///
+public interface IWorkflowControlService
+{
+ ///
+ /// 手动复位流程状态。
+ ///
+ /// 取消令牌。
+ /// 表示复位完成的任务。
+ Task ResetAsync(CancellationToken cancellationToken);
+
+ ///
+ /// 手动重连 PLC。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ Task ReconnectPlcAsync(CancellationToken cancellationToken);
+
+ ///
+ /// 手动重连扫码枪。
+ ///
+ /// 取消令牌。
+ /// 表示重连完成的任务。
+ Task ReconnectScannerAsync(CancellationToken cancellationToken);
+
+ ///
+ /// 手动测试安灯接口。
+ ///
+ /// 取消令牌。
+ /// 表示测试完成的任务。
+ Task TestAndonAsync(CancellationToken cancellationToken);
+}
+
+///
+/// 定义运行态快照与 UI 事件分发能力。
+///
+public interface IAppStateStore
+{
+ ///
+ /// 当运行态快照变化时触发。
+ ///
+ event EventHandler? SnapshotChanged;
+
+ ///
+ /// 当新增日志时触发。
+ ///
+ event EventHandler? LogAdded;
+
+ ///
+ /// 当新增单板记录时触发。
+ ///
+ event EventHandler? RecordAdded;
+
+ ///
+ /// 获取当前运行态快照副本。
+ ///
+ /// 当前快照副本。
+ RuntimeSnapshot GetSnapshot();
+
+ ///
+ /// 更新当前运行态快照。
+ ///
+ /// 用于修改快照的更新委托。
+ void UpdateSnapshot(Action updateAction);
+
+ ///
+ /// 追加一条 UI 日志。
+ ///
+ /// 待追加的日志对象。
+ void AddLog(UiLogEntry entry);
+
+ ///
+ /// 追加一条单板结果记录。
+ ///
+ /// 待追加的记录对象。
+ void AddRecord(BoardProcessRecord record);
+}
diff --git a/src/AxiOmron.PcbCheck/Utils/TextAndPatternHelpers.cs b/src/AxiOmron.PcbCheck/Utils/TextAndPatternHelpers.cs
new file mode 100644
index 0000000..ae889e7
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Utils/TextAndPatternHelpers.cs
@@ -0,0 +1,135 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace AxiOmron.PcbCheck.Utils;
+
+///
+/// 提供转义字符串处理能力。
+///
+internal static class StringEscapeHelper
+{
+ ///
+ /// 将配置字符串中的常见转义序列还原为实际字符。
+ ///
+ /// 待还原的字符串。
+ /// 还原后的字符串。
+ public static string Unescape(string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return string.Empty;
+ }
+
+ return value
+ .Replace("\\r", "\r", StringComparison.Ordinal)
+ .Replace("\\n", "\n", StringComparison.Ordinal)
+ .Replace("\\t", "\t", StringComparison.Ordinal)
+ .Replace("\\0", "\0", StringComparison.Ordinal);
+ }
+}
+
+///
+/// 提供扫码字符串清洗能力。
+///
+internal static class BarcodeCleaner
+{
+ ///
+ /// 去除条码中的首尾空白与控制字符。
+ ///
+ /// 原始条码字符串。
+ /// 清洗后的条码。
+ public static string Clean(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ var builder = new StringBuilder(value.Length);
+ foreach (var character in value.Trim())
+ {
+ if (!char.IsControl(character))
+ {
+ builder.Append(character);
+ }
+ }
+
+ return builder.ToString().Trim();
+ }
+}
+
+///
+/// 提供通配符匹配能力。
+///
+internal static class WildcardMatcher
+{
+ ///
+ /// 判断给定文本是否匹配通配符模式。
+ ///
+ /// 待匹配文本。
+ /// 通配符模式。
+ /// 匹配成功返回 ;否则返回 。
+ public static bool IsMatch(string text, string pattern)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ ArgumentNullException.ThrowIfNull(pattern);
+
+ var regexPattern = "^" + Regex.Escape(pattern)
+ .Replace("\\*", ".*", StringComparison.Ordinal)
+ .Replace("\\?", ".", StringComparison.Ordinal) + "$";
+
+ return Regex.IsMatch(text, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+ }
+}
+
+///
+/// 提供 Modbus 地址转换能力。
+///
+internal static class ModbusAddressConverter
+{
+ ///
+ /// 将离散输入地址转换为零基偏移。
+ ///
+ /// 离散输入地址。
+ /// 零基偏移。
+ public static ushort ToDiscreteInputOffset(int address)
+ {
+ return ConvertToOffset(address, 10001);
+ }
+
+ ///
+ /// 将线圈地址转换为零基偏移。
+ ///
+ /// 线圈地址。
+ /// 零基偏移。
+ public static ushort ToCoilOffset(int address)
+ {
+ return ConvertToOffset(address, 1);
+ }
+
+ ///
+ /// 将保持寄存器地址转换为零基偏移。
+ ///
+ /// 保持寄存器地址。
+ /// 零基偏移。
+ public static ushort ToHoldingRegisterOffset(int address)
+ {
+ return ConvertToOffset(address, 40001);
+ }
+
+ ///
+ /// 根据基地址执行统一偏移换算。
+ ///
+ /// 原始地址。
+ /// 地址段起始基值。
+ /// 零基偏移。
+ private static ushort ConvertToOffset(int address, int baseAddress)
+ {
+ if (address < baseAddress)
+ {
+ throw new ArgumentOutOfRangeException(nameof(address), $"地址 {address} 小于基地址 {baseAddress}。");
+ }
+
+ return checked((ushort)(address - baseAddress));
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..b9c4362
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,505 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.ViewModels;
+
+///
+/// 提供主窗口与首页共享的运行状态、日志和命令绑定能力。
+///
+public partial class MainWindowViewModel : ObservableObject
+{
+ private readonly IAppStateStore _stateStore;
+ private readonly IDispatcherService _dispatcherService;
+ private readonly IWorkflowControlService _workflowControlService;
+ private readonly WorkflowOptions _workflowOptions;
+
+ ///
+ /// 初始化主窗口视图模型。
+ ///
+ /// 运行态存储。
+ /// Dispatcher 调度服务。
+ /// 流程控制服务。
+ /// 应用配置。
+ public MainWindowViewModel(
+ IAppStateStore stateStore,
+ IDispatcherService dispatcherService,
+ IWorkflowControlService workflowControlService,
+ AppConfig config)
+ {
+ _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
+ _dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
+ _workflowControlService = workflowControlService ?? throw new ArgumentNullException(nameof(workflowControlService));
+ ArgumentNullException.ThrowIfNull(config);
+ _workflowOptions = config.Workflow;
+
+ Title = "Axi Omron PCB Check";
+ Logs = new ObservableCollection();
+ RecentBoards = new ObservableCollection();
+
+ Logs.CollectionChanged += OnLogsCollectionChanged;
+ RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged;
+
+ _stateStore.SnapshotChanged += OnSnapshotChanged;
+ _stateStore.LogAdded += OnLogAdded;
+ _stateStore.RecordAdded += OnRecordAdded;
+ ApplySnapshot(_stateStore.GetSnapshot());
+ RecalculateLogStatistics();
+ RecalculateProcessStatistics();
+ }
+
+ ///
+ /// 获取运行日志集合。
+ ///
+ public ObservableCollection Logs { get; }
+
+ ///
+ /// 获取最近处理记录集合。
+ ///
+ public ObservableCollection RecentBoards { get; }
+
+ ///
+ /// 获取最近运行日志集合(与 为同一集合,供 UI 绑定语义更清晰)。
+ ///
+ public ObservableCollection RecentLogs => Logs;
+
+ ///
+ /// 获取最近处理记录集合(与 为同一集合,供 UI 绑定语义更清晰)。
+ ///
+ public ObservableCollection RecentProcessRecords => RecentBoards;
+
+ ///
+ /// 获取或设置主窗口标题。
+ ///
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ ///
+ /// 获取或设置 PLC 状态文本。
+ ///
+ [ObservableProperty]
+ private string _plcStatus = "未连接";
+
+ ///
+ /// 获取或设置扫码枪状态文本。
+ ///
+ [ObservableProperty]
+ private string _scannerStatus = "未验证";
+
+ ///
+ /// 获取或设置 SFTP 状态文本。
+ ///
+ [ObservableProperty]
+ private string _sftpStatus = "未验证";
+
+ ///
+ /// 获取或设置安灯状态文本。
+ ///
+ [ObservableProperty]
+ private string _andonStatus = "未验证";
+
+ ///
+ /// 获取或设置当前流程状态文本。
+ ///
+ [ObservableProperty]
+ private string _workflowStateText = "空闲等待";
+
+ ///
+ /// 获取或设置当前条码。
+ ///
+ [ObservableProperty]
+ private string _currentBarcode = string.Empty;
+
+ ///
+ /// 获取或设置当前结果描述。
+ ///
+ [ObservableProperty]
+ private string _resultDescription = "等待触发";
+
+ ///
+ /// 获取或设置当前故障信息。
+ ///
+ [ObservableProperty]
+ private string _faultMessage = string.Empty;
+
+ ///
+ /// 获取或设置扫码次数。
+ ///
+ [ObservableProperty]
+ private int _scanTryCount;
+
+ ///
+ /// 获取或设置 SFTP 查询次数。
+ ///
+ [ObservableProperty]
+ private int _sftpTryCount;
+
+ ///
+ /// 获取或设置结果代码。
+ ///
+ [ObservableProperty]
+ private ushort _resultCode;
+
+ ///
+ /// 获取或设置报警代码。
+ ///
+ [ObservableProperty]
+ private ushort _alarmCode;
+
+ ///
+ /// 获取或设置最近触发时间。
+ ///
+ [ObservableProperty]
+ private string _lastTriggeredAt = "-";
+
+ ///
+ /// 获取或设置最近完成时间。
+ ///
+ [ObservableProperty]
+ private string _lastCompletedAt = "-";
+
+ ///
+ /// 获取或设置是否忙碌。
+ ///
+ [ObservableProperty]
+ private bool _isBusy;
+
+ ///
+ /// 获取或设置是否存在系统故障。
+ ///
+ [ObservableProperty]
+ private bool _isFaulted;
+
+ ///
+ /// 获取或设置是否已完成。
+ ///
+ [ObservableProperty]
+ private bool _isDone;
+
+ ///
+ /// 获取或设置是否已触发报警。
+ ///
+ [ObservableProperty]
+ private bool _isAlarmRaised;
+
+ ///
+ /// 获取或设置最近更新时间。
+ ///
+ [ObservableProperty]
+ private string _lastUpdatedAt = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
+
+ ///
+ /// 获取或设置运行日志区的最后刷新时间文本。
+ ///
+ [ObservableProperty]
+ private string _lastLogUpdateTime = "-";
+
+ ///
+ /// 获取或设置今日异常日志条数。
+ ///
+ [ObservableProperty]
+ private int _todayErrorCount;
+
+ ///
+ /// 获取或设置当前活跃告警数量。
+ ///
+ [ObservableProperty]
+ private int _activeAlarmCount;
+
+ ///
+ /// 获取或设置最近一次异常日志的时间文本。
+ ///
+ [ObservableProperty]
+ private string _lastErrorTime = "-";
+
+ ///
+ /// 获取或设置当前是否存在处理记录,供空状态显示切换使用。
+ ///
+ [ObservableProperty]
+ private bool _hasProcessRecords;
+
+ ///
+ /// 获取或设置今日处理总数。
+ ///
+ [ObservableProperty]
+ private int _todayProcessCount;
+
+ ///
+ /// 获取或设置今日 OK 数。
+ ///
+ [ObservableProperty]
+ private int _todayOkCount;
+
+ ///
+ /// 获取或设置今日 NG 数。
+ ///
+ [ObservableProperty]
+ private int _todayNgCount;
+
+ ///
+ /// 获取或设置处理记录区的最后刷新时间文本。
+ ///
+ [ObservableProperty]
+ private string _lastProcessUpdateTime = "-";
+
+ ///
+ /// 执行手动复位命令。
+ ///
+ /// 表示命令执行完成的任务。
+ [RelayCommand]
+ private Task ResetAsync()
+ {
+ return _workflowControlService.ResetAsync(CancellationToken.None);
+ }
+
+ ///
+ /// 执行 PLC 重连命令。
+ ///
+ /// 表示命令执行完成的任务。
+ [RelayCommand]
+ private Task ReconnectPlcAsync()
+ {
+ return _workflowControlService.ReconnectPlcAsync(CancellationToken.None);
+ }
+
+ ///
+ /// 执行扫码枪重连命令。
+ ///
+ /// 表示命令执行完成的任务。
+ [RelayCommand]
+ private Task ReconnectScannerAsync()
+ {
+ return _workflowControlService.ReconnectScannerAsync(CancellationToken.None);
+ }
+
+ ///
+ /// 执行安灯测试命令。
+ ///
+ /// 表示命令执行完成的任务。
+ [RelayCommand]
+ private Task TestAndonAsync()
+ {
+ return _workflowControlService.TestAndonAsync(CancellationToken.None);
+ }
+
+ ///
+ /// 查看全部运行日志命令占位。由工具栏触发,暂时只刷新统计与时间戳。
+ ///
+ [RelayCommand]
+ private void ShowAllLogs()
+ {
+ RecalculateLogStatistics();
+ }
+
+ ///
+ /// 仅显示错误日志命令占位。后续可接入筛选视图,当前刷新统计与时间戳。
+ ///
+ [RelayCommand]
+ private void ShowErrorLogs()
+ {
+ RecalculateLogStatistics();
+ }
+
+ ///
+ /// 导出运行日志命令占位。后续可接入日志文件导出逻辑。
+ ///
+ [RelayCommand]
+ private void ExportLogs()
+ {
+ LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+
+ ///
+ /// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。
+ ///
+ [RelayCommand]
+ private void OpenTrackingArea()
+ {
+ LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+
+ ///
+ /// 刷新处理记录命令占位。重新计算统计并更新最后刷新时间。
+ ///
+ [RelayCommand]
+ private void RefreshProcessRecords()
+ {
+ RecalculateProcessStatistics();
+ }
+
+ ///
+ /// 将快照应用到当前视图模型状态。
+ ///
+ /// 运行态快照。
+ private void ApplySnapshot(RuntimeSnapshot snapshot)
+ {
+ PlcStatus = snapshot.PlcStatus;
+ ScannerStatus = snapshot.ScannerStatus;
+ SftpStatus = snapshot.SftpStatus;
+ AndonStatus = snapshot.AndonStatus;
+ WorkflowStateText = snapshot.WorkflowStateText;
+ CurrentBarcode = snapshot.CurrentBarcode;
+ ResultDescription = snapshot.ResultDescription;
+ FaultMessage = snapshot.FaultMessage;
+ ScanTryCount = snapshot.ScanTryCount;
+ SftpTryCount = snapshot.SftpTryCount;
+ ResultCode = snapshot.ResultCode;
+ AlarmCode = snapshot.AlarmCode;
+ LastTriggeredAt = snapshot.LastTriggeredAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
+ LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
+ IsBusy = snapshot.IsBusy;
+ IsFaulted = snapshot.SystemFault;
+ IsDone = snapshot.ProcessDone;
+ IsAlarmRaised = snapshot.AlarmRaised;
+ ActiveAlarmCount = snapshot.AlarmRaised ? 1 : 0;
+ LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+
+ ///
+ /// 当 发生变化时同步活跃告警计数。
+ ///
+ /// 最新告警状态。
+ partial void OnIsAlarmRaisedChanged(bool value)
+ {
+ ActiveAlarmCount = value ? 1 : 0;
+ }
+
+ ///
+ /// 处理日志集合变化事件,刷新日志区统计字段。
+ ///
+ /// 事件源。
+ /// 集合变化参数。
+ private void OnLogsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ RecalculateLogStatistics();
+ }
+
+ ///
+ /// 处理处理记录集合变化事件,刷新处理区统计字段。
+ ///
+ /// 事件源。
+ /// 集合变化参数。
+ private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ RecalculateProcessStatistics();
+ }
+
+ ///
+ /// 根据当前日志集合重新计算日志区的统计信息与最后刷新时间。
+ ///
+ private void RecalculateLogStatistics()
+ {
+ DateTime today = DateTime.Today;
+ int errorToday = 0;
+ DateTimeOffset? lastErrorAt = null;
+
+ foreach (UiLogEntry entry in Logs)
+ {
+ bool isError = string.Equals(entry.Level, "Error", StringComparison.OrdinalIgnoreCase);
+ if (!isError)
+ {
+ continue;
+ }
+
+ if (entry.Timestamp.LocalDateTime.Date == today)
+ {
+ errorToday++;
+ }
+
+ if (lastErrorAt is null || entry.Timestamp > lastErrorAt.Value)
+ {
+ lastErrorAt = entry.Timestamp;
+ }
+ }
+
+ TodayErrorCount = errorToday;
+ LastErrorTime = lastErrorAt.HasValue
+ ? lastErrorAt.Value.ToString("MM-dd HH:mm:ss")
+ : "-";
+ LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+
+ ///
+ /// 根据当前处理记录集合重新计算处理区的统计信息与最后刷新时间。
+ ///
+ private void RecalculateProcessStatistics()
+ {
+ DateTime today = DateTime.Today;
+ int totalToday = 0;
+ int okToday = 0;
+ int ngToday = 0;
+
+ foreach (BoardProcessRecord record in RecentBoards)
+ {
+ if (record.CompletedAt.LocalDateTime.Date != today)
+ {
+ continue;
+ }
+
+ totalToday++;
+ if (record.ResultCode == (ushort)WorkflowResultCode.Passed)
+ {
+ okToday++;
+ }
+ else if (record.ResultCode != (ushort)WorkflowResultCode.Processing
+ && record.ResultCode != (ushort)WorkflowResultCode.None)
+ {
+ ngToday++;
+ }
+ }
+
+ TodayProcessCount = totalToday;
+ TodayOkCount = okToday;
+ TodayNgCount = ngToday;
+ HasProcessRecords = RecentBoards.Count > 0;
+ LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+
+ ///
+ /// 处理运行态快照变化事件。
+ ///
+ /// 事件源。
+ /// 最新快照。
+ private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
+ {
+ await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
+ }
+
+ ///
+ /// 处理新增日志事件。
+ ///
+ /// 事件源。
+ /// 新增日志。
+ private async void OnLogAdded(object? sender, UiLogEntry entry)
+ {
+ await _dispatcherService.InvokeAsync(() =>
+ {
+ Logs.Insert(0, entry);
+ while (Logs.Count > _workflowOptions.MaxUiLogEntries)
+ {
+ Logs.RemoveAt(Logs.Count - 1);
+ }
+ }).ConfigureAwait(false);
+ }
+
+ ///
+ /// 处理新增单板记录事件。
+ ///
+ /// 事件源。
+ /// 新增单板记录。
+ private async void OnRecordAdded(object? sender, BoardProcessRecord record)
+ {
+ await _dispatcherService.InvokeAsync(() =>
+ {
+ RecentBoards.Insert(0, record);
+ while (RecentBoards.Count > _workflowOptions.MaxBoardRecords)
+ {
+ RecentBoards.RemoveAt(RecentBoards.Count - 1);
+ }
+ }).ConfigureAwait(false);
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs
new file mode 100644
index 0000000..3ea474f
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs
@@ -0,0 +1,80 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.ViewModels;
+
+///
+/// 提供系统配置编辑、保存与重载能力。
+///
+public partial class SystemSettingViewModel : ObservableObject
+{
+ private readonly IAppConfigService _appConfigService;
+
+ ///
+ /// 初始化系统设置视图模型。
+ ///
+ /// 配置读写服务。
+ public SystemSettingViewModel(IAppConfigService appConfigService)
+ {
+ _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
+ EditableConfig = _appConfigService.Load();
+ ConfigPath = _appConfigService.GetConfigPath();
+ StatusMessage = "已加载配置。";
+ }
+
+ ///
+ /// 获取或设置可编辑配置对象。
+ ///
+ [ObservableProperty]
+ private AppConfig _editableConfig = new();
+
+ ///
+ /// 获取或设置状态文本。
+ ///
+ [ObservableProperty]
+ private string _statusMessage = string.Empty;
+
+ ///
+ /// 获取或设置配置文件路径。
+ ///
+ [ObservableProperty]
+ private string _configPath = string.Empty;
+
+ ///
+ /// 保存当前配置。
+ ///
+ private void SaveConfig()
+ {
+ _appConfigService.Save(EditableConfig);
+ StatusMessage = "配置已保存,重启应用后完全生效。";
+ }
+
+ ///
+ /// 重新加载配置文件。
+ ///
+ private void ReloadConfig()
+ {
+ EditableConfig = _appConfigService.Load();
+ StatusMessage = "配置已重新加载。";
+ }
+
+ ///
+ /// 保存配置命令。
+ ///
+ [RelayCommand]
+ private void Save()
+ {
+ SaveConfig();
+ }
+
+ ///
+ /// 重载配置命令。
+ ///
+ [RelayCommand]
+ private void Reload()
+ {
+ ReloadConfig();
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/ViewModels/ViewModelLocator.cs b/src/AxiOmron.PcbCheck/ViewModels/ViewModelLocator.cs
new file mode 100644
index 0000000..001b53e
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/ViewModels/ViewModelLocator.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AxiOmron.PcbCheck.ViewModels;
+
+///
+/// 为 XAML 提供视图模型定位能力。
+///
+public class ViewModelLocator
+{
+ ///
+ /// 获取主窗口视图模型。
+ ///
+ public MainWindowViewModel MainWindowViewModel
+ => App.Services.GetRequiredService();
+
+ ///
+ /// 获取系统设置视图模型。
+ ///
+ public SystemSettingViewModel SystemSettingViewModel
+ => App.Services.GetRequiredService();
+}
diff --git a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml
new file mode 100644
index 0000000..37098fe
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml
@@ -0,0 +1,1130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml.cs b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml.cs
new file mode 100644
index 0000000..67b427a
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace AxiOmron.PcbCheck.Views.Pages;
+
+///
+/// 表示系统运行总览页。
+///
+public partial class DashboardPage : Page
+{
+ ///
+ /// 初始化总览页。
+ ///
+ public DashboardPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
new file mode 100644
index 0000000..5a54bc3
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml.cs b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml.cs
new file mode 100644
index 0000000..ef2f5cc
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace AxiOmron.PcbCheck.Views.Pages;
+
+///
+/// 表示系统配置页面。
+///
+public partial class SystemSettingsPage : Page
+{
+ ///
+ /// 初始化系统配置页面。
+ ///
+ public SystemSettingsPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/appConfig.Development.json b/src/AxiOmron.PcbCheck/appConfig.Development.json
new file mode 100644
index 0000000..bcbd87b
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/appConfig.Development.json
@@ -0,0 +1,15 @@
+{
+ "Plc": {
+ "Host": "127.0.0.1"
+ },
+ "Scanner": {
+ "PortName": "COM3"
+ },
+ "Sftp": {
+ "Host": "127.0.0.1",
+ "RootPath": "/tmp/pcb"
+ },
+ "Andon": {
+ "Url": "http://127.0.0.1:5000/api/andon/test"
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/appConfig.json b/src/AxiOmron.PcbCheck/appConfig.json
new file mode 100644
index 0000000..b6397a2
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/appConfig.json
@@ -0,0 +1,84 @@
+{
+ "Plc": {
+ "Host": "127.0.0.1",
+ "Port": 502,
+ "UnitId": 1,
+ "PollIntervalMs": 200,
+ "ConnectTimeoutMs": 3000,
+ "HeartbeatIntervalMs": 500,
+ "ReleasePulseMs": 500,
+ "ReleaseAckTimeoutMs": 2000,
+ "Inputs": {
+ "PlcReady": 10001,
+ "PcbArrived": 10002,
+ "PlcReset": 10003,
+ "PlcAckRelease": 10004,
+ "AutoMode": 10005,
+ "StationEnable": 10006
+ },
+ "Outputs": {
+ "PcOnline": 51,
+ "PcBusy": 52,
+ "ScanOk": 53,
+ "ScanNg": 54,
+ "FileFound": 55,
+ "FileNotFound": 56,
+ "AlarmRaised": 57,
+ "ReleasePermit": 58,
+ "ProcessDone": 59,
+ "SystemFault": 60
+ },
+ "Registers": {
+ "ResultCode": 40001,
+ "ScanTryCount": 40002,
+ "SftpTryCount": 40003,
+ "AlarmCode": 40004,
+ "FlowStateCode": 40005
+ }
+ },
+ "Scanner": {
+ "PortName": "COM1",
+ "BaudRate": 9600,
+ "DataBits": 8,
+ "Parity": "None",
+ "StopBits": "One",
+ "ReadTimeoutMs": 3000,
+ "TriggerCommand": "SCAN\\r",
+ "ResponseTerminator": "\\r",
+ "MaxScanAttempts": 3
+ },
+ "Sftp": {
+ "Host": "127.0.0.1",
+ "Port": 22,
+ "Username": "user",
+ "Password": "",
+ "PrivateKeyPath": "",
+ "PrivateKeyPassphrase": "",
+ "RootPath": "/pcb",
+ "FileNamePattern": "${barcode}.txt",
+ "RetryIntervalSeconds": 2,
+ "MaxRetryCount": 3,
+ "ConnectTimeoutMs": 3000
+ },
+ "Andon": {
+ "Enable": true,
+ "Url": "http://127.0.0.1:5000/api/andon",
+ "Method": "POST",
+ "TimeoutMs": 3000,
+ "StationCode": "OMRON-01",
+ "StationName": "PCB目检工位",
+ "EnableScanFailAlarm": true,
+ "EnableFileNotFoundAlarm": false,
+ "Headers": {
+ "Content-Type": "application/json"
+ }
+ },
+ "Workflow": {
+ "RequirePlcReady": true,
+ "RequireAutoMode": true,
+ "RequireStationEnable": true,
+ "RequireManualResetAfterFault": true,
+ "MaxUiLogEntries": 200,
+ "MaxBoardRecords": 100
+ }
+}