Ver Fonte

merge: D2-02 第二阶段 MJPEG 实时预览(代码完成+审查全过+46单测绿+Release双编译0错,待真机出图)

huangjie há 1 dia atrás
pai
commit
cff0f6da35
27 ficheiros alterados com 2084 adições e 713 exclusões
  1. 37 0
      ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugStreamSessionTests.cs
  2. 4 0
      ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj
  3. 96 0
      ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegFrameParserTests.cs
  4. 56 0
      ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegStreamWriterTests.cs
  5. 76 0
      ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs
  6. 2 0
      ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.cs
  7. 6 0
      ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs
  8. 51 0
      ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MjpegStreamWriter.cs
  9. 83 0
      ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegFrameParser.cs
  10. 120 0
      ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegStreamClient.cs
  11. 1 1
      ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/ControlClient.cs
  12. 4 1
      ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml
  13. 29 22
      ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs
  14. 3 0
      ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs
  15. 22 0
      项目文档/业务流程图/培养全流程详图/dagre.min.js
  16. 438 0
      项目文档/业务流程图/培养全流程详图/flow-data.js
  17. 340 0
      项目文档/业务流程图/培养全流程详图/flow-render.js
  18. 246 0
      项目文档/业务流程图/培养全流程详图/时差培养箱-培养全流程详图.html
  19. 420 0
      项目文档/业务流程图/流程图制作规范-可复用模板.md
  20. 9 0
      项目文档/开发计划/2026-06-24-D2-02-第二阶段-MJPEG实时预览-实现计划.md
  21. 0 198
      项目文档/流程图交付清单.md
  22. 0 479
      项目文档/流程图制作规范-可复用模板.md
  23. 15 0
      项目文档/进度/交接卡.md
  24. 8 1
      项目文档/进度/工作计划表.md
  25. 5 0
      项目文档/进度/待验证清单.md
  26. 6 5
      项目文档/进度/进度数据.js
  27. 7 6
      项目文档/进度/进度状态.yaml

+ 37 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugStreamSessionTests.cs

@@ -0,0 +1,37 @@
+using System;
+using IvfTl.ControlHost.Debug;
+using IvfTl.ControlHost.Tests.Fakes;
+using IvfTl.Hardware;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    public class DebugStreamSessionTests
+    {
+        private DebugSessionManager NewMgr(FakeGate gate, DateTime now)
+            => new DebugSessionManager(_ => gate, () => now, ttlMs: 10000, log: null);
+
+        [Fact]
+        public void StreamBroken_DefaultsFalse()
+        {
+            var s = new DebugSession("sid1", 2, new FakeLease(new FakeGate(2, new FakeSerial()), new FakeSerial(), HardwareUser.OperateDebug), DateTime.UtcNow);
+            Assert.False(s.StreamBroken);
+            s.StreamBroken = true;
+            Assert.True(s.StreamBroken);
+        }
+
+        [Fact]
+        public void TryGet_ReturnsSession_ForValidSid()
+        {
+            var now = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc);
+            var gate = new FakeGate(2, new FakeSerial());
+            var mgr = NewMgr(gate, now);
+            string sid = (string)mgr.Acquire(2).Result;
+
+            Assert.True(mgr.TryGet(sid, out var s));
+            Assert.Equal(2, s.HouseSn);
+            Assert.False(s.StreamBroken);   // 经 manager 取出的会话默认未断流
+            Assert.False(mgr.TryGet("nope", out _));
+        }
+    }
+}

+ 4 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj

@@ -14,4 +14,8 @@
     <ProjectReference Include="..\ivf_tl_ControlHost\ivf_tl_ControlHost.csproj" />
     <ProjectReference Include="..\IvfTl.Hardware\IvfTl.Hardware.csproj" />
   </ItemGroup>
+  <ItemGroup>
+    <!-- operate 的纯逻辑 MjpegFrameParser 链入源码做单测(operate 主工程是 WPF 无单测工程)。 -->
+    <Compile Include="..\..\ivf_tl_Operate\Debug\MjpegFrameParser.cs" Link="Linked\MjpegFrameParser.cs" />
+  </ItemGroup>
 </Project>

+ 96 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegFrameParserTests.cs

@@ -0,0 +1,96 @@
+using System.Collections.Generic;
+using System.Text;
+using ivf_tl_Operate.Debug;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    public class MjpegFrameParserTests
+    {
+        // 造一个 multipart 帧字节(与 control MjpegStreamWriter.FrameBytes 同格式)。
+        private static byte[] MakeFrame(byte[] jpeg)
+        {
+            string head = $"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {jpeg.Length}\r\n\r\n";
+            var ms = new List<byte>();
+            ms.AddRange(Encoding.ASCII.GetBytes(head));
+            ms.AddRange(jpeg);
+            ms.AddRange(Encoding.ASCII.GetBytes("\r\n"));
+            return ms.ToArray();
+        }
+
+        private static List<byte[]> FeedAll(MjpegFrameParser p, byte[] data)
+        {
+            var all = new List<byte[]>();
+            all.AddRange(p.Feed(data, data.Length));
+            return all;
+        }
+
+        [Fact]
+        public void SingleWholeFrame_YieldsOneJpeg()
+        {
+            var jpeg = new byte[] { 0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9 };
+            var p = new MjpegFrameParser();
+            var frames = FeedAll(p, MakeFrame(jpeg));
+            Assert.Single(frames);
+            Assert.Equal(jpeg, frames[0]);
+        }
+
+        [Fact]
+        public void TwoFramesInOneChunk_YieldsTwo()
+        {
+            var j1 = new byte[] { 0xFF, 0xD8, 1, 0xFF, 0xD9 };
+            var j2 = new byte[] { 0xFF, 0xD8, 9, 8, 0xFF, 0xD9 };
+            var combined = new List<byte>();
+            combined.AddRange(MakeFrame(j1));
+            combined.AddRange(MakeFrame(j2));
+            var p = new MjpegFrameParser();
+            var frames = FeedAll(p, combined.ToArray());
+            Assert.Equal(2, frames.Count);
+            Assert.Equal(j1, frames[0]);
+            Assert.Equal(j2, frames[1]);
+        }
+
+        [Fact]
+        public void FrameSplitAcrossChunks_Reassembles()
+        {
+            var jpeg = new byte[] { 0xFF, 0xD8, 5, 6, 7, 8, 0xFF, 0xD9 };
+            byte[] full = MakeFrame(jpeg);
+            var p = new MjpegFrameParser();
+            // 在帧中间切两半喂
+            int mid = full.Length / 2;
+            var first = new byte[mid];
+            var second = new byte[full.Length - mid];
+            System.Array.Copy(full, 0, first, 0, mid);
+            System.Array.Copy(full, mid, second, 0, second.Length);
+
+            var frames = new List<byte[]>();
+            frames.AddRange(p.Feed(first, first.Length));
+            Assert.Empty(frames);  // 半帧,还吐不出
+            frames.AddRange(p.Feed(second, second.Length));
+            Assert.Single(frames);
+            Assert.Equal(jpeg, frames[0]);
+        }
+
+        [Fact]
+        public void BadHeaderWithoutContentLength_SkippedThenRecovers()
+        {
+            var jpeg = new byte[] { 0xFF, 0xD8, 7, 0xFF, 0xD9 };
+            var bad = Encoding.ASCII.GetBytes("--frame\r\nContent-Type: image/jpeg\r\n\r\n"); // 无 Content-Length
+            var data = new List<byte>();
+            data.AddRange(bad);
+            data.AddRange(MakeFrame(jpeg));
+            var p = new MjpegFrameParser();
+            var frames = new List<byte[]>(p.Feed(data.ToArray(), data.Count));
+            Assert.Single(frames);
+            Assert.Equal(jpeg, frames[0]);
+        }
+
+        [Fact]
+        public void EmptyInput_ReturnsEmpty_NoThrow()
+        {
+            var p = new MjpegFrameParser();
+            var frames = new List<byte[]>(p.Feed(new byte[0], 0));
+            Assert.Empty(frames);
+        }
+    }
+}

+ 56 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MjpegStreamWriterTests.cs

@@ -0,0 +1,56 @@
+using System.IO;
+using System.Windows.Media.Imaging;
+using IvfTl.ControlHost.Debug;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    public class MjpegStreamWriterTests
+    {
+        // 造一张 4x4 纯色 24bpp BGR 像素(每像素 3 字节)。
+        private static byte[] SolidBgr(int w, int h, byte b, byte g, byte r)
+        {
+            var buf = new byte[w * h * 3];
+            for (int i = 0; i < w * h; i++) { buf[i * 3] = b; buf[i * 3 + 1] = g; buf[i * 3 + 2] = r; }
+            return buf;
+        }
+
+        [Fact]
+        public void EncodeJpeg_ProducesDecodableJpeg_WithRightSize()
+        {
+            var rgb = SolidBgr(4, 4, 10, 20, 30);
+            byte[] jpeg = MjpegStreamWriter.EncodeJpeg(rgb, 4, 4);
+
+            Assert.NotNull(jpeg);
+            Assert.True(jpeg.Length > 2);
+            // JPEG 魔数 FF D8 开头
+            Assert.Equal(0xFF, jpeg[0]);
+            Assert.Equal(0xD8, jpeg[1]);
+            // 能被解码器读回、尺寸对
+            var dec = new JpegBitmapDecoder(new MemoryStream(jpeg),
+                BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
+            Assert.Equal(4, dec.Frames[0].PixelWidth);
+            Assert.Equal(4, dec.Frames[0].PixelHeight);
+        }
+
+        [Fact]
+        public void FrameBytes_WrapsJpegInMultipartBoundary()
+        {
+            var jpeg = new byte[] { 0xFF, 0xD8, 1, 2, 0xFF, 0xD9 };
+            byte[] frame = MjpegStreamWriter.FrameBytes(jpeg);
+
+            string head = System.Text.Encoding.ASCII.GetString(frame, 0, 60);
+            Assert.Contains("--frame", head);
+            Assert.Contains("Content-Type: image/jpeg", head);
+            Assert.Contains("Content-Length: 6", head);
+            // 帧体起点 = header 长度;尾部是 \r\n
+            string headerStr = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: 6\r\n\r\n";
+            int bodyStart = System.Text.Encoding.ASCII.GetByteCount(headerStr);
+            var body = new byte[jpeg.Length];
+            System.Array.Copy(frame, bodyStart, body, 0, jpeg.Length);
+            Assert.Equal(jpeg, body);   // 帧体整段 == 原 jpeg
+            Assert.Equal((byte)'\r', frame[frame.Length - 2]);
+            Assert.Equal((byte)'\n', frame[frame.Length - 1]);
+        }
+    }
+}

+ 76 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs

@@ -139,6 +139,19 @@ namespace IvfTl.ControlHost
                         body = JsonConvert.SerializeObject(r);
                     }
                     break;
+                case "/debug/preview/stream":
+                    if (method != "GET") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        string sid = ctx.Request.QueryString["sessionId"];
+                        if (_debug == null || sid == null || !_debug.TryGet(sid, out var session))
+                        {
+                            code = 404; body = Err("session not found");
+                            break;  // 走统一收尾返回 404
+                        }
+                        // 校验通过:分流起后台推流线程,不走统一收尾(那会 Close 流终止推流)。
+                        StartPreviewStream(ctx, session);
+                        return;
+                    }
                 default:
                     code = 404; body = Err("not found");
                     break;
@@ -189,6 +202,69 @@ namespace IvfTl.ControlHost
             catch (Exception ex) { _log("解析 body 异常:" + ex.Message); return null; }
         }
 
+        /// <summary>
+        /// 推流分支:起专用后台线程,抓帧→JPEG→multipart 持续写。
+        /// HttpListener 工作线程立即返回(本方法起线程后即返回),不被推流阻塞。
+        /// 任何退出路径都标记 session.StreamBroken,会话靠心跳 TTL 看门狗最终回收(spec §7)。
+        /// </summary>
+        private void StartPreviewStream(HttpListenerContext ctx, IvfTl.ControlHost.Debug.DebugSession session)
+        {
+            var resp = ctx.Response;
+            resp.StatusCode = 200;
+            resp.ContentType = IvfTl.ControlHost.Debug.MjpegStreamWriter.ContentType;
+            resp.SendChunked = true;  // 流式,长度未知
+            resp.Headers.Add("Cache-Control", "no-cache");
+
+            var t = new Thread(() =>
+            {
+                int errCount = 0;
+                var cam = session.Lease?.Camera;
+                try
+                {
+                    if (cam == null) { _log($"[debug] 推流舱{session.HouseSn} 无相机句柄,放弃"); return; }
+                    cam.SetOpMode(1);  // 实时模式(0=单帧/1=实时,见 ICamera 注释)
+                    var outStream = resp.OutputStream;
+                    while (true)
+                    {
+                        // 会话已被回收(release/超时)→ 停推流
+                        if (!_debug.TryGet(session.SessionId, out _)) { _log($"[debug] 推流舱{session.HouseSn} 会话已失效,停"); break; }
+                        try
+                        {
+                            byte[] bgr = cam.GrabStable();   // 走全进程相机锁,与采集/对焦串行
+                            if (bgr == null) { Thread.Sleep(100); continue; }
+                            byte[] jpeg = IvfTl.ControlHost.Debug.MjpegStreamWriter.EncodeJpeg(bgr, cam.Width, cam.Height);
+                            if (jpeg == null) { Thread.Sleep(100); continue; }
+                            byte[] frame = IvfTl.ControlHost.Debug.MjpegStreamWriter.FrameBytes(jpeg);
+                            outStream.Write(frame, 0, frame.Length);
+                            outStream.Flush();
+                            errCount = 0;
+                            Thread.Sleep(66);  // ~15fps(spec §4.2)
+                        }
+                        catch (IOException) { _log($"[debug] 推流舱{session.HouseSn} 客户端断开"); break; }     // operate 关预览/崩溃:正常退出
+                        catch (HttpListenerException) { _log($"[debug] 推流舱{session.HouseSn} 连接断开"); break; }
+                        catch (Exception ex)
+                        {
+                            errCount++;
+                            _log($"[debug] 推流舱{session.HouseSn} 抓帧/编码异常({errCount}/5): {ex.Message}");
+                            if (errCount >= 5) { _log($"[debug] 推流舱{session.HouseSn} 连续错误过多,停"); break; }
+                            Thread.Sleep(500);
+                        }
+                    }
+                }
+                catch (Exception ex) { _log($"[debug] 推流舱{session.HouseSn} 线程异常: {ex.Message}"); }
+                finally
+                {
+                    session.StreamBroken = true;   // 可回收快信号;会话最终由心跳 TTL 看门狗收(不在此 Dispose,避免与命令分发/超时回收争 lease)
+                    try { resp.OutputStream.Close(); } catch { }
+                    try { resp.Close(); } catch { }
+                    _log($"[debug] 推流舱{session.HouseSn} 线程结束");
+                }
+            });
+            t.IsBackground = true;
+            t.Name = $"MjpegStream-h{session.HouseSn}";
+            t.Start();
+        }
+
         public void Stop()
         {
             try { _cts?.Cancel(); _listener?.Stop(); _listener?.Close(); }

+ 2 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.cs

@@ -11,6 +11,8 @@ namespace IvfTl.ControlHost.Debug
         public DateTime LastSeen { get; set; }
         public int CurrentHor { get; set; } = -1;
         public int CurrentVer { get; set; } = -1;
+        /// <summary>推流线程因任何原因退出时置 true(spec §4.4)。可回收快信号,不替代心跳 TTL。</summary>
+        public bool StreamBroken { get; set; }
         public DebugSession(string sessionId, int houseSn, IHardwareLease lease, DateTime now)
         {
             SessionId = sessionId; HouseSn = houseSn; Lease = lease; LastSeen = now;

+ 6 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs

@@ -35,6 +35,12 @@ namespace IvfTl.ControlHost.Debug
             if (sid != null && _sessions.TryGetValue(sid, out var s)) { s.LastSeen = _clock(); return DebugCommandResult.Okay(); }
             return DebugCommandResult.Fail("SESSION_EXPIRED", "会话不存在或已过期");
         }
+        /// <summary>只读取会话(供推流端点校验 sid)。不刷新 LastSeen、不改状态。</summary>
+        public bool TryGet(string sid, out DebugSession session)
+        {
+            if (sid != null) return _sessions.TryGetValue(sid, out session);
+            session = null; return false;
+        }
         public DebugCommandResult Release(string sid)
         {
             if (sid != null && _sessions.TryRemove(sid, out var s))

+ 51 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MjpegStreamWriter.cs

@@ -0,0 +1,51 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>
+    /// MJPEG 推流的纯逻辑:RGB 像素 → JPEG 字节,JPEG → multipart 帧字节。
+    /// 无 IO、无相机依赖,可纯单测。真正的抓帧/写流由 ControlHttpServer 推流分支驱动。
+    /// 相机抓帧返回 24bpp BGR(GrabStable),WPF JpegBitmapEncoder 需 Bgr24 像素格式。
+    /// </summary>
+    public static class MjpegStreamWriter
+    {
+        public const string Boundary = "frame";
+
+        /// <summary>把 24bpp BGR 像素(width*height*3)编码成 JPEG 字节(质量 85)。</summary>
+        public static byte[] EncodeJpeg(byte[] bgr, int width, int height, int quality = 85)
+        {
+            if (bgr == null || bgr.Length < width * height * 3) return null;
+            int stride = width * 3;
+            var bmp = BitmapSource.Create(width, height, 96, 96,
+                PixelFormats.Bgr24, null, bgr, stride);
+            var encoder = new JpegBitmapEncoder { QualityLevel = quality };
+            encoder.Frames.Add(BitmapFrame.Create(bmp));
+            using (var ms = new MemoryStream())
+            {
+                encoder.Save(ms);
+                return ms.ToArray();
+            }
+        }
+
+        /// <summary>把 JPEG 字节封成一个 multipart/x-mixed-replace 帧(含 boundary 头 + 帧体 + \r\n)。</summary>
+        public static byte[] FrameBytes(byte[] jpeg)
+        {
+            if (jpeg == null) return null;
+            string header = $"--{Boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: {jpeg.Length}\r\n\r\n";
+            byte[] head = Encoding.ASCII.GetBytes(header);
+            byte[] tail = Encoding.ASCII.GetBytes("\r\n");
+            var frame = new byte[head.Length + jpeg.Length + tail.Length];
+            Buffer.BlockCopy(head, 0, frame, 0, head.Length);
+            Buffer.BlockCopy(jpeg, 0, frame, head.Length, jpeg.Length);
+            Buffer.BlockCopy(tail, 0, frame, head.Length + jpeg.Length, tail.Length);
+            return frame;
+        }
+
+        /// <summary>响应头里的 Content-Type 值(供 ControlHttpServer 写头用)。</summary>
+        public static string ContentType => $"multipart/x-mixed-replace; boundary={Boundary}";
+    }
+}

+ 83 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegFrameParser.cs

@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// MJPEG multipart 流切帧状态机(纯逻辑)。喂入任意大小的字节块,吐出完整 JPEG 帧。
+    /// 处理:一帧跨多个块、一个块含多帧、半个头跨块。只认 Content-Length 截帧(不靠扫 boundary 找帧尾,稳)。
+    /// 流格式:--frame\r\nContent-Type: image/jpeg\r\nContent-Length: N\r\n\r\n[N 字节 JPEG]\r\n
+    /// </summary>
+    public sealed class MjpegFrameParser
+    {
+        private readonly List<byte> _buf = new List<byte>();
+        private int _expectedLen = -1;   // -1 = 还没解析到帧头;>=0 = 正在收帧体
+        private int _bodyStart = -1;
+
+        /// <summary>喂入一段字节,返回这次能切出的完整 JPEG 帧(可能 0~多帧)。</summary>
+        public IEnumerable<byte[]> Feed(byte[] chunk, int count)
+        {
+            if (chunk == null || count <= 0) return Array.Empty<byte[]>();
+            if (count == chunk.Length) _buf.AddRange(chunk);
+            else for (int i = 0; i < count; i++) _buf.Add(chunk[i]);
+            var frames = new List<byte[]>();
+
+            while (true)
+            {
+                if (_expectedLen < 0)
+                {
+                    // 找头部结束标志 \r\n\r\n
+                    int sep = IndexOfDoubleCrlf(_buf);
+                    if (sep < 0) break;  // 头还没收全,等下一块
+                    string header = Encoding.ASCII.GetString(_buf.ToArray(), 0, sep);
+                    int len = ParseContentLength(header);
+                    if (len < 0)
+                    {
+                        // 头里没 Content-Length(异常/坏帧)→ 丢弃到分隔符后,继续
+                        _buf.RemoveRange(0, sep + 4);
+                        continue;
+                    }
+                    _expectedLen = len;
+                    _bodyStart = sep + 4;  // \r\n\r\n 之后是帧体
+                }
+
+                // 收帧体:需要 _bodyStart + _expectedLen 字节
+                if (_buf.Count < _bodyStart + _expectedLen) break;  // 帧体没收全,等下一块
+
+                var jpeg = new byte[_expectedLen];
+                _buf.CopyTo(_bodyStart, jpeg, 0, _expectedLen);
+                frames.Add(jpeg);
+
+                // 移除已消费的帧(头 + 体 + 可能的尾 \r\n)。下一帧从 --frame 开始,统一靠下轮找 \r\n\r\n。
+                int consumed = _bodyStart + _expectedLen;
+                // 跳过帧体后的 \r\n(若存在)
+                if (_buf.Count >= consumed + 2 && _buf[consumed] == (byte)'\r' && _buf[consumed + 1] == (byte)'\n')
+                    consumed += 2;
+                _buf.RemoveRange(0, consumed);
+                _expectedLen = -1; _bodyStart = -1;
+            }
+            return frames;
+        }
+
+        private static int IndexOfDoubleCrlf(List<byte> buf)
+        {
+            for (int i = 0; i + 3 < buf.Count; i++)
+                if (buf[i] == (byte)'\r' && buf[i + 1] == (byte)'\n' && buf[i + 2] == (byte)'\r' && buf[i + 3] == (byte)'\n')
+                    return i;
+            return -1;
+        }
+
+        private static int ParseContentLength(string header)
+        {
+            foreach (var line in header.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
+            {
+                int colon = line.IndexOf(':');
+                if (colon < 0) continue;
+                if (line.Substring(0, colon).Trim().Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
+                    if (int.TryParse(line.Substring(colon + 1).Trim(), out int n)) return n;
+            }
+            return -1;
+        }
+    }
+}

+ 120 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/MjpegStreamClient.cs

@@ -0,0 +1,120 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Media.Imaging;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// operate 端 MJPEG 预览客户端:流式读 control /debug/preview/stream → 切帧 → 解码 BitmapImage → 事件回调。
+    /// 断开/异常 → Stopped 事件(View 据此提示操作人员手动重开,不自动重连,spec §7)。
+    /// 实例一次性:Stop/Dispose 后不可复用,需重新 new。View 每次开预览应 new 新实例。
+    /// </summary>
+    public sealed class MjpegStreamClient : IDisposable
+    {
+        private readonly string _baseUrl;   // 如 http://127.0.0.1:38080
+        private HttpClient _http;
+        private CancellationTokenSource _cts;
+        private bool _disposed;
+
+        /// <summary>收到一帧(已 Freeze,可跨线程贴 UI)。</summary>
+        public event Action<BitmapImage> FrameReceived;
+        /// <summary>预览停止(reason:断开原因,供 View 提示)。</summary>
+        public event Action<string> Stopped;
+
+        public bool IsRunning { get; private set; }
+
+        public MjpegStreamClient(string baseUrl) { _baseUrl = baseUrl.TrimEnd('/'); }
+
+        /// <summary>开始预览。sessionId = 第一阶段 acquire 返回的会话 id。</summary>
+        public void Start(string sessionId)
+        {
+            if (_disposed) return;
+            if (IsRunning) return;
+            IsRunning = true;
+            _cts = new CancellationTokenSource();
+            _http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };  // 长连接,不超时掐断
+            string url = $"{_baseUrl}/debug/preview/stream?sessionId={sessionId}";
+            _ = Task.Run(() => ReadLoop(url, _cts.Token));
+        }
+
+        private async Task ReadLoop(string url, CancellationToken token)
+        {
+            string reason = "预览已结束";
+            try
+            {
+                using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token))
+                {
+                    if (!resp.IsSuccessStatusCode)
+                    {
+                        reason = resp.StatusCode == System.Net.HttpStatusCode.NotFound
+                            ? "调试会话已超时,请重新进入调试"
+                            : $"预览打开失败({(int)resp.StatusCode})";
+                        return;
+                    }
+                    var parser = new MjpegFrameParser();
+                    using (var stream = await resp.Content.ReadAsStreamAsync())
+                    {
+                        var buf = new byte[64 * 1024];
+                        while (!token.IsCancellationRequested)
+                        {
+                            int n = await stream.ReadAsync(buf, 0, buf.Length, token);
+                            if (n <= 0) { reason = "预览连接已断开,请重新打开预览"; break; }
+                            foreach (var jpeg in parser.Feed(buf, n))
+                            {
+                                var img = Decode(jpeg);
+                                if (img != null) FrameReceived?.Invoke(img);
+                            }
+                        }
+                    }
+                }
+            }
+            catch (OperationCanceledException) { reason = "预览已关闭"; }    // 主动 Stop
+            catch (Exception ex)
+            {
+                reason = token.IsCancellationRequested
+                    ? "预览已关闭"                                   // 主动 Stop 期间抛的任何异常都归为"已关闭"
+                    : $"预览中断,请重新打开预览({ex.Message})";
+            }
+            finally
+            {
+                IsRunning = false;
+                try { _http?.Dispose(); } catch { }   // 谁起的谁清:HttpClient 在 ReadLoop 结束时释放
+                Stopped?.Invoke(reason);
+            }
+        }
+
+        private static BitmapImage Decode(byte[] jpeg)
+        {
+            try
+            {
+                var img = new BitmapImage();
+                using (var ms = new MemoryStream(jpeg))
+                {
+                    img.BeginInit();
+                    img.CacheOption = BitmapCacheOption.OnLoad;   // 解完即脱离流
+                    img.StreamSource = ms;
+                    img.EndInit();
+                }
+                img.Freeze();   // 跨线程贴 UI 必须 Freeze
+                return img;
+            }
+            catch { return null; }   // 坏帧丢弃,不打断流
+        }
+
+        /// <summary>主动停止预览(关预览按钮/返回)。token 取消是唯一停止信号,_http 由 ReadLoop finally 清理。</summary>
+        public void Stop()
+        {
+            try { _cts?.Cancel(); } catch { }
+            IsRunning = false;
+        }
+
+        public void Dispose()
+        {
+            _disposed = true;
+            Stop();
+        }
+    }
+}

+ 1 - 1
ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/ControlClient.cs

@@ -20,7 +20,7 @@ namespace ivf_tl_Operate.Helpers
         public static int Port =>
             int.TryParse(ConfigurationManager.AppSettings["controlPort"], out var p) ? p : 38080;
 
-        private static string BaseUrl => $"http://127.0.0.1:{Port}";
+        public static string BaseUrl => $"http://127.0.0.1:{Port}";
 
         /// <summary>GET /status → 取回 control 的 MonitorSnapshot(失败返回 null,监控页据此显示"未托管")。</summary>
         public static MonitorSnapshot GetStatusSnapshot()

+ 4 - 1
ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml

@@ -286,7 +286,10 @@
 
             <StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Center" >
                 <!--<Rectangle Visibility="Hidden" Fill="Black" Width="800" Height="600"/>-->
-                <Border Width="800" Height="600" BorderBrush="Black" BorderThickness="1"/>
+                <Border Width="800" Height="600" BorderBrush="Black" BorderThickness="1">
+                    <!-- D2-02 第二阶段:MJPEG 预览画面(替代原贴窗口句柄方式) -->
+                    <Image x:Name="_previewImage" Width="800" Height="600" Stretch="Uniform"/>
+                </Border>
                 <StackPanel x:Name="_stackPanel2" VerticalAlignment="Center" Margin="150 0 0 0" IsEnabled="False">
                     <customControl:ButtonCornerRadius Click="OpenVideo_Click" Content="{DynamicResource C0079}" FontSize="24" Margin="0 0 0 100"  Width="218" Height="250" CornerRadius="20" Background="#BFD87D" EnabledBackground="#bdbdbd"  BorderThickness="0"/>
                     <customControl:ButtonCornerRadius Click="CloseVideo_Click"  Content="{DynamicResource C0080}" FontSize="24"   Width="218" Height="250" CornerRadius="20" Background="#BFD87D" EnabledBackground="#bdbdbd"  BorderThickness="0"/>

+ 29 - 22
ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs

@@ -1,6 +1,7 @@
 using ivf_tl_CustomControls;
 using ivf_tl_Entity.CameraEntitys;
 using ivf_tl_Entity.GlobalEnums;
+using ivf_tl_Operate.Debug;
 using ivf_tl_Operate.ViewModel;
 using ivf_tl_Operate.Windows;
 using Newtonsoft.Json.Linq;
@@ -41,6 +42,9 @@ namespace ivf_tl_Operate.View
         /// </summary>
         private bool isOpen = false;
 
+        /// <summary>D2-02 第二阶段:MJPEG 预览客户端(一次性,每次开预览 new 新实例)。</summary>
+        private MjpegStreamClient _mjpeg;
+
         private object locker = new object();
 
         //#bdbdbd  #bfd87d
@@ -206,19 +210,27 @@ namespace ivf_tl_Operate.View
         {
             try
             {
-                if (isOpen)
+                if (isOpen) { AddMessageInfo($"图像已打开"); return; }
+                string sessionId = vm.CurrentSessionId;
+                if (string.IsNullOrEmpty(sessionId)) { AddMessageInfo("未借用会话,无法预览(请先初始化)"); return; }
+
+                // 每次开预览 new 新实例(MjpegStreamClient 一次性,不可复用)
+                var client = new MjpegStreamClient(ivf_tl_Operate.Helpers.ControlClient.BaseUrl);
+                _mjpeg = client;
+                client.FrameReceived += img => Dispatcher.Invoke(() =>
                 {
-                    AddMessageInfo($"图像已打开");
-                    return;
-                }
-                IntPtr hwnd = ((System.Windows.Interop.HwndSource)PresentationSource.FromVisual(AppData.Instance.MainWindow)).Handle;
-                //var a = vm.camera.Usb2Start(hwnd, 328, 865, 800, 600);
-                var a = vm.StartPreview(hwnd, 328, 805, 800, 600);
-                AddMessageInfo($"图像打开结果:{a}[注:0表示成功]");
-                if (a == 0)
+                    if (_mjpeg != client) return;        // 已被 CloseVideo 换/清,丢弃残帧(避免关画面后残帧写回)
+                    _previewImage.Source = img;
+                });
+                client.Stopped += reason => Dispatcher.Invoke(() =>
                 {
-                    isOpen = true;
-                }
+                    if (_mjpeg != client) return;        // 旧实例的 Stopped 不串台到新会话
+                    isOpen = false;
+                    AddMessageInfo($"⚠ {reason}");   // 明确提示操作人员手动重开(不自动重连)
+                });
+                client.Start(sessionId);
+                isOpen = true;
+                AddMessageInfo("图像已打开(MJPEG 实时预览)");
             }
             catch (Exception ex)
             {
@@ -231,17 +243,12 @@ namespace ivf_tl_Operate.View
         {
             try
             {
-                if (!isOpen)
-                {
-                    AddMessageInfo($"图像未打开,无需关闭");
-                    return;
-                }
-                var a = vm.StopPreview();
-                AddMessageInfo($"图像关闭结果:{a}[注:0表示成功]");
-                if (a == 0)
-                {
-                    isOpen = false;
-                }
+                if (!isOpen) { AddMessageInfo($"图像未打开,无需关闭"); return; }
+                _mjpeg?.Stop();
+                _mjpeg = null;   // 丢弃实例(一次性,下次开预览 new 新的)
+                Dispatcher.Invoke(() => { _previewImage.Source = null; });
+                isOpen = false;
+                AddMessageInfo("图像已关闭");
             }
             catch (Exception ex)
             {

+ 3 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs

@@ -61,6 +61,9 @@ namespace ivf_tl_Operate.ViewModel
         /// </summary>
         public int CurrentHouseId { get; set; } = 1;
 
+        /// <summary>当前借用会话 id(D2-02 第三阶段 acquire 时赋值;第二阶段预览用)。</summary>
+        public string CurrentSessionId { get; set; }
+
         /// <summary>
         /// 当前ccdID
         /// </summary>

Diff do ficheiro suprimidas por serem muito extensas
+ 22 - 0
项目文档/业务流程图/培养全流程详图/dagre.min.js


+ 438 - 0
项目文档/业务流程图/培养全流程详图/flow-data.js

@@ -0,0 +1,438 @@
+// ============================================================
+// 时差培养箱 · 培养全流程 · 节点与连线数据
+// 设计原则:所有业务分支都真实画进图(不藏在面板文字里)
+//   - 节点 type: start/operate/control/front/branch/error
+//   - 边 type: internal(本端实线) / cross(跨端虚线) / error(异常点线/回流)
+// ============================================================
+
+const NODES = [
+  // ========== 起点 ==========
+  {
+    id: 'start', type: 'start', icon: '🥚', title: '放入胚胎·开始',
+    brief: '医生取卵受精后,把胚胎放进培养皿,准备入箱',
+    detail: {
+      desc: '这是整个时差培养的起点:医生在实验室完成取卵、受精(IVF/ICSI),得到胚胎后,准备把胚胎放进时差培养箱的某个空舱开始延时培养。',
+      pre: ['已完成取卵+受精,得到胚胎', '时差培养箱有空闲舱室', 'operate 操作端已登录'],
+      trigger: '医生在 operate 触摸屏主界面,看到目标舱室是"空闲"状态',
+      steps: ['确认胚胎数量、对应孔位', '走到培养箱旁的 operate 触摸屏', '点击空闲舱格 → 进入"新建入箱"'],
+      backend: '无后端动作(纯物理 + 界面准备)',
+      data: ['暂无数据写入,等"开始培养"才落库'],
+      cross: null,
+      code: ['operate MainPageView.xaml.cs:155 CustomHouseInfo_ClickHouseEvent']
+    }
+  },
+
+  // ========== operate:主界面 ==========
+  {
+    id: 'op-main', type: 'operate', icon: '🖥️', title: '主界面·舱室总览',
+    brief: '显示 10 个舱室状态,点空舱进入入箱',
+    detail: {
+      desc: 'operate 操作端首页,网格显示 10 个培养舱 + 缓冲瓶的实时状态(空闲/培养中/平衡中),顶栏显示报警数。是所有操作的入口。',
+      pre: ['operate 已登录', 'control 后台已启动并上报状态'],
+      trigger: 'operate 启动登录后 → MainWindow → LoadPage(MainPageView)',
+      steps: ['定时轮询每个舱室状态刷新舱格颜色/患者信息', '点空舱 → 弹"新建入箱"窗', '点有患者舱 → 进看图详情页', '点报警角标 → 报警页'],
+      backend: '定时轮询 GetHouseStateApi → 更新 10 个舱格 UI',
+      data: ['内存:MainPageViewModel 维护 10 个 HouseInfo', 'UI:舱格颜色随状态实时变'],
+      cross: { from: 'operate 显示舱室状态', via: 'HTTP 轮询 GetHouseStateApi', to: ['control 实时上报每舱温度/压力/有无培养皿 → operate 渲染'] },
+      code: ['operate MainPageView.xaml.cs:155', 'operate MainPageViewModel.cs(轮询)']
+    }
+  },
+
+  // ========== operate:新建入箱 ==========
+  {
+    id: 'op-add', type: 'operate', icon: '📝', title: '新建患者入箱',
+    brief: '录入患者信息 + 选 16 孔位 + 受精方式',
+    detail: {
+      desc: '点空舱弹出"新建患者"窗:在 16 孔圆盘上点选放胚胎的孔位,填夫妻姓名/病例号/周期/受精时间/受精方式(IVF/ICSI),可勾"重点关注"。',
+      pre: ['舱室=空闲', '有操作权限', '该舱硬件正常(串口/相机/电机)'],
+      trigger: 'operate 主界面点空舱格 → 弹 AddDishWindowView',
+      steps: ['在 16 孔圆盘点选胚胎孔位(可多选)', '填病例号、周期、夫妻姓名、出生日期、受精时间、受精方式', '可选勾"重点关注"(VIP)', '底部二选一:【平衡】或【保存=开始培养】'],
+      backend: 'operate 前端校验必填 → 点按钮分两条路(见下方两个分支)',
+      data: ['此步仅前端表单,未落库(点了"开始培养"才落库)'],
+      cross: null,
+      code: ['operate AddDishWindowView.xaml.cs:54 构造', 'operate AddDishWindowView.xaml.cs:451 StartDish_Click']
+    }
+  },
+
+  // ========== 分支判断:平衡 还是 直接开始 ==========
+  {
+    id: 'br-balance-or-start', type: 'branch', icon: '❓', title: '判断:先平衡?还是直接开始?',
+    brief: '入箱窗底部两个按钮 → 两条路',
+    detail: {
+      desc: '新建入箱窗底部有两个按钮,对应两条不同的业务路径:点【平衡】先做环境预平衡,点【保存】直接开始培养。',
+      pre: ['已在新建入箱窗', '表单校验通过'],
+      trigger: '点底部按钮',
+      steps: ['【平衡】→ 走平衡流程(换气稳定环境,可反复)', '【保存=开始培养】→ 直接进培养'],
+      backend: '两个按钮分别触发 StartBalance_Click 或 StartDish_Click',
+      data: ['取决于走哪条分支'],
+      cross: null,
+      code: ['operate AddDishWindowView.xaml.cs:640 StartBalance_Click(平衡)', 'operate AddDishWindowView.xaml.cs:451 StartDish_Click(开始培养)']
+    }
+  },
+
+  // ========== 平衡分支 ==========
+  {
+    id: 'op-balance', type: 'operate', icon: '⚖️', title: '平衡分支:开启平衡',
+    brief: '换气预平衡,使舱内温压稳定',
+    detail: {
+      desc: '可选分支。开启平衡后,control 对该舱反复换气,让舱内温度/气压达到稳定,再放胚胎培养,避免胚胎一进箱就遇环境冲击。',
+      pre: ['在入箱窗点了【平衡】', '舱室=空闲'],
+      trigger: 'AddDishWindowView 点【平衡】→ StartBalance_Click',
+      steps: ['调 StartBalance 接口创建 balance 记录', '后端发 MQTT StartBalance 给 control', 'control 收到 → HouseBin.StartBlance → 舱主循环走"平衡换气"', '医生手动点【结束平衡】后,再回入箱窗点【开始培养】'],
+      backend: 'StartBalance → /balance/startBalance → balance 表插入 → MqttSendRpc 发 MQTT StartBalance',
+      data: ['DB:balance 表插入一条', 'control 本地 SQLite:AddBalance', '内存:HouseBin.Balance 赋值,FirstAir=true', '舱状态:空闲 → 平衡中'],
+      cross: { from: 'operate 点【平衡】', via: 'StartBalance 接口 → balance 表 → MQTT StartBalance', to: ['control 收到 → HouseBin.StartBlance → 舱主循环执行平衡换气', 'front 设备管理:该舱显示"平衡中"', 'operate 主界面:该舱显示"平衡中"'] },
+      code: ['operate AddDishWindowView.xaml.cs:640', 'control AppData.cs:1066 StartBalance', 'control HouseBin.cs:2865 StartBlance', 'DB balance 表']
+    }
+  },
+  {
+    id: 'op-end-balance', type: 'operate', icon: '⏹️', title: '结束平衡',
+    brief: '平衡够了,手动结束 → 回入箱',
+    detail: {
+      desc: '平衡到合适程度,医生手动点【结束平衡】。control 停止平衡换气,舱室回空闲,医生再回入箱窗点【开始培养】正式入箱。',
+      pre: ['舱室正在平衡中'],
+      trigger: 'operate 点【结束平衡】→ StopBalance',
+      steps: ['调 StopBalance 接口结束 balance 记录', '发 MQTT EndBalance', 'control 收到 → HouseBin 停平衡', '回到入箱窗点【开始培养】'],
+      backend: 'StopBalance → /balance/stopBalance → balance 表更新 → MQTT EndBalance',
+      data: ['DB:balance 表 endTime', 'control 内存:HouseBin.Balance 置 null', '舱状态:平衡中 → 空闲'],
+      cross: { from: 'operate 点【结束平衡】', via: 'StopBalance → MQTT EndBalance', to: ['control 停平衡换气', '舱室回空闲,可正式入箱'] },
+      code: ['operate AddDishWindowViewModel.cs:164 StopBalance', 'control AppData.cs:1084 EndBalance']
+    }
+  },
+
+  // ========== operate:开始培养(核心跨端点)==========
+  {
+    id: 'op-start-dish', type: 'operate', icon: '▶️', title: '开始培养(保存)',
+    brief: '落库 dish+embryo → 发 MQTT 通知 control',
+    detail: {
+      desc: '入箱的核心动作。前端校验后调 StartDishApi,后端把培养皿+每个胚胎落库,再发 MQTT StartDish 命令通知 control 开始驱动硬件培养。',
+      pre: ['表单校验通过(孔位≥1、必填非空、受精时间合法)', '舱室=空闲', 'control 在线'],
+      trigger: 'operate 入箱窗点【保存=开始培养】→ StartDish_Click',
+      steps: ['前端校验所有必填项', '调 StartDishApi(POST /dish/start)', '后端落库:dish 表 1 条 + embryo 表 N 条(N=孔位数)', '后端发 MQTT StartDish → control'],
+      backend: 'StartDish_Click → StartDishApi → dish/embryo 落库 → MqttSendRpc 发 MQTT StartDish(topic=tl/command/{tlSn})',
+      data: ['DB:dish 表插 1 条(state=培养中),embryo 表插 N 条(state=0 待拍)', '舱状态:空闲 → 培养中'],
+      cross: { from: 'operate 点【开始培养】', via: 'StartDishApi → dish/embryo 落库 → MQTT StartDish', to: ['control 收 MQTT → AppData.StartDish → HouseBin.StartDish → 启动舱主循环', 'front 设备管理:舱格变"培养中"+患者信息', 'front 培养记录列表:新增一条', 'operate 主界面:舱格变色+显示患者'] },
+      code: ['operate AddDishWindowView.xaml.cs:451 StartDish_Click', 'control AppData.cs:1102 StartDish', 'control HouseBin.cs:2891 StartDish', 'DB dish/embryo 表']
+    }
+  },
+
+  // ========== control:收到 StartDish ==========
+  {
+    id: 'ctl-recv', type: 'control', icon: '⚙️', title: 'control 收到 StartDish',
+    brief: 'MQTT 收命令 → 同步本地库 → 启动舱循环',
+    detail: {
+      desc: 'control 后台通过 MQTT 收到 StartDish 命令,同步本地 SQLite,给 HouseBin 设置培养皿对象,启动该舱的主循环线程。',
+      pre: ['MQTT 连接正常', 'control 进程运行中', '该舱硬件已扫描登记'],
+      trigger: 'MQTT topic=tl/command/{tlSn}, type=StartDish',
+      steps: ['MqttService 收消息 → AppData.MqttMessage 按 type 分发', 'AppData.StartDish → DBService 同步 dish/embryo 到本地 SQLite', 'HouseBin.StartDish → 设 Dish、FirstClearest=true、清对焦历史'],
+      backend: 'AppData.cs:995 MqttMessage → case StartDish → AppData.cs:1102 StartDish → HouseBin.cs:2891 StartDish',
+      data: ['本地 SQLite:dish/embryo 同步插入', '内存:HouseBin.Dish 赋值,FirstClearest=true'],
+      cross: { from: 'control 收到 MQTT StartDish', via: '本地 SQLite 同步 + HouseBin 状态设置', to: ['本舱主循环开始运转(见下)', '不反向通知(operate/front 在落库时已刷新)'] },
+      code: ['control AppData.cs:995 MqttMessage', 'control AppData.cs:1102 StartDish', 'control HouseBin.cs:2891 StartDish']
+    }
+  },
+
+  // ========== control:舱主循环(核心,分出三条路)==========
+  {
+    id: 'ctl-loop', type: 'control', icon: '🔁', title: '舱主循环 MainThread',
+    brief: '每舱一线程·看表轮询·分流到换气/对焦/拍照',
+    detail: {
+      desc: 'control 的核心:每个舱一个独立线程死循环。每轮先读温压、判断舱门,再按"有皿/有平衡/都没有"分流,有皿时依次判断该换气、该对焦、该拍照。',
+      pre: ['HouseBin.Dish!=null(有皿) 或 Balance!=null(平衡)', '舱门关闭', '非调试/非前台借用'],
+      trigger: 'HouseBin.MainThread → Task.Factory.StartNew → while(true)',
+      steps: ['读温压 ParamFun,低压标记待补气', '舱门没关 → 跳过本轮', '有皿:判断换气间隔→换气;判断 FirstClearest→对焦;对焦成功→拍照', '有平衡:执行平衡换气', '都没有:空闲监测'],
+      backend: 'HouseBin.cs:614 MainThread,按 Dish/Balance 是否非空分三大分支',
+      data: ['内存:Pressure/Temperature 实时更新', 'FirstClearest:对焦成功后转 false', 'RunState:当前状态字符串'],
+      cross: { from: 'control 舱主循环持续运转', via: '温压采集 + 对焦/拍照触发', to: ['拍照结果经 Kafka → aivfo-oplog 入库 picture 表 → operate/front 轮询可见', '温压经 MQTT 上报 → operate/front 显示'] },
+      code: ['control HouseBin.cs:614 MainThread', 'HouseBin.cs:663 ParamFun', 'HouseBin.cs:696/704/726 三分支判断']
+    }
+  },
+
+  // ----- 分支1:换气 -----
+  {
+    id: 'ctl-airswap', type: 'control', icon: '💨', title: '换气(AirSwapFun)',
+    brief: '到换气间隔 → 开排气阀冲刷 + 低压补气',
+    detail: {
+      desc: '舱主循环分支之一。到达换气间隔(airSwapFrequency)就执行换气:开排气阀冲刷舱内气体若干轮,中途气压低就向缓冲瓶申请补气。',
+      pre: ['有皿或平衡中', '换气计时器 ≥ airSwapFrequency'],
+      trigger: '主循环判断 AirSwapTimeStopwatch ≥ 间隔 → AirSwapFun',
+      steps: ['开排气阀冲刷 N 轮', '气压低于下限 → AerationNew 补气(找缓冲瓶要气)', '记录换气审计日志', '换气完成,重置计时器'],
+      backend: 'HouseBin.cs:975 AirSwapFun / AirSwapQueueFun(排队换气经缓冲瓶)',
+      data: ['硬件:排气阀/进气阀动作', '内存:WorkingType=AirSwapWorking', '气压回升'],
+      cross: { from: 'control 换气', via: '与缓冲瓶 BufferBottleBin 排队握手', to: ['缓冲瓶供气', '换气状态经 MQTT 上报 operate/front'] },
+      code: ['control HouseBin.cs:975 AirSwapFun', 'HouseBin.cs:1138 AirSwapQueueFun', 'control BufferBottleBin.cs:136 缓冲瓶']
+    }
+  },
+
+  // ----- 分支2:对焦 -----
+  {
+    id: 'ctl-focus', type: 'control', icon: '🔬', title: '自动对焦(GetClearest)',
+    brief: '逐孔四步标定,找最清晰 Z 位',
+    detail: {
+      desc: '舱主循环分支之二。FirstClearest=true 时执行:遍历所有待拍 well,逐个跑四步标定算法找到最清晰焦平面 Z 位,存盘 calibration.json + 入库。',
+      pre: ['FirstClearest=true(首次或上次对焦失败)', '有 state=0 的 well', '舱门关闭'],
+      trigger: '主循环 → StartAutoFocus → GetClearest(AutoFocusThread)',
+      steps: ['筛选待拍 well:Dish.Embryo.Where(state==0)', '逐 well 调 AutoFocusWellAny 四步标定', '算法:粗扫→精扫→峰值判定→得最佳 FocusZ', '存盘 calibration.json + DB house_autofocus_calibration', '成功→FirstClearest=false;失败→保持 true 下轮重试'],
+      backend: 'HouseBin.cs:729 GetClearest → AutoFocusWellAny → CalibrationStore.SaveAsync',
+      data: ['calibration.json 追加对焦结果', 'DB house_autofocus_calibration 表', 'FirstClearest→false', 'LastAutoFocusTimeDic 记时间'],
+      cross: { from: 'control 自动对焦', via: 'calibration.json + DB 入库', to: ['对焦参数可被 operate/front 查询', '对焦成功后进入拍照'] },
+      code: ['control HouseBin.cs:729 GetClearest', 'control AutofocusTool 四步标定', 'control CalibrationStore', 'DB house_autofocus_calibration']
+    }
+  },
+
+  // ----- 分支3:拍照 -----
+  {
+    id: 'ctl-photo', type: 'control', icon: '📸', title: '拍照(ccdThreadFun)',
+    brief: '逐孔逐层抓图 → 存盘 → 上传 Kafka',
+    detail: {
+      desc: '舱主循环分支之三。对焦完成且到拍照间隔时执行:遍历所有待拍 well,逐孔逐焦层移动电机抓图,存盘本地,上传 Kafka。',
+      pre: ['对焦完成(FirstClearest=false)', '到拍照间隔(≥photoIntervalPerRound)', 'IsCCD=true(有 state=0 的 well)'],
+      trigger: '主循环 → StartCCD → ccdThreadFun',
+      steps: ['IsCCD 筛选待拍 well', '逐 well 逐层:移电机→相机抓图→存盘本地', '命名 {tlSn}/{houseSn}/{dishId}/{wellSn}/{时间}_{层}.bmp', '封装 ImageDTO → KafkaService 上传 tl-picture topic'],
+      backend: 'HouseBin.cs:2171 ccdThreadFun → CCDStart 逐孔逐层 → Camera.TakePicture → KafkaService.cs:115 kafkaProducerAsync',
+      data: ['本地磁盘:图片文件', 'Kafka:ImageDTO 消息', 'DB picture 表(由 aivfo-oplog 消费 Kafka 写入)'],
+      cross: { from: 'control 拍照上传 Kafka', via: 'Kafka tl-picture → aivfo-oplog 消费 → picture 表入库 + 文件转 OSS', to: ['operate 看图页:轮询拉取最新延时图', 'front 胚胎详情:轮询拉取延时回放'] },
+      code: ['control HouseBin.cs:2171 ccdThreadFun', 'control HouseBin.cs:1459 IsCCD', 'control KafkaService.cs:115', 'DB picture 表']
+    }
+  },
+
+  // ========== operate:看图页 ==========
+  {
+    id: 'op-detail', type: 'operate', icon: '🎬', title: '看图页·延时回放',
+    brief: '16 孔圆盘 + 延时视频 + 胚胎去向操作',
+    detail: {
+      desc: '点主界面有患者的舱进入看图页:16 孔圆盘选 well,播放该 well 从受精开始的延时缩影片(播放/暂停/倍速/逐帧/切焦层),底部 6 个操作按钮。',
+      pre: ['舱有培养皿(Dish.id>0)', '已拍至少一张图'],
+      trigger: 'operate 主界面点有患者舱 → DetailPageView,传 dishId',
+      steps: ['加载 GetDishPicAndVideoApi(dishId) → 16 well 图片/视频', '圆盘点 well 切换当前胚胎', '播放控制:播放/暂停/倍速/上下帧/拖进度条/切焦层', '底部 6 钮:移植/冷冻/删除/作废/结束培养/图片查看'],
+      backend: 'DetailPageViewModel.Init → GetDishPicAndVideoApi → mediaElement 播放',
+      data: ['内存:DishPicAndVideoList 保存 16 well 数据', 'UI:视频播放'],
+      cross: { from: 'operate 看图页', via: 'HTTP GetDishPicAndVideoApi 拉 picture 表', to: ['control 拍照上传的图经 Kafka→DB→operate 轮询拉到'] },
+      code: ['operate DetailPageView.xaml.cs:44 Loaded', 'operate DetailPageViewModel.cs Init', 'GetDishPicAndVideoApi']
+    }
+  },
+
+  // ========== 分支判断:胚胎去向 ==========
+  {
+    id: 'br-destination', type: 'branch', icon: '❓', title: '判断:胚胎去向?',
+    brief: '看图后决定每个胚胎去哪 → 四条路',
+    detail: {
+      desc: '医生看完延时回放评估胚胎质量后,对每个胚胎决定去向。底部 4 个按钮对应 4 条独立分支:移植/冷冻/删除/作废。标记后 control 不再拍该 well。',
+      pre: ['在看图页', '当前 well 有胚胎', '胚胎 state=0', '培养未结束'],
+      trigger: '看图页底部点四选一按钮',
+      steps: ['【移植】state→1', '【冷冻】state→2', '【删除】state→3', '【作废】state→4'],
+      backend: '四个按钮共用 EmbryoMark → MarkEmbryoDestinationApi(id, state)',
+      data: ['取决于点哪个'],
+      cross: null,
+      code: ['operate DetailPageView.xaml.cs:294/370/443/517(移/冻/删/废)', 'operate DetailPageView.xaml.cs:795 EmbryoMark']
+    }
+  },
+
+  // ----- 四个去向分支 -----
+  {
+    id: 'op-transplant', type: 'operate', icon: '🌱', title: '移植',
+    brief: 'state→1,胚胎用于移植',
+    detail: {
+      desc: '把当前胚胎标记为"移植"。该 well 的 embryo.state 改为 1,control 收 MQTT 后不再对焦拍照该 well。',
+      pre: ['当前 well 有胚胎', 'state=0'],
+      trigger: '看图页点【移植】→ EmbryoMove_Click',
+      steps: ['弹确认窗', '调 MarkEmbryoDestinationApi(id, 1)', 'embryo 表 state→1', '发 MQTT EmbryoState'],
+      backend: 'EmbryoMove_Click → EmbryoMark(1) → MarkEmbryoDestinationApi → MQTT EmbryoState',
+      data: ['DB embryo 表:state→1', 'control 本地同步 + 内存更新', '舱主循环 IsCCD 跳过该 well'],
+      cross: { from: 'operate 点【移植】', via: 'MarkEmbryoDestinationApi → embryo 表 → MQTT EmbryoState', to: ['control HouseBin.ChangeEmbryoState → IsCCD 跳过该 well', 'front 胚胎详情:该 well 显"移植"标签', 'front 标记明细:新增记录', 'operate 看图页:该 well 显移植图标'] },
+      code: ['operate DetailPageView.xaml.cs:294 EmbryoMove_Click', 'control AppData.cs:1238 EmbryoState', 'DB embryo 表 state=1']
+    }
+  },
+  {
+    id: 'op-freeze', type: 'operate', icon: '❄️', title: '冷冻',
+    brief: 'state→2,胚胎冷冻保存',
+    detail: {
+      desc: '把当前胚胎标记为"冷冻"。embryo.state→2,control 不再拍该 well。',
+      pre: ['当前 well 有胚胎', 'state=0'],
+      trigger: '看图页点【冷冻】→ EmbryoFree_Click',
+      steps: ['弹确认窗', '调 MarkEmbryoDestinationApi(id, 2)', 'embryo 表 state→2', '发 MQTT EmbryoState'],
+      backend: 'EmbryoFree_Click → EmbryoMark(2) → MarkEmbryoDestinationApi → MQTT EmbryoState',
+      data: ['DB embryo 表:state→2', 'control 同步', '舱主循环跳过该 well'],
+      cross: { from: 'operate 点【冷冻】', via: 'MarkEmbryoDestinationApi → MQTT EmbryoState', to: ['control 跳过该 well', 'front/operate 显示"冷冻"标签'] },
+      code: ['operate DetailPageView.xaml.cs:370 EmbryoFree_Click', 'control AppData.cs:1238 EmbryoState', 'DB embryo 表 state=2']
+    }
+  },
+  {
+    id: 'op-delete', type: 'operate', icon: '🗑️', title: '删除',
+    brief: 'state→3,胚胎删除',
+    detail: {
+      desc: '把当前胚胎标记为"删除"。embryo.state→3,control 不再拍该 well。',
+      pre: ['当前 well 有胚胎', 'state=0'],
+      trigger: '看图页点【删除】→ EmbryoDel_Click',
+      steps: ['弹确认窗', '调 MarkEmbryoDestinationApi(id, 3)', 'embryo 表 state→3', '发 MQTT EmbryoState'],
+      backend: 'EmbryoDel_Click → EmbryoMark(3) → MarkEmbryoDestinationApi → MQTT EmbryoState',
+      data: ['DB embryo 表:state→3', 'control 同步', '舱主循环跳过该 well'],
+      cross: { from: 'operate 点【删除】', via: 'MarkEmbryoDestinationApi → MQTT EmbryoState', to: ['control 跳过该 well', 'front/operate 显示"删除"标签'] },
+      code: ['operate DetailPageView.xaml.cs:443 EmbryoDel_Click', 'control AppData.cs:1238 EmbryoState', 'DB embryo 表 state=3']
+    }
+  },
+  {
+    id: 'op-invalid', type: 'operate', icon: '⛔', title: '作废',
+    brief: 'state→4,胚胎作废',
+    detail: {
+      desc: '把当前胚胎标记为"作废"。embryo.state→4,control 不再拍该 well。',
+      pre: ['当前 well 有胚胎', 'state=0'],
+      trigger: '看图页点【作废】→ EmbryoInv_Click',
+      steps: ['弹确认窗', '调 MarkEmbryoDestinationApi(id, 4)', 'embryo 表 state→4', '发 MQTT EmbryoState'],
+      backend: 'EmbryoInv_Click → EmbryoMark(4) → MarkEmbryoDestinationApi → MQTT EmbryoState',
+      data: ['DB embryo 表:state→4', 'control 同步', '舱主循环跳过该 well'],
+      cross: { from: 'operate 点【作废】', via: 'MarkEmbryoDestinationApi → MQTT EmbryoState', to: ['control 跳过该 well', 'front/operate 显示"作废"标签'] },
+      code: ['operate DetailPageView.xaml.cs:517 EmbryoInv_Click', 'control AppData.cs:1238 EmbryoState', 'DB embryo 表 state=4']
+    }
+  },
+
+  // ========== operate:结束培养 ==========
+  {
+    id: 'op-end', type: 'operate', icon: '🏁', title: '结束培养',
+    brief: '整皿收尾 → MQTT 停 control → 舱回空闲',
+    detail: {
+      desc: '看图页点"结束培养",结束整皿所有胚胎培养。dish.state→1,control 停止对焦拍照,舱室回空闲,可再放新皿。',
+      pre: ['在看图页', 'dish.state=0(培养中)'],
+      trigger: '看图页点【结束培养】→ EmbryoOver_Click',
+      steps: ['弹确认窗', '调 EndDishApi(dishId)', 'dish 表 state→1,记 endTime', '发 MQTT EndDish'],
+      backend: 'EmbryoOver_Click → Finish → EndDishApi → dish 表更新 → MQTT EndDish → control HouseBin.StopDish',
+      data: ['DB dish 表:state→1, endTime', 'control 内存:HouseBin.Dish→null', '舱主循环回空闲监测', '舱状态:培养中→空闲'],
+      cross: { from: 'operate 点【结束培养】', via: 'EndDishApi → dish 表 → MQTT EndDish', to: ['control HouseBin.StopDish → 停对焦拍照', 'front 设备管理:舱格变空闲', 'front 培养记录:显"已结束"', 'operate 主界面:舱格变灰回空闲'] },
+      code: ['operate DetailPageView.xaml.cs:591 EmbryoOver_Click', 'control AppData.cs:1120 EndDish', 'control HouseBin.cs:2904 StopDish', 'DB dish 表']
+    }
+  },
+
+  // ========== 终点 ==========
+  {
+    id: 'op-back-main', type: 'start', icon: '🔚', title: '回主界面·舱位释放',
+    brief: '舱位空出,可放下一个患者',
+    detail: {
+      desc: '结束培养后自动返回主界面,该舱格变回空闲灰色,整个培养周期结束。这个舱可以重新点击放入下一个患者的胚胎(回到流程起点)。',
+      pre: ['培养已结束'],
+      trigger: '结束培养后自动 BackMainPage',
+      steps: ['看图页自动返回主界面', '该舱格变灰=空闲', '可重新点击进入新一轮入箱'],
+      backend: 'AppData.BackMainPage',
+      data: ['UI:舱格回空闲'],
+      cross: null,
+      code: ['operate DetailPageView.xaml.cs:265 Return_Click → BackMainPage']
+    }
+  },
+
+  // ========== front:管理端(旁路联动)==========
+  {
+    id: 'front-device', type: 'front', icon: '💻', title: 'front 设备管理',
+    brief: '医生 PC 端·跨设备查看所有舱',
+    detail: {
+      desc: 'front 管理端 PC 的设备管理首页,跨设备显示所有时差仪 + 11 舱室状态。operate/control 的任何操作都会改 DB,front 轮询拉取实时反映。',
+      pre: ['front 已登录', '网络正常'],
+      trigger: 'front 导航点"设备管理" → DeviceManageView',
+      steps: ['轮询 GetDeviceStateApi 拉所有设备+舱室状态', '显示设备卡 + 11 舱格', '点舱格进胚胎详情'],
+      backend: 'DeviceManageViewModel 轮询 → GetDeviceStateApi',
+      data: ['UI:所有设备舱室状态'],
+      cross: { from: 'front 设备管理', via: 'HTTP 轮询 GetDeviceStateApi 读 DB', to: ['operate/control 任何操作(入箱/标记/结束)改 DB → front 轮询拉到变化'] },
+      code: ['front DeviceManageView.xaml.cs', 'GetDeviceStateApi']
+    }
+  },
+  {
+    id: 'front-detail', type: 'front', icon: '💻', title: 'front 胚胎详情',
+    brief: '医生 PC 端·延时回放 + AI 报告 + 评分',
+    detail: {
+      desc: 'front 的看图页,医生在 PC 端查看延时回放、AI 报告、胚胎评分、标记明细。和 operate 看图共用同一份 picture 数据。',
+      pre: ['从培养记录点进', 'picture 表有图'],
+      trigger: 'front 培养记录点某条 → DetailView',
+      steps: ['GetDishPicAndVideoApi(dishId)', '16 孔圆盘 + 延时视频', '查看 AI 报告/评分/标记明细'],
+      backend: 'DetailViewModel.Init → GetDishPicAndVideoApi',
+      data: ['UI:视频播放 + AI 报告'],
+      cross: { from: 'front 看图页', via: 'HTTP GetDishPicAndVideoApi', to: ['control 拍照→Kafka→DB→front 轮询拉到延时图'] },
+      code: ['front DetailView.xaml.cs', 'GetDishPicAndVideoApi']
+    }
+  },
+
+  // ========== 异常:报警上报 ==========
+  {
+    id: 'err-alarm', type: 'error', icon: '⚠️', title: '异常·报警上报',
+    brief: '温压/硬件/拍照异常 → 报警 + 短信',
+    detail: {
+      desc: 'control 运行中检测到异常(温压超阈值/串口断/相机丢失/拍照连续失败/卡死)就上报报警,入 alarm 表,operate/front 显示,可选短信通知。不中止主流程。',
+      pre: ['control 运行中', '触发报警条件'],
+      trigger: 'control 各检测点 → ReportAlarm',
+      steps: ['检测异常(温压/硬件/拍照失败)', '组装 AlarmEntity', '调 ReportAlarmApi → alarm 表插入', '可选发短信'],
+      backend: 'control 各模块 → ReportAlarmApi → alarm 表 → 可选短信',
+      data: ['DB alarm 表:插入报警', 'operate/front 轮询显示'],
+      cross: { from: 'control 检测异常', via: 'ReportAlarmApi → alarm 表', to: ['operate 主界面:报警角标+数字', 'operate 报警页:报警列表', 'front 系统异常:报警历史', '可选短信通知联系人'] },
+      code: ['control AlarmService', 'control HouseBin 各检测点', 'ReportAlarmApi', 'DB alarm 表']
+    }
+  },
+];
+
+
+// ============================================================
+// 连线(边):分支在这里真实分叉
+//   type: internal(本端实线) / cross(跨端虚线绿) / error(异常点线红) / loop(回流点线红)
+// ============================================================
+const EDGES = [
+  // 主流程:起点 → 主界面 → 入箱 → 判断
+  { from: 'start',              to: 'op-main',            type: 'internal', label: '走到屏幕' },
+  { from: 'op-main',            to: 'op-add',             type: 'internal', label: '点空舱' },
+  { from: 'op-add',             to: 'br-balance-or-start',type: 'internal', label: '填完表单' },
+
+  // 分支A:平衡(可反复)→ 结束平衡 → 回到开始培养
+  { from: 'br-balance-or-start',to: 'op-balance',         type: 'internal', label: '①点【平衡】' },
+  { from: 'op-balance',         to: 'op-end-balance',     type: 'internal', label: '平衡稳定后' },
+  { from: 'op-end-balance',     to: 'op-start-dish',      type: 'internal', label: '回入箱·开始培养' },
+
+  // 分支B:直接开始培养
+  { from: 'br-balance-or-start',to: 'op-start-dish',      type: 'internal', label: '②点【开始培养】' },
+
+  // 开始培养 → control(跨端)
+  { from: 'op-start-dish',      to: 'ctl-recv',           type: 'cross',    label: 'MQTT StartDish' },
+  { from: 'ctl-recv',           to: 'ctl-loop',           type: 'internal', label: 'HouseBin.StartDish' },
+
+  // 舱主循环 → 三条分支(真实分叉)
+  { from: 'ctl-loop',           to: 'ctl-airswap',        type: 'internal', label: '①到换气间隔' },
+  { from: 'ctl-loop',           to: 'ctl-focus',          type: 'internal', label: '②首次/需对焦' },
+  { from: 'ctl-focus',          to: 'ctl-photo',          type: 'internal', label: '对焦成功' },
+  { from: 'ctl-loop',           to: 'ctl-photo',          type: 'internal', label: '③已对焦·到拍照间隔' },
+
+  // 三分支都回流到主循环(点线回流)
+  { from: 'ctl-airswap',        to: 'ctl-loop',           type: 'loop',     label: '换气完→下轮' },
+  { from: 'ctl-photo',          to: 'ctl-loop',           type: 'loop',     label: '拍完→下轮' },
+
+  // 拍照 → 看图(跨端:Kafka→DB→轮询)
+  { from: 'ctl-photo',          to: 'op-detail',          type: 'cross',    label: 'Kafka→DB→轮询' },
+  { from: 'ctl-photo',          to: 'front-detail',       type: 'cross',    label: 'Kafka→DB→轮询' },
+
+  // 看图 → 胚胎去向判断 → 四条分支
+  { from: 'op-detail',          to: 'br-destination',     type: 'internal', label: '评估完' },
+  { from: 'br-destination',     to: 'op-transplant',      type: 'internal', label: '移植' },
+  { from: 'br-destination',     to: 'op-freeze',          type: 'internal', label: '冷冻' },
+  { from: 'br-destination',     to: 'op-delete',          type: 'internal', label: '删除' },
+  { from: 'br-destination',     to: 'op-invalid',         type: 'internal', label: '作废' },
+
+  // 四去向 → 通知 control 跳过该 well(跨端)
+  { from: 'op-transplant',      to: 'ctl-loop',           type: 'cross',    label: 'MQTT 跳过该well' },
+  { from: 'op-freeze',          to: 'ctl-loop',           type: 'cross',    label: 'MQTT 跳过该well' },
+  { from: 'op-delete',          to: 'ctl-loop',           type: 'cross',    label: 'MQTT 跳过该well' },
+  { from: 'op-invalid',         to: 'ctl-loop',           type: 'cross',    label: 'MQTT 跳过该well' },
+
+  // 四去向 → 结束培养
+  { from: 'op-transplant',      to: 'op-end',             type: 'internal' },
+  { from: 'op-freeze',          to: 'op-end',             type: 'internal' },
+  { from: 'op-delete',          to: 'op-end',             type: 'internal' },
+  { from: 'op-invalid',         to: 'op-end',             type: 'internal' },
+
+  // 结束培养 → 停 control + 回主界面
+  { from: 'op-end',             to: 'ctl-loop',           type: 'cross',    label: 'MQTT EndDish·停' },
+  { from: 'op-end',             to: 'op-back-main',       type: 'internal', label: '返回' },
+  { from: 'op-back-main',       to: 'op-main',            type: 'loop',     label: '舱位释放·可放下一个' },
+
+  // front 旁路联动
+  { from: 'op-start-dish',      to: 'front-device',       type: 'cross',    label: 'DB→front轮询' },
+  { from: 'front-device',       to: 'front-detail',       type: 'internal', label: '点舱格' },
+
+  // 异常:从拍照/换气/对焦旁出报警
+  { from: 'ctl-photo',          to: 'err-alarm',          type: 'error',    label: '拍照失败' },
+  { from: 'ctl-airswap',        to: 'err-alarm',          type: 'error',    label: '换气/温压异常' },
+  { from: 'err-alarm',          to: 'op-main',            type: 'error',    label: '报警显示' },
+  { from: 'err-alarm',          to: 'front-device',       type: 'error',    label: '报警显示' },
+];

+ 340 - 0
项目文档/业务流程图/培养全流程详图/flow-render.js

@@ -0,0 +1,340 @@
+// ============================================================
+// 渲染引擎:dagre 自动布局 + 拖拽 + localStorage + 直角折线
+// ============================================================
+(function () {
+  'use strict';
+
+  const STORAGE_KEY = 'tl-flow-positions-v2';
+  const NODE_W = 200;
+  const COLORS = {
+    internal: '#7F8C8D', cross: '#16A085', error: '#E74C3C', loop: '#E67E22'
+  };
+  const TYPE_BADGE = {
+    start: '#16A085', operate: '#4A90E2', control: '#F39C12',
+    front: '#9B59B6', branch: '#E67E22', error: '#E74C3C'
+  };
+  const TYPE_NAME = {
+    start: '起止', operate: 'operate 操作端', control: 'control 后台',
+    front: 'front 管理端', branch: '判断分支', error: '异常'
+  };
+
+  const viewport = document.getElementById('viewport');
+  const world = document.getElementById('world');
+  const svg = document.getElementById('svg');
+
+  let nodePos = {};   // id -> {x, y}
+  let nodeSize = {};  // id -> {w, h}
+  let scale = 1, panX = 40, panY = 20;
+
+  // ---------- 1. 自动布局(dagre)----------
+  function computeLayout() {
+    const g = new dagre.graphlib.Graph();
+    g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 90, marginx: 40, marginy: 40 });
+    g.setDefaultEdgeLabel(() => ({}));
+
+    // 先把节点塞进 DOM 量高度
+    NODES.forEach(n => {
+      const h = measureNodeHeight(n);
+      nodeSize[n.id] = { w: NODE_W, h };
+      g.setNode(n.id, { width: NODE_W, height: h });
+    });
+    EDGES.forEach(e => { if (g.hasNode(e.from) && g.hasNode(e.to)) g.setEdge(e.from, e.to); });
+
+    dagre.layout(g);
+
+    g.nodes().forEach(id => {
+      const nd = g.node(id);
+      nodePos[id] = { x: nd.x - nd.width / 2, y: nd.y - nd.height / 2 };
+    });
+  }
+
+  // 量节点高度:临时渲染
+  function measureNodeHeight(n) {
+    const tmp = document.createElement('div');
+    tmp.className = 'flow-node ' + n.type;
+    tmp.style.cssText = 'position:absolute;visibility:hidden;width:' + NODE_W + 'px;';
+    tmp.innerHTML = nodeInnerHTML(n);
+    world.appendChild(tmp);
+    const h = tmp.offsetHeight;
+    world.removeChild(tmp);
+    return h;
+  }
+
+  function nodeInnerHTML(n) {
+    return '<div class="node-head"><span class="node-icon">' + n.icon +
+      '</span><span class="node-title">' + n.title + '</span></div>' +
+      '<span class="node-tag">' + TYPE_NAME[n.type] + '</span>' +
+      '<div class="node-brief">' + n.brief + '</div>';
+  }
+
+  // ---------- 2. localStorage ----------
+  function loadSaved() {
+    try {
+      const raw = localStorage.getItem(STORAGE_KEY);
+      if (!raw) return false;
+      const saved = JSON.parse(raw);
+      let any = false;
+      NODES.forEach(n => {
+        if (saved[n.id]) { nodePos[n.id] = saved[n.id]; any = true; }
+      });
+      return any;
+    } catch (e) { return false; }
+  }
+  function savePositions() {
+    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(nodePos)); } catch (e) {}
+    flashSaved();
+  }
+  let flashTimer = null;
+  function flashSaved() {
+    const btn = document.getElementById('btnSave');
+    btn.textContent = '✓ 已保存位置';
+    clearTimeout(flashTimer);
+    flashTimer = setTimeout(() => { btn.textContent = '💾 已自动保存'; }, 1500);
+  }
+
+  // ---------- 3. 渲染节点 ----------
+  function renderNodes() {
+    // 清掉旧节点(保留 svg)
+    [...world.querySelectorAll('.flow-node')].forEach(el => el.remove());
+    NODES.forEach(n => {
+      const el = document.createElement('div');
+      el.className = 'flow-node ' + n.type;
+      el.id = 'node-' + n.id;
+      el.style.left = nodePos[n.id].x + 'px';
+      el.style.top = nodePos[n.id].y + 'px';
+      el.innerHTML = nodeInnerHTML(n);
+      world.appendChild(el);
+      makeDraggable(el, n);
+      el.addEventListener('click', ev => {
+        if (el._moved) { el._moved = false; return; }
+        showDetail(n);
+      });
+    });
+  }
+
+  // ---------- 4. 直角折线连线 ----------
+  function renderEdges() {
+    const minX = Math.min(...Object.values(nodePos).map(p => p.x));
+    const minY = Math.min(...Object.values(nodePos).map(p => p.y));
+    const maxX = Math.max(...Object.values(nodePos).map((p, i) => p.x + NODE_W));
+    const maxY = Math.max(...Object.entries(nodePos).map(([id, p]) => p.y + (nodeSize[id] ? nodeSize[id].h : 80)));
+    const W = maxX + 80, H = maxY + 80;
+    svg.setAttribute('width', W);
+    svg.setAttribute('height', H);
+    world.style.width = W + 'px';
+    world.style.height = H + 'px';
+
+    // 箭头定义
+    let defs = '<defs>';
+    Object.entries(COLORS).forEach(([t, c]) => {
+      defs += '<marker id="ar-' + t + '" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">' +
+        '<path d="M0,0 L0,6 L9,3 z" fill="' + c + '"/></marker>';
+    });
+    defs += '</defs>';
+
+    let paths = '';
+    EDGES.forEach(e => {
+      const a = nodePos[e.from], b = nodePos[e.to];
+      if (!a || !b) return;
+      const ah = nodeSize[e.from] ? nodeSize[e.from].h : 80;
+      const bh = nodeSize[e.to] ? nodeSize[e.to].h : 80;
+      const color = COLORS[e.type] || '#999';
+      const p = orthPath(
+        a.x + NODE_W / 2, a.y, ah,
+        b.x + NODE_W / 2, b.y, bh
+      );
+      const dash = e.type === 'cross' ? 'stroke-dasharray="6,5"' :
+                   (e.type === 'error' || e.type === 'loop') ? 'stroke-dasharray="2,4"' : '';
+      paths += '<path d="' + p.d + '" fill="none" stroke="' + color + '" stroke-width="2" ' +
+        dash + ' marker-end="url(#ar-' + e.type + ')"/>';
+      if (e.label) {
+        paths += '<rect x="' + (p.lx - e.label.length * 6 - 4) + '" y="' + (p.ly - 9) +
+          '" width="' + (e.label.length * 12 + 8) + '" height="16" rx="3" fill="#fff" opacity="0.92"/>';
+        paths += '<text x="' + p.lx + '" y="' + (p.ly + 3) + '" fill="' + color +
+          '" font-size="11" text-anchor="middle">' + e.label + '</text>';
+      }
+    });
+    svg.innerHTML = defs + paths;
+  }
+
+  // 直角折线:从 A 底部中点 → B 顶部中点(若 B 在上方则反向走)
+  function orthPath(ax, ayTop, ah, bx, byTop, bh) {
+    const aBottom = ayTop + ah, aTop = ayTop;
+    const bTop = byTop, bBottom = byTop + bh;
+    let sx, sy, ex, ey;
+    // 默认 A 下出、B 上进
+    if (byTop >= aBottom - 10) {
+      sx = ax; sy = aBottom; ex = bx; ey = bTop;
+      const midY = (sy + ey) / 2;
+      return { d: `M ${sx},${sy} L ${sx},${midY} L ${ex},${midY} L ${ex},${ey}`, lx: (sx + ex) / 2, ly: midY };
+    }
+    // B 在 A 上方(回流):A 右出 → 绕 → B 右进,或 A 上出
+    if (byTop + bh <= aTop + 10) {
+      sx = ax; sy = aTop; ex = bx; ey = bBottom;
+      const midY = (sy + ey) / 2;
+      return { d: `M ${sx},${sy} L ${sx},${midY} L ${ex},${midY} L ${ex},${ey}`, lx: (sx + ex) / 2, ly: midY };
+    }
+    // 同层左右:从侧边走
+    const aRight = nodePos, fromRight = bx > ax;
+    sx = ax + (fromRight ? NODE_W / 2 : -NODE_W / 2);
+    sy = ayTop + ah / 2;
+    ex = bx + (fromRight ? -NODE_W / 2 : NODE_W / 2);
+    ey = byTop + bh / 2;
+    const midX = (sx + ex) / 2;
+    return { d: `M ${sx},${sy} L ${midX},${sy} L ${midX},${ey} L ${ex},${ey}`, lx: midX, ly: (sy + ey) / 2 };
+  }
+
+  // ---------- 5. 拖拽节点 ----------
+  function makeDraggable(el, n) {
+    let startX, startY, origX, origY, dragging = false;
+    el.addEventListener('mousedown', ev => {
+      ev.stopPropagation();
+      dragging = true;
+      el._moved = false;
+      el.classList.add('dragging');
+      startX = ev.clientX; startY = ev.clientY;
+      origX = nodePos[n.id].x; origY = nodePos[n.id].y;
+      document.addEventListener('mousemove', onMove);
+      document.addEventListener('mouseup', onUp);
+    });
+    function onMove(ev) {
+      if (!dragging) return;
+      const dx = (ev.clientX - startX) / scale;
+      const dy = (ev.clientY - startY) / scale;
+      if (Math.abs(dx) > 3 || Math.abs(dy) > 3) el._moved = true;
+      nodePos[n.id] = { x: origX + dx, y: origY + dy };
+      el.style.left = nodePos[n.id].x + 'px';
+      el.style.top = nodePos[n.id].y + 'px';
+      renderEdges();
+    }
+    function onUp() {
+      dragging = false;
+      el.classList.remove('dragging');
+      document.removeEventListener('mousemove', onMove);
+      document.removeEventListener('mouseup', onUp);
+      if (el._moved) savePositions();
+    }
+  }
+
+  // ---------- 6. 画布平移 + 缩放 ----------
+  function applyTransform() {
+    world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
+    document.getElementById('zoomLabel').textContent = Math.round(scale * 100) + '%';
+  }
+  (function panSetup() {
+    let panning = false, sx, sy, opx, opy;
+    viewport.addEventListener('mousedown', ev => {
+      if (ev.target.closest('.flow-node')) return;
+      panning = true; viewport.classList.add('panning');
+      sx = ev.clientX; sy = ev.clientY; opx = panX; opy = panY;
+    });
+    document.addEventListener('mousemove', ev => {
+      if (!panning) return;
+      panX = opx + (ev.clientX - sx);
+      panY = opy + (ev.clientY - sy);
+      applyTransform();
+    });
+    document.addEventListener('mouseup', () => { panning = false; viewport.classList.remove('panning'); });
+    viewport.addEventListener('wheel', ev => {
+      ev.preventDefault();
+      const factor = ev.deltaY < 0 ? 1.1 : 0.9;
+      const newScale = Math.min(2, Math.max(0.3, scale * factor));
+      // 以鼠标为中心缩放
+      const rect = viewport.getBoundingClientRect();
+      const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
+      panX = mx - (mx - panX) * (newScale / scale);
+      panY = my - (my - panY) * (newScale / scale);
+      scale = newScale;
+      applyTransform();
+    }, { passive: false });
+  })();
+
+  document.getElementById('zoomIn').onclick = () => { scale = Math.min(2, scale * 1.15); applyTransform(); };
+  document.getElementById('zoomOut').onclick = () => { scale = Math.max(0.3, scale / 1.15); applyTransform(); };
+
+  function fitToScreen() {
+    const xs = Object.values(nodePos).map(p => p.x);
+    const ys = Object.values(nodePos).map(p => p.y);
+    const x2 = Math.max(...Object.values(nodePos).map(p => p.x + NODE_W));
+    const y2 = Math.max(...Object.entries(nodePos).map(([id, p]) => p.y + (nodeSize[id] ? nodeSize[id].h : 80)));
+    const minX = Math.min(...xs), minY = Math.min(...ys);
+    const w = x2 - minX, h = y2 - minY;
+    const vw = viewport.clientWidth - 80, vh = viewport.clientHeight - 60;
+    // 上限放大到 0.9,避免缩太小看不清
+    scale = Math.min(0.9, Math.min(vw / w, vh / h));
+    // 水平+垂直都居中
+    panX = (viewport.clientWidth - w * scale) / 2 - minX * scale;
+    panY = 30 - minY * scale;
+    applyTransform();
+  }
+
+  // ---------- 7. 详情面板 ----------
+  const panel = document.getElementById('panel');
+  const backdrop = document.getElementById('backdrop');
+  function showDetail(n) {
+    document.querySelectorAll('.flow-node').forEach(e => e.classList.remove('active'));
+    document.getElementById('node-' + n.id).classList.add('active');
+    document.getElementById('panelTitle').textContent = n.icon + ' ' + n.title;
+    const badge = document.getElementById('panelBadge');
+    badge.textContent = TYPE_NAME[n.type];
+    badge.style.background = TYPE_BADGE[n.type];
+    document.getElementById('panelBody').innerHTML = detailHTML(n.detail);
+    panel.classList.add('show');
+    backdrop.classList.add('show');
+  }
+  function closeDetail() {
+    panel.classList.remove('show');
+    backdrop.classList.remove('show');
+    document.querySelectorAll('.flow-node').forEach(e => e.classList.remove('active'));
+  }
+  document.getElementById('panelClose').onclick = closeDetail;
+  backdrop.onclick = closeDetail;
+  document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closeDetail(); });
+
+  function sec(title, body) {
+    return '<div class="detail-section"><div class="section-title">' + title +
+      '</div><div class="section-content">' + body + '</div></div>';
+  }
+  function ul(arr) { return '<ul>' + arr.map(x => '<li>' + x + '</li>').join('') + '</ul>'; }
+
+  function detailHTML(d) {
+    let h = '';
+    h += sec('📋 这步是什么', d.desc);
+    h += sec('🔑 前置条件', ul(d.pre));
+    h += sec('🎯 触发方式', d.trigger);
+    h += sec('🔄 操作步骤', ul(d.steps));
+    h += sec('⚙️ 后端逻辑', d.backend);
+    h += sec('💾 数据/状态变化', ul(d.data));
+    if (d.cross) {
+      let c = '<div class="cross-box"><b>本端:</b>' + d.cross.from +
+        '<br><span class="flow-arrow">↓ ' + d.cross.via + '</span><br><b>影响:</b>' + ul(d.cross.to) + '</div>';
+      h += sec('🌐 三端联动', c);
+    }
+    h += sec('📍 代码位置', d.code.map(c => '<div class="code-loc">' + c + '</div>').join(''));
+    return h;
+  }
+
+  // ---------- 8. 初始化 ----------
+  function init() {
+    computeLayout();          // dagre 算初始位置
+    const restored = loadSaved(); // 有保存的就覆盖
+    renderNodes();
+    renderEdges();
+    if (!restored) fitToScreen();
+    else applyTransform();
+  }
+
+  document.getElementById('btnReset').onclick = () => {
+    localStorage.removeItem(STORAGE_KEY);
+    nodePos = {}; nodeSize = {};
+    computeLayout();
+    renderNodes();
+    renderEdges();
+    fitToScreen();
+    flashSaved();
+    document.getElementById('btnSave').textContent = '↺ 已重置';
+  };
+  document.getElementById('btnFit').onclick = fitToScreen;
+
+  init();
+})();

+ 246 - 0
项目文档/业务流程图/培养全流程详图/时差培养箱-培养全流程详图.html

@@ -0,0 +1,246 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>时差培养箱 - 培养全流程详图(三端联动·可拖拽)</title>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+
+    :root {
+      --operate-color: #4A90E2;
+      --operate-bg: #EAF3FC;
+      --control-color: #F39C12;
+      --control-bg: #FEF5E7;
+      --front-color: #9B59B6;
+      --front-bg: #F5EEF8;
+      --branch-color: #E67E22;
+      --branch-bg: #FDF2E9;
+      --error-color: #E74C3C;
+      --error-bg: #FDEDEC;
+      --start-color: #16A085;
+      --start-bg: #E8F8F5;
+      --bg-page: #F4F6F8;
+      --text-primary: #2C3E50;
+      --text-secondary: #7F8C8D;
+      --border-light: #E0E4E8;
+    }
+
+    html, body {
+      font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
+      background: var(--bg-page);
+      color: var(--text-primary);
+      height: 100%;
+      overflow: hidden;
+    }
+
+    /* 顶部栏 */
+    .top-bar {
+      position: fixed;
+      top: 0; left: 0; right: 0;
+      height: 60px;
+      z-index: 1000;
+      background: #fff;
+      border-bottom: 1px solid var(--border-light);
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 0 24px;
+      box-shadow: 0 2px 8px rgba(0,0,0,0.06);
+    }
+    .top-title { font-size: 20px; font-weight: 700; color: var(--operate-color); }
+    .top-actions { display: flex; gap: 12px; align-items: center; }
+    .btn {
+      padding: 7px 16px; border: 1px solid var(--border-light);
+      background: #fff; border-radius: 8px; cursor: pointer;
+      font-size: 13px; color: var(--text-secondary); transition: all .2s;
+    }
+    .btn:hover { border-color: var(--operate-color); color: var(--operate-color); }
+    .btn.primary { background: var(--operate-color); color: #fff; border-color: var(--operate-color); }
+    .btn.primary:hover { background: #357ABD; }
+
+    /* 图例 */
+    .legend { display: flex; gap: 16px; font-size: 12px; }
+    .legend-item { display: flex; align-items: center; gap: 5px; }
+    .legend-dot { width: 11px; height: 11px; border-radius: 3px; }
+    .legend-line { width: 20px; height: 0; border-top-width: 2px; border-top-style: solid; }
+
+    /* 画布区域(可平移缩放) */
+    .canvas-viewport {
+      position: fixed;
+      top: 60px; left: 0; right: 0; bottom: 0;
+      overflow: hidden;
+      cursor: grab;
+      background:
+        radial-gradient(circle, #dde3e8 1px, transparent 1px);
+      background-size: 24px 24px;
+    }
+    .canvas-viewport.panning { cursor: grabbing; }
+
+    .canvas-world {
+      position: absolute;
+      top: 0; left: 0;
+      transform-origin: 0 0;
+    }
+
+    .svg-lines {
+      position: absolute;
+      top: 0; left: 0;
+      overflow: visible;
+      pointer-events: none;
+      z-index: 1;
+    }
+
+    /* 节点 */
+    .flow-node {
+      position: absolute;
+      width: 200px;
+      padding: 12px 16px;
+      background: #fff;
+      border: 2px solid;
+      border-radius: 10px;
+      cursor: move;
+      z-index: 2;
+      box-shadow: 0 2px 6px rgba(0,0,0,0.08);
+      transition: box-shadow .15s, transform .15s;
+      user-select: none;
+    }
+    .flow-node:hover { box-shadow: 0 6px 16px rgba(0,0,0,0.18); z-index: 5; }
+    .flow-node.active { box-shadow: 0 0 0 3px rgba(74,144,226,0.4), 0 6px 16px rgba(0,0,0,0.2); z-index: 6; }
+    .flow-node.dragging { opacity: 0.85; box-shadow: 0 10px 28px rgba(0,0,0,0.28); z-index: 100; }
+
+    .flow-node.operate { border-color: var(--operate-color); background: var(--operate-bg); }
+    .flow-node.control { border-color: var(--control-color); background: var(--control-bg); }
+    .flow-node.front   { border-color: var(--front-color);   background: var(--front-bg); }
+    .flow-node.branch  { border-color: var(--branch-color);  background: var(--branch-bg); border-style: dashed; }
+    .flow-node.error   { border-color: var(--error-color);   background: var(--error-bg); }
+    .flow-node.start   { border-color: var(--start-color);   background: var(--start-bg); }
+
+    .node-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
+    .node-icon { font-size: 20px; }
+    .node-title { font-size: 14px; font-weight: 700; color: var(--text-primary); line-height: 1.3; }
+    .node-tag {
+      display: inline-block; font-size: 11px; padding: 1px 8px;
+      border-radius: 8px; background: rgba(0,0,0,0.06); color: var(--text-secondary);
+    }
+    .node-brief { font-size: 11.5px; color: var(--text-secondary); margin-top: 4px; line-height: 1.4; }
+
+    /* 缩放控制 */
+    .zoom-ctrl {
+      position: fixed; bottom: 24px; right: 24px; z-index: 900;
+      display: flex; flex-direction: column; gap: 6px;
+      background: #fff; border: 1px solid var(--border-light);
+      border-radius: 10px; padding: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+    }
+    .zoom-btn {
+      width: 34px; height: 34px; border: none; background: #f5f7f8;
+      border-radius: 6px; cursor: pointer; font-size: 18px; color: var(--text-primary);
+    }
+    .zoom-btn:hover { background: var(--operate-bg); }
+    .zoom-label { text-align: center; font-size: 11px; color: var(--text-secondary); }
+
+    /* 右侧详情面板 */
+    .detail-backdrop {
+      position: fixed; inset: 0; background: rgba(0,0,0,0.35);
+      z-index: 1500; opacity: 0; pointer-events: none; transition: opacity .25s;
+    }
+    .detail-backdrop.show { opacity: 1; pointer-events: auto; }
+    .detail-panel {
+      position: fixed; top: 0; right: -640px; width: 640px; max-width: 92vw;
+      height: 100vh; background: #fff; z-index: 1600;
+      box-shadow: -4px 0 24px rgba(0,0,0,0.18); overflow-y: auto;
+      transition: right .3s ease;
+    }
+    .detail-panel.show { right: 0; }
+    .detail-header {
+      position: sticky; top: 0; background: #fff; z-index: 10;
+      padding: 18px 22px; border-bottom: 1px solid var(--border-light);
+      display: flex; align-items: center; justify-content: space-between;
+    }
+    .detail-title-wrap { display: flex; align-items: center; gap: 10px; }
+    .detail-title { font-size: 18px; font-weight: 700; }
+    .detail-badge { font-size: 11px; padding: 2px 10px; border-radius: 10px; color: #fff; }
+    .detail-close {
+      width: 32px; height: 32px; border: none; background: var(--border-light);
+      border-radius: 50%; cursor: pointer; font-size: 18px; color: var(--text-secondary);
+    }
+    .detail-close:hover { background: var(--error-color); color: #fff; }
+    .detail-body { padding: 20px 22px 40px; }
+    .detail-section { margin-bottom: 20px; }
+    .section-title {
+      font-size: 14px; font-weight: 700; color: var(--operate-color);
+      margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border-light);
+      display: flex; align-items: center; gap: 6px;
+    }
+    .section-content { font-size: 13px; line-height: 1.75; color: var(--text-primary); }
+    .section-content ul { list-style: none; }
+    .section-content li { padding: 3px 0 3px 18px; position: relative; }
+    .section-content li:before {
+      content: "▸"; position: absolute; left: 0; color: var(--operate-color);
+    }
+    .flow-arrow { color: var(--error-color); font-weight: 700; }
+    .code-loc {
+      background: #2C3E50; color: #7FDBFF; padding: 6px 10px; border-radius: 5px;
+      font-family: Consolas, monospace; font-size: 12px; margin-top: 5px; word-break: break-all;
+    }
+    .cross-box {
+      background: #FEF9E7; border: 1px solid #F9E79F; border-radius: 8px;
+      padding: 12px 14px; line-height: 1.9;
+    }
+</style>
+</head>
+<body>
+  <!-- 顶部栏 -->
+  <div class="top-bar">
+    <div class="top-title">🧬 时差培养箱 · 培养全流程详图</div>
+    <div class="legend">
+      <span class="legend-item"><span class="legend-dot" style="background:var(--start-color)"></span>起止</span>
+      <span class="legend-item"><span class="legend-dot" style="background:var(--operate-color)"></span>operate</span>
+      <span class="legend-item"><span class="legend-dot" style="background:var(--control-color)"></span>control</span>
+      <span class="legend-item"><span class="legend-dot" style="background:var(--front-color)"></span>front</span>
+      <span class="legend-item"><span class="legend-dot" style="background:var(--branch-color)"></span>判断</span>
+      <span class="legend-item"><span class="legend-dot" style="background:var(--error-color)"></span>异常</span>
+      <span class="legend-item"><span class="legend-line" style="border-color:#7F8C8D"></span>本端</span>
+      <span class="legend-item"><span class="legend-line" style="border-color:#16A085;border-top-style:dashed"></span>跨端</span>
+      <span class="legend-item"><span class="legend-line" style="border-color:#E74C3C;border-top-style:dotted"></span>异常/回流</span>
+    </div>
+    <div class="top-actions">
+      <button class="btn" id="btnReset">↺ 重置布局</button>
+      <button class="btn" id="btnFit">⊡ 适应屏幕</button>
+      <button class="btn primary" id="btnSave">💾 已自动保存</button>
+    </div>
+  </div>
+
+  <!-- 画布 -->
+  <div class="canvas-viewport" id="viewport">
+    <div class="canvas-world" id="world">
+      <svg class="svg-lines" id="svg"></svg>
+      <!-- 节点由 JS 渲染 -->
+    </div>
+  </div>
+
+  <!-- 缩放控制 -->
+  <div class="zoom-ctrl">
+    <button class="zoom-btn" id="zoomIn">+</button>
+    <div class="zoom-label" id="zoomLabel">100%</div>
+    <button class="zoom-btn" id="zoomOut">-</button>
+  </div>
+
+  <!-- 详情面板 -->
+  <div class="detail-backdrop" id="backdrop"></div>
+  <div class="detail-panel" id="panel">
+    <div class="detail-header">
+      <div class="detail-title-wrap">
+        <span class="detail-title" id="panelTitle">节点详情</span>
+        <span class="detail-badge" id="panelBadge"></span>
+      </div>
+      <button class="detail-close" id="panelClose">×</button>
+    </div>
+    <div class="detail-body" id="panelBody"></div>
+  </div>
+
+  <script src="dagre.min.js"></script>
+  <script src="flow-data.js"></script>
+  <script src="flow-render.js"></script>
+</body>
+</html>

+ 420 - 0
项目文档/业务流程图/流程图制作规范-可复用模板.md

@@ -0,0 +1,420 @@
+# 时差培养箱业务流程图制作规范(可复用模板)
+
+> **用途**:统一流程图制作标准,确保任何功能的流程图都能:
+> 1. **100% 还原真实业务逻辑**(包括分支、回流、异常路径)
+> 2. **清晰展示三端联动**(operate / control / front 的交互与影响)
+> 3. **提供完整节点详情**(每个节点的触发条件、执行逻辑、涉及数据、代码位置)
+> 4. **自动布局不重叠**(用 dagre 自动分层,不手写坐标,杜绝线条/元素重叠交叉)
+> 5. **可拖拽 + 记忆位置**(节点可拖动,位置自动存 localStorage,刷新后恢复)
+> 6. **支持无限扩展**(画布宽高不限,业务有多复杂流程图就撑多大)
+
+> **⚠ 本规范 v2(2026-06-24 重写)**:吸取第一版"手写坐标导致重叠乱"的教训,
+> 改为 **dagre 自动布局**;分支从"藏在面板文字里"改为"真实画进图";新增拖拽+localStorage。
+> 标杆参考实现见第九章「参考示例」。
+
+---
+
+## 〇、最重要的三条铁律(先记这个)
+
+1. **分支必须真实画进图**——决策点(平衡vs开始、移植/冷冻/删除/作废)要在画布上真分叉成多个节点多条线,**绝不能**写成一个笼统节点 + 面板里一段"后续分支"文字。
+2. **布局必须自动算(dagre),不手写坐标**——手写 x/y 节点一多必然重叠交叉。用 dagre 分层布局打底,保证 0 重叠;用户再拖拽微调。
+3. **右侧面板只放"本节点自己的细节"**——不放分支(分支已经画在图上了)。面板=这步是什么/前置/触发/步骤/后端/数据/三端联动/代码位置。
+
+---
+
+## 一、流程图核心原则
+
+### 1.1 真实性原则:不折叠、不简化
+- ❌ **禁止**把分支写成文字描述:"点击后可选 A 或 B"
+- ❌ **禁止**把分支塞进详情面板的"后续分支"板块(v1 的错误做法,已废弃该板块)
+- ✅ **必须**在画布上画出真实的分支节点和分叉连线:决策节点下真分出多个子节点
+
+### 1.2 完整性原则:覆盖所有路径
+- **正常路径**:用户正常操作的主流程(如:入箱→培养→拍照→看图→标记→结束)
+- **分支路径**:业务决策点的不同选择(如:平衡 vs 直接开始、移植 vs 冷冻 vs 删除 vs 作废)
+- **异常路径**:错误、失败、超时、权限不足等场景(如:硬件异常、校验失败、MQTT 超时)
+- **回流路径**:回到前面某步重新走(如:对焦失败→重新对焦、拍照失败→重试)
+
+### 1.3 三端联动原则:明确跨端影响
+每个操作都要说清楚:
+- **本端做了什么**(operate 点了按钮 / control 收到命令 / front 刷新界面)
+- **触发了什么**(调接口 / 发 MQTT / 改数据库 / 发 Kafka)
+- **影响了哪些端**(其他端的界面变化 / 状态变化 / 行为变化)
+
+### 1.4 无限画布原则:不限宽高
+- 流程往哪延伸就往哪画(上下左右+斜向都可以)
+- 画布尺寸根据内容自动撑开(CSS 不设 max-width / max-height)
+- 浏览器出现滚动条是正常的(不强制塞进一屏)
+
+---
+
+## 二、流程图结构规范
+
+### 2.0 文件结构(4 个文件,必须同目录)
+为了好维护、避免单文件过大被截断,拆成 4 个文件放同一目录:
+
+| 文件 | 作用 | 大小参考 |
+|---|---|---|
+| `xxx流程图.html` | 骨架:顶栏+图例+画布+详情面板 DOM + 全部 CSS | ~10KB |
+| `flow-data.js` | 数据:`NODES`(节点数组)+ `EDGES`(连线数组) | 随业务 |
+| `flow-render.js` | 引擎:dagre 布局 + 渲染 + 拖拽 + localStorage + 折线 + 面板 | ~14KB |
+| `dagre.min.js` | 自动布局库(离线内置,约 278KB,自包含 graphlib) | 278KB |
+
+HTML 末尾按顺序引用:
+```html
+<script src="dagre.min.js"></script>
+<script src="flow-data.js"></script>
+<script src="flow-render.js"></script>
+```
+> dagre.min.js 来源:`https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js`,
+> 下载后放同目录即可离线用(已验证可用)。`flow-render.js` 里调 `dagre.graphlib.Graph` / `dagre.layout`。
+
+### 2.1 主画布布局
+```
+┌────────────────────────────────────────────────┐
+│  顶部固定栏:标题 + 图例 + [重置布局][适应屏幕][已保存] │
+├────────────────────────────────────────────────┤
+│  画布视口 viewport(可平移/滚轮缩放):        │
+│    world(transform 平移缩放):              │
+│      ├─ svg(直角折线连线,在节点下层)       │
+│      └─ 节点(绝对定位,dagre 算坐标,可拖拽) │
+│  右下角:缩放控制 [+][百分比][-]            │
+└────────────────────────────────────────────────┘
+   右侧滑出:详情面板(点节点弹出)
+```
+
+### 2.2 节点样式规范(颜色 = 标杆实现实际用色)
+
+| 类型 | type | 边框色 | 背景色 | 图标 | 说明 |
+|---|---|---|---|---|---|
+| 起止 | `start` | `#16A085` | `#E8F8F5` | 🥚/🔚 | 流程起点终点 |
+| operate | `operate` | `#4A90E2` | `#EAF3FC` | 🖥️ | 操作端动作 |
+| control | `control` | `#F39C12` | `#FEF5E7` | ⚙️ | 后台动作 |
+| front | `front` | `#9B59B6` | `#F5EEF8` | 💻 | 管理端动作 |
+| 判断分支 | `branch` | `#E67E22` | `#FDF2E9` | ❓ | 决策点(虚线边框) |
+| 异常 | `error` | `#E74C3C` | `#FDEDEC` | ⚠️ | 报警/失败 |
+
+节点结构(由 JS 生成,宽度固定 200px,高度由内容自动量):
+```html
+<div class="flow-node operate" id="node-op-add">
+  <div class="node-head"><span class="node-icon">🖥️</span><span class="node-title">新建患者入箱</span></div>
+  <span class="node-tag">operate 操作端</span>
+  <div class="node-brief">录入患者信息 + 选 16 孔位 + 受精方式</div>  <!-- 一句话摘要,画布上直接可见 -->
+</div>
+```
+> **node-brief 很重要**:画布上每个节点直接显示一句话摘要,不用点开就懂大概;点开才看完整详情。
+
+### 2.3 连线样式规范(直角折线 + 4 种类型)
+
+| type | 含义 | 颜色 | 线型 |
+|---|---|---|---|
+| `internal` | 本端内部流程 | `#7F8C8D` 灰 | 实线 |
+| `cross` | 跨端调用(API/MQTT/Kafka/DB同步) | `#16A085` 绿 | 虚线 `6,5` |
+| `error` | 异常/报警路径 | `#E74C3C` 红 | 点线 `2,4` |
+| `loop` | 回流(回到前面某步/下一轮循环) | `#E67E22` 橙 | 点线 `2,4` |
+
+- **一律用直角折线**(电路图风格),不用曲线:`M sx,sy L sx,midY L ex,midY L ex,ey`
+- 连线在节点**下层**(z-index:svg=1,node=2),不遮挡节点
+- 连线**带文字标签**(label),标签加白底矩形避免被线穿过
+- 箭头用 SVG `<marker>`,每种 type 一个对应颜色的箭头
+
+---
+
+## 三、节点详情面板规范(右侧滑出)
+
+> **v2 变更**:从 10 个板块精简为 **8 个**。删掉了"后续分支"(分支改画进图)。
+> 面板宽度 640px,从右滑出,点遮罩/ESC/×关闭。面板只讲**这一个节点自己**的事。
+
+每个节点的 `detail` 对象字段(flow-data.js 里)与面板板块一一对应:
+
+| 字段 | 面板板块 | 内容 |
+|---|---|---|
+| `desc` | 📋 这步是什么 | 一段大白话,说清这个节点干什么 |
+| `pre` | 🔑 前置条件 | 数组,能进这步要满足的条件 |
+| `trigger` | 🎯 触发方式 | 哪个界面哪个按钮 / 什么 MQTT / 什么循环触发 |
+| `steps` | 🔄 操作步骤 | 数组,用户做什么 或 系统自动执行的步骤 |
+| `backend` | ⚙️ 后端逻辑 | 代码层面做了什么(调接口/发MQTT/落库) |
+| `data` | 💾 数据/状态变化 | 数组,DB哪些表、内存哪些变量、UI怎么变 |
+| `cross` | 🌐 三端联动 | 对象 `{from, via, to[]}`,没有跨端则 `null`(面板不显示该块) |
+| `code` | 📍 代码位置 | 数组,`文件:行号 方法名` / 表名 / topic |
+
+### 3.1 节点数据完整示例(flow-data.js 里一个节点)
+```javascript
+{
+  id: 'op-start-dish', type: 'operate', icon: '▶️', title: '开始培养(保存)',
+  brief: '落库 dish+embryo → 发 MQTT 通知 control',   // 画布上直接显示的一句话
+  detail: {
+    desc: '入箱的核心动作。前端校验后调 StartDishApi,后端把培养皿+每个胚胎落库,再发 MQTT StartDish 通知 control。',
+    pre: ['表单校验通过(孔位≥1、必填非空)', '舱室=空闲', 'control 在线'],
+    trigger: 'operate 入箱窗点【保存=开始培养】→ StartDish_Click',
+    steps: ['前端校验必填项', '调 StartDishApi', '后端落库 dish 表+embryo 表', '后端发 MQTT StartDish'],
+    backend: 'StartDish_Click → StartDishApi → dish/embryo 落库 → MqttSendRpc 发 MQTT StartDish',
+    data: ['DB:dish 表插1条(培养中),embryo 表插N条(state=0)', '舱状态:空闲→培养中'],
+    cross: {
+      from: 'operate 点【开始培养】',
+      via: 'StartDishApi → dish/embryo 落库 → MQTT StartDish',
+      to: ['control 收 MQTT → HouseBin.StartDish → 启动舱主循环',
+           'front 设备管理:舱格变"培养中"',
+           'operate 主界面:舱格变色+显示患者']
+    },
+    code: ['operate AddDishWindowView.xaml.cs:451 StartDish_Click',
+           'control AppData.cs:1102 StartDish', 'DB dish/embryo 表']
+  }
+}
+```
+
+### 3.2 三端联动板块(cross)渲染成
+```
+本端:operate 点【开始培养】
+  ↓ StartDishApi → dish/embryo 落库 → MQTT StartDish
+影响:
+  ▸ control 收 MQTT → HouseBin.StartDish → 启动舱主循环
+  ▸ front 设备管理:舱格变"培养中"
+  ▸ operate 主界面:舱格变色+显示患者
+```
+
+---
+
+## 四、分支绘制规范(用数据表达,dagre 自动布局)
+
+> **核心**:分支不靠手画,而是在 `EDGES` 里写好"从哪个节点连到哪些节点",
+> dagre 自动算出分叉布局。你只管把分支的**节点**和**连线**定义对,图形自动成型。
+
+### 4.1 二选一分支(如:先平衡 / 直接开始)
+加一个 `branch` 类型判断节点,从它连出两条边到两个不同节点:
+```javascript
+// 节点
+{ id: 'br-balance-or-start', type: 'branch', title: '判断:先平衡?还是直接开始?', ... }
+// 连线:判断节点 → 两条路
+{ from: 'br-balance-or-start', to: 'op-balance',   type:'internal', label:'①点【平衡】' },
+{ from: 'br-balance-or-start', to: 'op-start-dish', type:'internal', label:'②点【开始培养】' },
+```
+
+### 4.2 多路分支(如:移植/冷冻/删除/作废)
+判断节点连出 N 条边到 N 个并排节点,dagre 自动把它们排成一排:
+```javascript
+{ from: 'br-destination', to: 'op-transplant', type:'internal', label:'移植' },
+{ from: 'br-destination', to: 'op-freeze',     type:'internal', label:'冷冻' },
+{ from: 'br-destination', to: 'op-delete',     type:'internal', label:'删除' },
+{ from: 'br-destination', to: 'op-invalid',    type:'internal', label:'作废' },
+// 四个去向再汇合到"结束培养"
+{ from: 'op-transplant', to: 'op-end', type:'internal' },
+{ from: 'op-freeze',     to: 'op-end', type:'internal' },
+{ from: 'op-delete',     to: 'op-end', type:'internal' },
+{ from: 'op-invalid',    to: 'op-end', type:'internal' },
+```
+
+### 4.3 回流(如:换气完→回主循环、舱位释放→回主界面)
+用 `type:'loop'`(橙色点线),从后面节点连回前面节点:
+```javascript
+{ from: 'ctl-airswap', to: 'ctl-loop', type:'loop', label:'换气完→下轮' },
+{ from: 'op-back-main', to: 'op-main', type:'loop', label:'舱位释放·可放下一个' },
+```
+
+### 4.4 异常旁路(如:拍照失败→报警)
+用 `type:'error'`(红色点线),从主流程节点旁拉出到异常节点:
+```javascript
+{ from: 'ctl-photo',  to: 'err-alarm', type:'error', label:'拍照失败' },
+{ from: 'err-alarm',  to: 'op-main',   type:'error', label:'报警显示' },
+```
+
+### 4.5 跨端调用(如:operate→control)
+用 `type:'cross'`(绿色虚线),表示经 API/MQTT/Kafka 到另一个端:
+```javascript
+{ from: 'op-start-dish', to: 'ctl-recv', type:'cross', label:'MQTT StartDish' },
+{ from: 'ctl-photo', to: 'op-detail', type:'cross', label:'Kafka→DB→轮询' },
+```
+
+> **要点**:分支汇合点(如四去向都→结束培养)也用边表达,dagre 会自动收拢。
+> 你不需要算任何坐标,只要边写对,图就对。
+
+---
+
+## 五、交互规范
+
+### 5.1 节点交互
+- **默认**:显示图标+标题+类型标签+一句话摘要(brief)
+- **hover**:阴影加深,z-index 提升
+- **激活(点击)**:边框高亮光圈,右侧滑出详情面板
+- **拖拽**:按住可拖动,连线实时跟随重绘;拖动距离>3px 算拖拽(不触发点击)
+
+### 5.2 拖拽 + localStorage 记忆(v2 新增·关键)
+- 每个节点可鼠标拖动,松手后**自动存 localStorage**(键名带版本号如 `tl-flow-positions-v2`)
+- 下次打开**自动恢复**上次拖好的位置(没存过则用 dagre 初始布局)
+- 顶栏 **「重置布局」** 按钮:清 localStorage + 重跑 dagre + 适应屏幕(拖乱了能救回来)
+- 顶栏 **「适应屏幕」** 按钮:整图居中缩放到刚好看全
+- 顶栏 **「已自动保存」** 状态提示:拖完闪一下"✓ 已保存位置"
+
+### 5.3 画布平移 + 缩放
+- 空白处按住拖动 = 平移整个画布
+- 鼠标滚轮 = 以鼠标位置为中心缩放(0.3~2 倍)
+- 右下角 [+][-] 按钮 + 百分比显示
+- 缩放/平移用 `world` 元素的 `transform: translate() scale()`
+
+### 5.3 详情面板
+- 从右侧滑出,宽 **640px**(max-width 92vw 适配小屏)
+- 关闭:点遮罩 / 按 ESC / 点右上角 ×
+- 标题带类型徽章(颜色=节点类型色)
+
+### 5.4 连线绘制(直角折线)
+- SVG 在节点渲染后**动态读取节点真实坐标**再画(保证对齐)
+- 直角折线:`M sx,sy L sx,midY L ex,midY L ex,ey`
+- A 在 B 上方 → A 底部出、B 顶部进;B 在 A 上方(回流)→ 反向;同层 → 走侧边
+- 拖动节点时**实时重绘所有连线**(`renderEdges()`)
+
+---
+
+## 六、代码结构规范
+
+### 6.1 三文件职责
+- **flow-data.js**:只放数据,导出 `NODES` 和 `EDGES` 两个数组(见 3.1 / 第四章示例)
+- **flow-render.js**:IIFE 包裹,按 8 步走:
+  1. `computeLayout()`——dagre 算每个节点 `{x,y}`(先临时渲染量节点高度)
+  2. `loadSaved()`——读 localStorage 覆盖坐标
+  3. `renderNodes()`——绝对定位渲染节点 + 绑拖拽 + 绑点击
+  4. `renderEdges()`——SVG 直角折线 + 箭头 + 标签
+  5. `makeDraggable()`——拖拽 + 拖完 `savePositions()`
+  6. 平移/缩放(wheel + 空白拖动 + 缩放按钮)
+  7. 详情面板(`showDetail` / `closeDetail` / `detailHTML`)
+  8. `init()`——串起来;`重置布局`/`适应屏幕` 按钮
+- **HTML**:骨架 DOM(顶栏/viewport/world/svg/缩放/面板)+ 全部 CSS
+
+### 6.2 节点/连线数据结构(⚠ 不含坐标,dagre 自动算)
+```javascript
+const NODES = [
+  {
+    id: 'op-start-dish',          // 唯一ID,格式:端-功能-动作
+    type: 'operate',              // start/operate/control/front/branch/error
+    icon: '▶️',
+    title: '开始培养(保存)',
+    brief: '落库 dish+embryo → 发 MQTT 通知 control',  // 画布上直接显示
+    detail: { desc, pre[], trigger, steps[], backend, data[], cross|null, code[] }  // 见第三章
+  },
+  // ... 没有 x/y!坐标由 dagre 算
+];
+
+const EDGES = [
+  { from: 'op-start-dish', to: 'ctl-recv', type: 'cross', label: 'MQTT StartDish' },
+  // type: internal(本端) / cross(跨端) / error(异常) / loop(回流)
+  // label 可选
+];
+```
+
+### 6.3 dagre 调用要点
+```javascript
+const g = new dagre.graphlib.Graph();
+g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 90, marginx: 40, marginy: 40 });
+g.setDefaultEdgeLabel(() => ({}));
+NODES.forEach(n => g.setNode(n.id, { width: 200, height: 量出来的高度 }));
+EDGES.forEach(e => g.setEdge(e.from, e.to));
+dagre.layout(g);
+g.nodes().forEach(id => { const nd = g.node(id); nodePos[id] = { x: nd.x - nd.width/2, y: nd.y - nd.height/2 }; });
+```
+
+---
+
+## 七、制作清单(每次制作流程图必做)
+
+### 7.1 前期准备
+- [ ] 用 codegraph 挖通完整业务链路(从入口到出口,包括所有分支)
+- [ ] 列出涉及的三端文件(operate / control / front 各有哪些文件参与)
+- [ ] 列出涉及的数据表(哪些表会增删改查)
+- [ ] 列出涉及的接口/MQTT(哪些 API / topic 会被调用)
+
+### 7.2 节点设计
+- [ ] 每个操作/判断/事件抽象成一个节点(含 `branch` 判断节点、`error` 异常节点)
+- [ ] 唯一 ID(格式 `端-功能`,如 `op-start-dish`、`ctl-loop`、`br-destination`)
+- [ ] 填 `brief`(画布一句话)+ 完整 `detail`(8 字段,见第三章)
+- [ ] **不写坐标**(dagre 自动算)
+
+### 7.3 分支设计(画进图,不进面板)
+- [ ] 每个决策点建一个 `branch` 节点
+- [ ] 从决策节点连出多条 `EDGES` 到不同子节点(见第四章)
+- [ ] 分支汇合点也用边连(dagre 自动收拢)
+- [ ] 回流用 `loop`、异常旁路用 `error`、跨端用 `cross`
+
+### 7.4 连线设计
+- [ ] 每条边定 `type`(internal/cross/error/loop)+ 可选 `label`
+- [ ] 检查 EDGES 里 from/to 的 id 都在 NODES 里存在(用脚本验,见 7.6)
+
+### 7.5 三端联动设计
+- [ ] 每个跨端节点的 `detail.cross` 写清 `{from, via, to[]}`
+- [ ] 跨端用 `cross` 类型边在图上连出来
+
+### 7.6 测试与验收(用脚本 + 浏览器双验)
+- [ ] **脚本验数据**:node 跑一遍,检查①边引用的节点都存在 ②id唯一 ③每个节点 detail 8字段齐 ④跑 dagre 布局**0 重叠对**
+- [ ] **浏览器验视觉**:Edge/Chrome headless 截图,确认布局整齐、分支并排、连线分类、颜色正确
+- [ ] **JS 语法**:`node --check flow-data.js` / `node --check flow-render.js`
+- [ ] **交互**:点节点弹面板、拖动节点连线跟随、刷新后位置恢复、重置布局可救回
+- [ ] 4 个文件在同一目录
+
+验数据脚本(复用):
+```bash
+node -e '
+global.dagre=require("./dagre.min.js");
+const src=require("fs").readFileSync("./flow-data.js","utf8").replace(/const NODES/,"NODES").replace(/const EDGES/,"EDGES");
+global.NODES=[];global.EDGES=[];eval(src);
+const ids=new Set(NODES.map(n=>n.id));let bad=0;
+EDGES.forEach(e=>{if(!ids.has(e.from)){console.log("X起点:",e.from);bad++}if(!ids.has(e.to)){console.log("X终点:",e.to);bad++}});
+const g=new dagre.graphlib.Graph();g.setGraph({rankdir:"TB",nodesep:60,ranksep:90});g.setDefaultEdgeLabel(()=>({}));
+NODES.forEach(n=>g.setNode(n.id,{width:200,height:90}));EDGES.forEach(e=>{if(g.hasNode(e.from)&&g.hasNode(e.to))g.setEdge(e.from,e.to)});
+dagre.layout(g);
+const b=g.nodes().map(id=>{const n=g.node(id);return{id,x:n.x-100,y:n.y-45,w:200,h:90}});let ov=0;
+for(let i=0;i<b.length;i++)for(let j=i+1;j<b.length;j++){const a=b[i],c=b[j];if(a.x<c.x+c.w&&a.x+a.w>c.x&&a.y<c.y+c.h&&a.y+a.h>c.y)ov++}
+console.log("节点",NODES.length,"边",EDGES.length,"坏引用",bad,"重叠",ov,(ov===0&&bad===0)?"[OK]":"[!]");
+'
+```
+
+---
+
+## 八、常见错误与纠正
+
+### ❌ 错误 1:把分支写成文字 / 塞进面板(v1 最大的坑)
+**错**:一个"标记胚胎去向"节点,分支写在面板"后续分支"里:移植/冷冻/删除/作废。
+**纠正**:建 `br-destination` 判断节点,画布上真分出 4 个并排节点 + 4 条边(见 4.2)。
+
+### ❌ 错误 2:手写节点坐标导致重叠交叉(v1 第二大坑)
+**错**:每个节点写死 `x:200, y:100`,节点一多线条到处交叉。
+**纠正**:删掉所有坐标,用 dagre 自动分层布局(见 6.3),脚本验证 0 重叠。
+
+### ❌ 错误 3:详情面板内容不完整
+缺前置条件/缺三端联动/缺代码位置。**纠正**:严格按第三章 8 字段填。
+
+### ❌ 错误 4:连线颜色/线型不区分
+所有线一个样 → 看不出本端还是跨端。**纠正**:按 2.3 用 4 种 type 区分颜色+线型。
+
+### ❌ 错误 5:单文件塞太多导致写入被截断
+**错**:节点详情多了,单个 HTML 几千行,一次写容易断。
+**纠正**:拆 4 文件(见 2.0),数据(flow-data.js)和引擎(flow-render.js)分开,分批追加。
+
+---
+
+## 九、参考示例(标杆实现,照着抄)
+
+### 标杆:培养全流程图(本规范 v2 配套,桌面)
+- **文件**(4 个,同目录):
+  - `时差培养箱-培养全流程详图.html`(骨架+CSS)
+  - `flow-data.js`(23 节点 + 39 连线)
+  - `flow-render.js`(dagre+拖拽+localStorage+折线+面板)
+  - `dagre.min.js`(布局库)
+- **覆盖**:入箱→平衡分支/直接开始→control 舱主循环(换气/对焦/拍照三分支)→看图→胚胎去向四分支→结束→回主界面回流;异常报警旁路;front 旁路联动
+- **特点**:dagre 0 重叠、分支真画进图、可拖拽记忆位置、直角折线、三端配色
+- **验证**:脚本(0坏引用/0重叠/detail齐) + Edge headless 截图均通过
+
+> **下次做新功能流程图**:复制这 4 个文件,改 `flow-data.js` 的 NODES/EDGES,
+> HTML 改标题,flow-render.js 基本不用动。改完跑 7.6 的验证脚本。
+
+---
+
+## 十、总结
+
+✅ **记住这几条,流程图就不会出错**:
+1. **分支真实画进图**(建 branch 节点 + 多条边,不写进面板文字)
+2. **坐标交给 dagre**(不手写 x/y,杜绝重叠交叉)
+3. **详情面板写全 8 字段**(desc/pre/trigger/steps/backend/data/cross/code)
+4. **三端联动说清楚**(cross 字段:谁触发→经过什么→影响谁)
+5. **连线分 4 类**(internal/cross/error/loop,颜色+线型区分)
+6. **拖拽+localStorage**(可微调 + 记忆位置 + 重置可救回)
+7. **拆 4 文件 + 脚本验证**(避免截断,保证 0 重叠 0 坏引用)
+
+📌 **每次制作前先读这份规范,制作后跑 7.6 验证脚本。直接复制第九章标杆 4 文件改最省事。**

+ 9 - 0
项目文档/开发计划/2026-06-24-D2-02-第二阶段-MJPEG实时预览-实现计划.md

@@ -766,6 +766,8 @@ git commit -m "feat(d2-02): operate MjpegStreamClient 流式读+解码 BitmapIma
 >
 > **sessionId 来源(已澄清)**:第一阶段命令分发**尚未接进 operate VM**(那是第三阶段)。本阶段预览要独立真机验证,需要一个已 acquire 的 sid。处理:VM 加一个 `public string CurrentSessionId { get; set; }`(纯持有,第三阶段 acquire 时赋值;本阶段真机验证时由 Claude 先 curl `/debug/acquire` 拿到 sid 后通过调试入口赋上,或临时硬编码验证)。**不改 VM 任何既有方法**。
 
+> **★实例一次性(Task6 代码审查 I-1)**:`MjpegStreamClient` 是一次性实例,Stop/Dispose 后**不可复用 Start**(否则旧后台 Task 尾帧/Stopped 会串台到新会话)。本 Task 必须:**OpenVideo 每次 `new MjpegStreamClient`,CloseVideo 里 `_mjpeg.Stop()` 后置 `_mjpeg = null` 丢弃实例**。绝不复用同一个 client 实例反复 Start。
+
 - [ ] **Step 0: 把 ControlClient.BaseUrl 提升为 public**
 
 Edit `ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/ControlClient.cs:23`,把 `private static string BaseUrl` 改为 `public static string BaseUrl`(仅可见性,值不变)。
@@ -903,8 +905,15 @@ git commit -m "docs(d2-02): 第二阶段 MJPEG 预览代码完成回写——断
 ## 真机门控(归第三阶段 V-012,不在本计划阻塞)
 
 本计划交付**代码完成 + 纯逻辑单测全绿 + Release 双编译 0 错**。下列需真机/真 UI:
+- **★预览启动前置(Task7 审查 M-1)**:第二阶段 `CurrentSessionId` 无 UI 赋值点(第三阶段 acquire 才赋值)。真机验证预览**必须先 curl `/debug/acquire` 拿 sid → 把 sid 赋给 `vm.CurrentSessionId`**(调试入口/临时赋值),否则 OpenVideo 走"未借用会话,无法预览"分支、预览点不开(这是阶段边界,非 bug)。
+- **★★会话 10s 无心跳自断(最终整体审查发现,真机必踩)**:第二阶段 operate 端**不发心跳**(心跳归第三阶段)。开预览后若不操作、无外部心跳,`LastSeen` 停在 acquire 时刻,**TTL 10s(Program.cs ttlMs=10000,看门狗每3s扫)到点 → SweepExpired 回收会话 → 推流线程下轮 TryGet 失败自停 → 预览自断**,operate 弹"调试会话已超时"。**这不是预览 bug,是 spec 已知设计缺口**(推流端点 TryGet 只读不刷新 LastSeen,心跳归第三阶段)。真机验证第二阶段预览**必须制造心跳续命**:开预览后用 curl 每 ~5s 打一次 `POST /debug/heartbeat {sessionId}`,观察预览能否稳定持续 >30s。第三阶段接入 DebugSessionClient 周期心跳后此坑自然消失,**无需改第二阶段代码**。
 - MJPEG 真机出图:acquire 某舱 → operate 端 `<Image>` 看到该舱实时画面。
+- **关预览/返回后画面干净无残帧(Task7 审查 I-1 已代码根治"当值实例"闸)**:真机确认关预览后画面立即清空、不闪残帧。
+- **画面方向核查(Task1 代码审查提出)**:旧 `Camera.SaveBmpPic` 落盘前有 `RotateNoneFlipY`(相机原始 buffer 可能是 bottom-up)。MjpegStreamWriter 不做翻转 → 真机预览可能上下倒置。**若倒置,在推流调用层(Task3 抓帧后)补一次 Y 翻转**,勿改纯逻辑类 MjpegStreamWriter。
 - 崩溃自愈:断预览/杀 operate → 推流线程自停 + 标记 StreamBroken → 心跳 TTL 看门狗回收 → 该舱采集恢复(/status 反映)。
+- **★重点压测(Task3 代码审查 I-1)**:**预览中反复 release/超时回收同一舱**——推流线程抓帧与会话回收 Dispose 相机存在 use-after-free 窗口(底层 `Camera.UnInit` 只置 `IsInit=false` 不置 `IsStart=false`,`GetRgbData` 的 `IsStart` 护栏拦不住已 Dispose 相机 → 下一轮抓帧进 native 踩已释放 pDest/已卸载 hImager)。被全局相机锁串行 + native 的 HPCSE 兜底降级到"大概率只丢一帧",但 .NET6 上 HPCSE 不保证可恢复。**真机务必压**:开预览同时反复 release/超时回收,确认 control 不偶发崩溃/native 日志无致命报错。若真机暴露崩溃,最小修法=让底层 `Camera.UnInit()` 顺带置 `IsStart=false`(单独评估,影响面超出本阶段)。
+- **SetOpMode(1) 实参被底层忽略(Task3 审查 M-3)**:`CameraImpl.SetOpMode(byte)` 转发底层无参 `SetOpMode()`,传 1 不生效。真机确认"连续 GrabStable 确实能出帧"(底层默认模式是否支持连续抓帧)。
+- **SendChunked + multipart 端到端分帧(Task3 审查 M-1)**:control 用 chunked transfer + multipart 双层分帧,operate `MjpegStreamClient` 需先解 chunked 再按 boundary 切帧(HttpClient 自动解 chunked)。端到端联调确认分帧正常。
 - 相机锁冲突实测:A 舱预览 + B 舱采集拍照同时,观察实际影响(spec §6.3)。
 
 随第三阶段(operate 完整接入)一并真机验收。

+ 0 - 198
项目文档/流程图交付清单.md

@@ -1,198 +0,0 @@
-# 时差培养箱业务流程图交付清单
-
-> **交付日期**:2026-06-24  
-> **任务**:制作完整的"从入箱到结束"业务流程图 + 可复用的流程图制作规范
-
----
-
-## 一、交付文件清单
-
-### 1.1 核心交付物
-
-| 文件 | 位置 | 说明 |
-|------|------|------|
-| **时差培养箱-培养全流程详图.html** | `C:/Users/AIVFO/Desktop/` | 完整业务流程图(双击浏览器打开),覆盖从入箱→培养→拍照→看图→标记→结束的全流程,包含正常路径、分支路径、异常路径、回流路径、三端联动 |
-| **流程图制作规范-可复用模板.md** | `项目文档/` | 流程图制作规范文档,定义了节点详情面板的 10 个板块规范、分支绘制规范、三端联动规范、制作清单,以后其他功能复用 |
-| **时差培养箱-流程图使用说明.md** | `C:/Users/AIVFO/Desktop/` | 使用说明文档,包含如何查看流程图、如何制作其他功能流程图、常见问题等 |
-
-### 1.2 参考文件
-
-| 文件 | 位置 | 说明 |
-|------|------|------|
-| **flow-click.html** | `C:/Users/AIVFO/Desktop/` | 参考模板(已存在),展示了点击节点弹详情面板、多条并行分支、SVG 连线的实现方式 |
-
----
-
-## 二、流程图核心特性
-
-### 2.1 完整性(100% 覆盖真实业务)
-✅ **正常路径**:入箱 → 平衡(可选)→ 对焦 → 拍照 → 看图 → 标记 → 结束  
-✅ **分支路径**:平衡 vs 直接开始、移植 vs 冷冻 vs 删除 vs 作废  
-✅ **异常路径**:硬件异常、网络异常、业务校验失败、报警上报  
-✅ **回流路径**:对焦失败重试、拍照失败重试、换气循环
-
-### 2.2 三端联动(跨端影响清晰标注)
-每个节点的详情面板都包含"三端联动影响"板块,明确:
-- **本端动作**:operate 点按钮 / control 收到命令 / front 刷新界面
-- **中间层**:HTTP API / MQTT / Kafka / 数据库
-- **影响端**:其他端的界面变化 / 状态变化 / 行为变化
-
-### 2.3 节点详情(10 个板块完整信息)
-每个节点点击后右侧滑出详情面板,包含:
-1. 📋 节点基本信息
-2. 🔑 前置条件
-3. 🎯 触发方式
-4. 🔄 交互步骤
-5. ⚙️ 后端逻辑
-6. 💾 涉及数据 / 状态变化
-7. 🌐 三端联动影响(核心)
-8. 🔀 后续分支
-9. ⚠️ 异常分支 / 边界情况
-10. 📍 代码位置
-
-### 2.4 无限画布(业务有多复杂,流程图就撑多大)
-- 画布宽高不限制(`min-width` / `min-height`)
-- 浏览器自动出现滚动条
-- 分支可以往任意方向延伸(上下左右 + 回流)
-
----
-
-## 三、覆盖的业务节点(15 个核心节点)
-
-### 3.1 operate 端(6 个节点)
-1. **主界面舱室总览** - 显示 10 个舱室状态,点击空舱进入新建入箱
-2. **新建患者入箱** - 录入患者信息,选孔位,可选平衡或直接开始培养
-3. **看图页(详情页)** - 显示 16 孔圆盘 + 延时视频 + 播放控制
-4. **标记胚胎去向** - 移植 / 冷冻 / 删除 / 作废(4 条分支)
-5. **结束培养** - 结束整皿培养,舱室变空闲
-6. **平衡流程(分支)** - 可选的平衡流程,换气若干轮后再开始培养
-
-### 3.2 control 后台(5 个节点)
-7. **收到 StartDish** - MQTT 收到 StartDish 命令,同步本地 SQLite,启动舱主循环
-8. **舱主循环(MainThread)** - 核心循环:温压监测 → 换气 → 对焦 → 拍照
-9. **自动对焦** - 四步标定算法,逐 well 找清晰 Z 位,存盘 calibration.json
-10. **拍照(ccdThreadFun)** - 逐孔逐层抓图,存盘本地,上传 Kafka
-11. **报警上报(异常)** - 温压异常 / 硬件异常 / 拍照失败 → 报警入库 + 短信通知
-
-### 3.3 front 管理端(2 个节点)
-12. **设备管理** - 显示设备卡 + 11 舱室状态,医生在 PC 端查看
-13. **胚胎详情 / 延时回放** - PC 端看图页,查看延时回放 + AI 报告 + 评分
-
-### 3.4 连线(22 条)
-- **本端流程**:实线(蓝色 / 橙色 / 紫色)
-- **跨端调用**:虚线(绿色 MQTT / 青色同步)
-- **异常路径**:虚线(红色)
-- **回流路径**:曲线(回到前面某步重新走)
-
----
-
-## 四、技术实现
-
-### 4.1 技术栈
-- **纯前端**:HTML + CSS + JavaScript(无框架依赖)
-- **SVG 绘图**:连线用 SVG 动态绘制
-- **响应式**:支持无限宽高画布,自动滚动
-
-### 4.2 代码结构
-```javascript
-const flowData = {
-  nodes: [
-    {
-      id: '唯一ID',
-      type: 'operate|control|front|branch|error',
-      title: '节点标题',
-      icon: '🖥️',
-      x: 100,  // 绝对坐标
-      y: 100,
-      detail: { /* 10 个板块数据 */ }
-    }
-  ],
-  edges: [
-    {
-      from: '起点节点ID',
-      to: '终点节点ID',
-      type: 'internal|mqtt|sync|error',
-      label: '连线标签',
-      curved: false  // 是否曲线
-    }
-  ]
-};
-```
-
-### 4.3 核心函数
-- `renderNodes()` - 渲染所有节点
-- `drawLines()` - 绘制 SVG 连线
-- `showNodeDetail(node)` - 显示节点详情面板
-- `closeDetailPanel()` - 关闭详情面板
-
----
-
-## 五、如何使用
-
-### 5.1 查看流程图
-1. 双击 `时差培养箱-培养全流程详图.html`,用浏览器打开
-2. 点击任意节点,右侧滑出详情面板
-3. 滚动画布查看完整流程
-4. 按 ESC 或点击面板外区域关闭详情
-
-### 5.2 制作其他功能流程图
-1. 阅读 `项目文档/流程图制作规范-可复用模板.md`
-2. 用 codegraph 挖通业务链路
-3. 复制 HTML 模板,修改节点数据和连线数据
-4. 对照制作清单逐项验收
-
-### 5.3 详细说明
-参考 `时差培养箱-流程图使用说明.md`
-
----
-
-## 六、验收标准(全部通过 ✅)
-
-- [x] **完整性**:覆盖从入箱到结束的全流程(正常 + 分支 + 异常 + 回流)
-- [x] **真实性**:所有分支都真实画出来(不折叠成文字)
-- [x] **三端联动**:每个跨端操作都清晰标注(谁触发 → 经过什么 → 影响谁)
-- [x] **节点详情**:每个节点的详情面板都包含完整的 10 个板块
-- [x] **代码定位**:每个节点都标注了代码位置(文件:行号 方法名)
-- [x] **无限画布**:画布宽高不限制,能正常滚动
-- [x] **交互流畅**:点击节点弹详情、关闭面板、连线绘制都正常
-- [x] **可复用**:制作规范文档完整,可用于其他功能流程图
-
----
-
-## 七、后续建议
-
-### 7.1 其他功能流程图
-按同样标准制作以下功能的流程图:
-- [ ] 报警处理流程(报警触发 → 上报 → 短信通知 → 闭环确认)
-- [ ] 对焦流程详图(四步标定算法 + 安全门降级)
-- [ ] 数据同步流程(Kafka → aivfo-oplog → 入库 + 文件移动)
-- [ ] 配置管理流程(云端拉取 → 本地 SQLite 兜底)
-
-### 7.2 流程图集成
-- 在项目文档首页增加"流程图索引"
-- 按功能模块组织流程图
-- 新人上手时先看流程图,再读代码
-
-### 7.3 持续更新
-- 业务逻辑改变时,同步更新流程图
-- 每次提交代码时,检查是否需要更新相关流程图
-
----
-
-## 八、总结
-
-✅ **已完成**:
-1. 完整的业务流程图 HTML(15 个节点 + 22 条连线 + 三端联动)
-2. 可复用的流程图制作规范文档(10 个板块 + 分支绘制 + 制作清单)
-3. 使用说明文档(查看方式 + 制作方式 + 常见问题)
-
-📌 **三个核心原则**(复用模板时记住):
-1. **分支必须真实画出来**(不折叠成文字)
-2. **详情面板必须写全 10 个板块**(不漏掉任何一项)
-3. **三端联动必须说清楚**(谁触发→经过什么→影响谁)
-
-🎯 **目标达成**:
-- ✅ 100% 把控业务逻辑的每个细节点
-- ✅ 真实分支无限延伸,不限制方向
-- ✅ 三端联动清晰标注
-- ✅ 制作规范文档可复用

+ 0 - 479
项目文档/流程图制作规范-可复用模板.md

@@ -1,479 +0,0 @@
-# 时差培养箱业务流程图制作规范(可复用模板)
-
-> **用途**:统一流程图制作标准,确保任何功能的流程图都能:
-> 1. **100% 还原真实业务逻辑**(包括分支、回流、异常路径)
-> 2. **清晰展示三端联动**(operate / control / front 的交互与影响)
-> 3. **提供完整节点详情**(每个节点的触发条件、执行逻辑、后续分支、涉及数据、代码位置)
-> 4. **支持无限扩展**(画布宽高不限,业务有多复杂流程图就撑多大)
-
----
-
-## 一、流程图核心原则
-
-### 1.1 真实性原则:不折叠、不简化
-- ❌ **禁止**把分支写成文字描述:"点击后可选 A 或 B"
-- ✅ **必须**画出真实的分支路径:主干下方真实分叉,左边一路走 A,右边一路走 B
-
-### 1.2 完整性原则:覆盖所有路径
-- **正常路径**:用户正常操作的主流程(如:入箱→培养→拍照→看图→标记→结束)
-- **分支路径**:业务决策点的不同选择(如:平衡 vs 直接开始、移植 vs 冷冻 vs 删除 vs 作废)
-- **异常路径**:错误、失败、超时、权限不足等场景(如:硬件异常、校验失败、MQTT 超时)
-- **回流路径**:回到前面某步重新走(如:对焦失败→重新对焦、拍照失败→重试)
-
-### 1.3 三端联动原则:明确跨端影响
-每个操作都要说清楚:
-- **本端做了什么**(operate 点了按钮 / control 收到命令 / front 刷新界面)
-- **触发了什么**(调接口 / 发 MQTT / 改数据库 / 发 Kafka)
-- **影响了哪些端**(其他端的界面变化 / 状态变化 / 行为变化)
-
-### 1.4 无限画布原则:不限宽高
-- 流程往哪延伸就往哪画(上下左右+斜向都可以)
-- 画布尺寸根据内容自动撑开(CSS 不设 max-width / max-height)
-- 浏览器出现滚动条是正常的(不强制塞进一屏)
-
----
-
-## 二、流程图结构规范
-
-### 2.1 主画布布局
-```
-┌────────────────────────────────────────────────┐
-│  顶部固定栏:标题 + 导航 + 图例               │
-├────────────────────────────────────────────────┤
-│                                                │
-│  流程画布(无限宽高,可滚动):               │
-│    ├─ 节点(圆角矩形,点击弹详情面板)       │
-│    ├─ 连线(SVG,不同颜色区分本端/跨端)     │
-│    └─ 分支(水平并排 / 树形分叉 / 回流箭头) │
-│                                                │
-└────────────────────────────────────────────────┘
-```
-
-### 2.2 节点样式规范
-
-#### 节点类型与颜色
-- **operate 端节点**:蓝色边框 `#4A90E2`,浅蓝背景 `#E3F2FD`,图标 🖥️
-- **control 端节点**:橙色边框 `#FF9800`,浅橙背景 `#FFF3E0`,图标 ⚙️
-- **front 端节点**:紫色边框 `#9C27B0`,浅紫背景 `#F3E5F5`,图标 💻
-- **分支决策节点**:琥珀色边框 `#FFA726`,琥珀背景 `#FFF8E1`,图标 ❓
-- **异常节点**:红色边框 `#F44336`,浅红背景 `#FFEBEE`,图标 ⚠️
-
-#### 节点内容
-```html
-<div class="flow-node" data-node-id="operate-add-dish">
-  <div class="node-icon">🖥️</div>
-  <div class="node-title">新建患者入箱</div>
-  <div class="node-tag">operate 端</div>
-</div>
-```
-
-### 2.3 连线样式规范
-
-#### 连线类型与颜色
-- **本端流程**:实线 `stroke-width: 2px`
-  - operate 内部:`#4A90E2` 蓝色
-  - control 内部:`#FF9800` 橙色
-  - front 内部:`#9C27B0` 紫色
-- **跨端调用**:虚线 `stroke-dasharray: 5,5`
-  - 调接口/发 MQTT:`#4CAF50` 绿色
-  - 同步通知:`#00BCD4` 青色
-- **异常路径**:虚线 `stroke-dasharray: 3,3`
-  - 报警/失败:`#F44336` 红色
-
-#### 箭头样式
-```svg
-<marker id="arrowhead" markerWidth="8" markerHeight="6">
-  <polygon points="0 0, 8 3, 0 6" fill="继承连线颜色"/>
-</marker>
-```
-
----
-
-## 三、节点详情面板规范(右侧滑出)
-
-### 3.1 面板结构
-点击任意节点,右侧滑出详情面板,必须包含以下板块:
-
-#### 📋 板块 1:节点基本信息
-```markdown
-【节点标题】新建患者入箱
-
-【所属端】operate 操作端
-
-【这步是什么】
-医生在空舱点击,弹窗录入患者信息(夫妻姓名/病例号/周期/受精方式),
-选 16 孔位放哪些胚胎,可选先平衡或直接开始培养。
-```
-
-#### 🔑 板块 2:前置条件(能进这步的条件)
-```markdown
-【前置条件】
-✓ 舱室状态 = 空舱(无培养皿)
-✓ 用户已登录且有操作权限
-✓ control 后台已启动、该舱硬件正常(串口/相机/电机正常)
-```
-
-#### 🎯 板块 3:触发方式 / 界面位置
-```markdown
-【触发方式】
-- operate 主界面(A2)点某个空舱格 → 弹 AddDishWindowView 弹窗
-- 或 front 设备管理(D3)点空舱格 → 弹 AddDishWindow
-```
-
-#### 🔄 板块 4:交互步骤(用户做什么)
-```markdown
-【交互步骤】
-1. 在 16 孔圆周上点选要放胚胎的孔位(可多选)
-2. 填写表单:病例号、周期、女方/男方姓名、出生年月日、受精时间、受精方式
-3. 可选:勾选"重点关注"(VIP)
-4. 底部两个按钮:
-   - 【平衡】→ 先启动平衡流程(见分支 A)
-   - 【保存(开始培养)】→ 直接开始培养(见分支 B)
-```
-
-#### ⚙️ 板块 5:后端逻辑(代码做了什么)
-```markdown
-【后端逻辑】
-1. operate 前端校验:必填项非空、孔位至少选一个、受精时间合法
-2. 调接口:StartDishApi(POST /api/dish/start)
-   - 参数:舱号、患者信息、选中孔位列表、是否 VIP
-   - 后端落库:dish 表(培养皿)、embryo 表(胚胎,每个选中孔一条)
-3. 发 MQTT 命令:topic `tl/command/{tlSn}`, type=StartDish
-   - control 后台收到 → AppData.StartDish → 舱状态改"培养中"
-   - 启动对焦+拍照节拍(见 C6 舱主循环)
-```
-
-#### 💾 板块 6:涉及数据 / 状态变化
-```markdown
-【涉及数据】
-- 数据库:dish 表插入一条(status=培养中),embryo 表插入 N 条
-- 内存:HouseBin.CurrentDish 设为新 dishId,IsWorking=true
-- 舱室状态:空闲 → 培养中(主界面该舱格变色+显示患者信息)
-```
-
-#### 🌐 板块 7:三端联动影响(核心)
-```markdown
-【三端联动影响】
-本端动作:operate 点"开始培养"按钮
-    ↓ 触发
-后端/中间件:StartDishApi → dish 表插入 → MQTT 发 StartDish 命令
-    ↓ 通知
-control 后台:收到 MQTT → AppData.StartDish → HouseBin 状态改"培养中" → 启动对焦拍照循环
-    ↓ 同步到
-front 管理端:
-  - D3 设备管理首页:该舱格状态变"培养中",显示患者信息
-  - D7 培养记录列表:新增一条记录
-    ↓ 反向影响
-本端界面:A2 主界面该舱格变色+显示患者姓名
-```
-
-#### 🔀 板块 8:后续分支(接下来会走哪)
-```markdown
-【后续分支】
-→ 分支 A:点了"平衡"
-   → 启动平衡流程(换气若干轮,duration 可配置)
-   → 手动点"结束平衡"
-   → 再点"开始培养"
-   → 进入分支 B
-
-→ 分支 B:点了"保存(开始培养)"
-   → control 收到 StartDish
-   → 进入 C6 舱主循环(温压监测 → 判断是否对焦 → 判断是否拍照)
-```
-
-#### ⚠️ 板块 9:异常分支 / 边界情况
-```markdown
-【异常分支】
-- 若该舱硬件异常(串口断/相机丢失)→ 前端提示"该舱室不可用",操作失败
-- 若 control 后台未启动 → 前端提示"设备离线"
-- 若正在平衡中又点"开始培养" → 校验失败,提示"请先结束平衡"
-```
-
-#### 📍 板块 10:代码位置
-```markdown
-【代码位置】
-- operate 前端:AddDishWindowView.xaml.cs:451 StartDish_Click
-- operate 接口调用:StartDishApi (Urls.cs + ApiService)
-- control 后台:AppData.cs:1102 StartDish → HouseBin.cs:614 MainThread
-- 数据库表:dish、embryo、house_state
-- MQTT topic:tl/command/{tlSn}
-```
-
----
-
-## 四、分支绘制规范
-
-### 4.1 水平并排分支(两条平行路径)
-```
-         主干
-          ↓
-    ┌─────┴─────┐
-    ↓           ↓
-  分支A       分支B
-  (冷冻)      (鲜胚移植)
-    ↓           ↓
-  [节点]      [节点]
-```
-
-**适用场景**:两条独立且平行的路径(如冷冻 vs 鲜胚移植)
-
-### 4.2 树形分叉(多条分支)
-```
-         主干
-          ↓
-    ┌─────┼─────┐
-    ↓     ↓     ↓
-  分支A  分支B  分支C
-  (移植) (冷冻) (删除)
-```
-
-**适用场景**:三条及以上分支(如胚胎去向:移植/冷冻/删除/作废)
-
-### 4.3 回流路径(回到前面某步)
-```
-  [对焦] → [拍照]
-     ↑        ↓
-     └←─[失败重试]
-```
-
-**适用场景**:失败重试、循环逻辑(如对焦失败→重新对焦)
-
-### 4.4 条件分支(if-else)
-```
-       [判断条件]
-         ↙   ↘
-    [条件成立]  [条件不成立]
-```
-
-**适用场景**:业务判断(如:是否首次对焦、是否到达拍照时间)
-
----
-
-## 五、交互规范
-
-### 5.1 节点点击
-- **默认状态**:节点显示标题+图标+标签
-- **hover 状态**:节点放大 1.05 倍,出现阴影 `box-shadow: 0 4px 12px rgba(0,0,0,0.15)`
-- **激活状态**:节点放大 1.12 倍,边框加粗,右侧滑出详情面板
-
-### 5.2 详情面板
-- **滑出动画**:从右侧滑入,transition 0.3s ease
-- **面板宽度**:固定 `600px`(不遮挡主画布)
-- **关闭方式**:点击面板外区域 / 按 ESC 键 / 点右上角 × 按钮
-
-### 5.3 连线绘制
-- **使用 SVG**:动态计算节点中心点坐标,绘制路径
-- **路径算法**:
-  - 直线:`M x1,y1 L x2,y2`
-  - 折线:`M x1,y1 L x1,midY L x2,midY L x2,y2`(适用跨模块)
-  - 曲线:`M x1,y1 Q cpX,cpY x2,y2`(适用回流)
-
----
-
-## 六、代码结构规范
-
-### 6.1 HTML 结构
-```html
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-  <meta charset="UTF-8">
-  <title>时差培养箱 - [功能名]业务流程图</title>
-  <style>
-    /* CSS 变量定义颜色 */
-    :root {
-      --operate-color: #4A90E2;
-      --control-color: #FF9800;
-      --front-color: #9C27B0;
-      --branch-color: #FFA726;
-      --error-color: #F44336;
-    }
-    /* 流程图容器:无限宽高 */
-    .flow-container {
-      position: relative;
-      min-width: 100vw;
-      min-height: 100vh;
-      padding: 100px;
-    }
-  </style>
-</head>
-<body>
-  <!-- 顶部固定栏 -->
-  <header class="top-bar">
-    <h1>时差培养箱 - [功能名]业务流程图</h1>
-    <nav><!-- 导航按钮 --></nav>
-  </header>
-  
-  <!-- 流程画布 -->
-  <div class="flow-container" id="flowContainer">
-    <svg id="svgLines"></svg>
-    <!-- 节点由 JS 动态生成 -->
-  </div>
-  
-  <!-- 详情面板 -->
-  <div class="detail-panel" id="detailPanel">
-    <!-- 动态填充 -->
-  </div>
-  
-  <script>
-    // 节点数据 + 绘制逻辑
-  </script>
-</body>
-</html>
-```
-
-### 6.2 节点数据结构
-```javascript
-const flowData = {
-  nodes: [
-    {
-      id: 'operate-add-dish',
-      type: 'operate',  // operate / control / front / branch / error
-      title: '新建患者入箱',
-      icon: '🖥️',
-      x: 200,  // 绝对坐标
-      y: 100,
-      detail: {
-        description: '医生在空舱点击,弹窗录入患者信息...',
-        preconditions: ['舱室状态=空舱', '用户已登录'],
-        trigger: 'operate 主界面点空舱格',
-        steps: ['选孔位', '填表单', '点保存'],
-        backend: 'StartDishApi → dish表插入 → MQTT发StartDish',
-        dataChanges: ['dish表插入', 'HouseBin.CurrentDish设值'],
-        crossPlatform: {
-          from: 'operate 点按钮',
-          to: ['control 收到MQTT启动对焦拍照', 'front 界面显示新记录']
-        },
-        nextBranches: ['平衡流程', '直接开始培养'],
-        exceptions: ['硬件异常', 'control离线'],
-        codeLocation: ['AddDishWindowView.xaml.cs:451', 'AppData.cs:1102']
-      }
-    }
-  ],
-  edges: [
-    {
-      from: 'operate-add-dish',
-      to: 'control-start-dish',
-      type: 'mqtt',  // internal / api / mqtt / sync / error
-      label: 'MQTT StartDish'
-    }
-  ]
-};
-```
-
----
-
-## 七、制作清单(每次制作流程图必做)
-
-### 7.1 前期准备
-- [ ] 用 codegraph 挖通完整业务链路(从入口到出口,包括所有分支)
-- [ ] 列出涉及的三端文件(operate / control / front 各有哪些文件参与)
-- [ ] 列出涉及的数据表(哪些表会增删改查)
-- [ ] 列出涉及的接口/MQTT(哪些 API / topic 会被调用)
-
-### 7.2 节点设计
-- [ ] 每个操作/判断/事件都抽象成一个节点
-- [ ] 给每个节点定义唯一 ID(格式:`端-功能-动作`,如 `operate-add-dish-save`)
-- [ ] 给每个节点填写完整的 10 个板块内容(见第三章)
-- [ ] 确认每个节点的前驱节点和后继节点
-
-### 7.3 分支设计
-- [ ] 列出所有决策点(用户选择 / 业务判断 / 异常分叉)
-- [ ] 每个决策点画出真实的分支路径(不折叠成文字)
-- [ ] 确认分支的汇合点(是否回到主干 / 各走各的 / 结束流程)
-
-### 7.4 连线设计
-- [ ] 用不同颜色区分本端流程 / 跨端调用 / 异常路径
-- [ ] 回流路径用曲线 + 箭头清晰标注方向
-- [ ] 跨模块连线用折线(避免直线穿过其他节点)
-
-### 7.5 三端联动设计
-- [ ] 每个跨端操作都明确:谁触发 → 经过什么 → 影响谁
-- [ ] 在详情面板的"三端联动影响"板块写清楚完整链路
-- [ ] 用不同颜色连线体现三端关系
-
-### 7.6 测试与验收
-- [ ] 点击每个节点,详情面板能正常弹出
-- [ ] 详情面板的 10 个板块内容都完整
-- [ ] 所有分支路径都画出来了(没有折叠成文字)
-- [ ] 异常路径、回流路径都标注清楚
-- [ ] 三端联动的影响都写明白了
-- [ ] 画布能正常滚动(宽高没限制死)
-
----
-
-## 八、常见错误与纠正
-
-### ❌ 错误 1:把分支写成文字
-```
-[入箱] → [点击后可选:平衡 或 直接开始培养] → [开始培养]
-```
-**纠正**:必须画出真实分支
-```
-         [入箱]
-          ↓
-    ┌─────┴─────┐
-    ↓           ↓
- [平衡]    [直接开始培养]
-    ↓           ↓
-[结束平衡]      ↓
-    ↓           ↓
-    └─────┬─────┘
-          ↓
-     [开始培养]
-```
-
-### ❌ 错误 2:详情面板内容不完整
-**常见缺失**:
-- 缺前置条件(不知道什么时候能进这步)
-- 缺三端联动(不知道影响了哪些端)
-- 缺异常分支(不知道失败了怎么办)
-- 缺代码位置(不知道去哪改)
-
-**纠正**:严格按第三章的 10 个板块填写
-
-### ❌ 错误 3:连线颜色不区分
-**所有连线都一个颜色** → 看不出哪些是本端流程、哪些是跨端调用
-
-**纠正**:按第 2.3 节规范,用不同颜色 + 线型区分
-
-### ❌ 错误 4:画布限制死宽高
-```css
-.flow-container {
-  width: 1920px;  /* ❌ 限死了 */
-  height: 1080px; /* ❌ 限死了 */
-  overflow: hidden; /* ❌ 超出部分被裁 */
-}
-```
-
-**纠正**:使用 `min-width` / `min-height` + 允许滚动
-```css
-.flow-container {
-  min-width: 100vw;
-  min-height: 100vh;
-  /* 不设 max-width / max-height */
-}
-```
-
----
-
-## 九、参考示例
-
-### 示例 1:完整的"入箱→培养→拍照→看图→标记→结束"流程图
-- 文件:`时差培养箱-培养全流程详图.html`(本次制作)
-- 特点:覆盖正常路径、分支路径、异常路径、回流路径、三端联动
-
-### 示例 2:参考模板(flow-click.html)
-- 位置:`C:/Users/AIVFO/Desktop/flow-click.html`
-- 特点:点击节点弹浮动面板、多条并行分支、SVG 连线
-
----
-
-## 十、总结
-
-✅ **记住这三点,流程图就不会出错**:
-1. **分支必须真实画出来**(不折叠成文字)
-2. **详情面板必须写全 10 个板块**(不漏掉任何一项)
-3. **三端联动必须说清楚**(谁触发→经过什么→影响谁)
-
-📌 **每次制作前,先读这份规范,制作时对照清单逐项检查。**

+ 15 - 0
项目文档/进度/交接卡.md

@@ -565,3 +565,18 @@
 - **提交**:git commit dcf7a52 "docs(flow): 完成业务流程图制作+可复用规范模板",包含 3 份文档(流程图制作规范/流程图交付清单/进度状态更新)。
 - **核实**:HTML 文件 1053 行,双击浏览器打开正常,点击节点详情面板正常滑出,所有连线正常绘制,15 个节点详情 10 板块全部填写完整,codegraph 挖通的业务链路全部覆盖。
 - **下一步**:① 建议用户过目三份文件(桌面 HTML + 使用说明 + 项目文档/规范+清单);② 后续可按同样标准制作其他功能流程图(报警处理/对焦详图/数据同步/配置管理);③ 继续推进原任务(清僵尸/D2-02 二三阶段/运行期去抖)。
+
+---
+
+## 2026-06-24 · D2-02 第二阶段 MJPEG 实时预览【代码完成·子代理驱动+两阶段审查全过·待真机出图】
+
+- **背景**:D2-02 第一阶段(control 后端会话管理)已真机验证过。本轮做第二阶段 MJPEG 实时预览——拆分后相机在 control 进程,operate 原"贴窗口句柄(StartPreview/Usb2Start)"预览方式断链,改成 control 推 MJPEG 流、operate 解码贴 `<Image>`。分支 `feature/d2-02-mjpeg-preview`(从 main 切)。
+- **流程(全 skill 驱动)**:brainstorming(逐项澄清+用户确认)→ writing-plans(8 任务 TDD,含业务闭环/影响面登记表)→ subagent-driven-development(每任务派实现子代理 + spec合规审查 + 代码质量审查两阶段,主线收结论+把审查 Minor/真机门控回写计划)。spec/计划见 `需求文档/specs/2026-06-24-D2-02-第二阶段-MJPEG实时预览-design.md` + `开发计划/2026-06-24-D2-02-第二阶段-MJPEG实时预览-实现计划.md`。
+- **关键决策(用户确认)**:① JPEG 编码放 control 端(压缩 20-100 倍,带宽优于推原始 RGB);② 专用后台线程推流(与命令分发解耦,崩了不影响主服务);③ 相机锁全进程一把(SDK 不改),A/B 舱预览/采集互等串行,本轮不优化留真机观察;④ **不自动重连**,断了明确提示操作人员手动重开(用户要求);⑤ A 舱调试时 B 舱可能正培养拍照,不限"仅空闲时段调试"。
+- **代码(7 编码任务,12 commit)**:
+  - control 端(`ivf_tl_operate_2.0/control/ivf_tl_ControlHost/`):`Debug/MjpegStreamWriter.cs`(纯逻辑 RGB→JPEG 编码 + multipart 帧封装,WPF JpegBitmapEncoder)/`Debug/DebugSession.cs`(+StreamBroken 字段)/`Debug/DebugSessionManager.cs`(+TryGet 只读,不动既有6方法)/`ControlHttpServer.cs`(+`/debug/preview/stream` 推流分支:校验会话→起专用后台线程抓帧→EncodeJpeg→FrameBytes→写流,**校验后 return 绕过统一收尾的 Close**,不阻塞 HttpListener;退出标记 StreamBroken,不在推流线程 Dispose lease 交心跳TTL看门狗回收)。
+  - operate 端(`ivf_tl_operate_2.0/ivf_tl_Operate/`):`Debug/MjpegFrameParser.cs`(切帧状态机,靠 Content-Length 截帧不扫 boundary,处理半帧拼接/一块多帧/坏帧跳过)/`Debug/MjpegStreamClient.cs`(HttpClient InfiniteTimeout 流式读+喂 parser+解码 BitmapImage Freeze+FrameReceived/Stopped 事件,Stop 仅 Cancel、_http 谁起谁清挪 finally,一次性实例不可复用)/`View/HouseDebugPageView.xaml`(预览区 Border 内加 `<Image x:Name=_previewImage>`)/`View/HouseDebugPageView.xaml.cs`(OpenVideo/CloseVideo 改连/断 client,每次 new 实例+CloseVideo置null丢弃,FrameReceived/Stopped lambda 用局部 client 比对字段防残帧串台)/`ViewModel/HouseDebugPageViewModel.cs`(+CurrentSessionId 属性)/`Helpers/ControlClient.cs`(BaseUrl 提 public)。
+- **核实**:每编码任务 TDD red→green;两阶段审查全过(spec 合规 + 代码质量,审查揪出的有价值问题均已修:FrameBytes判空/切帧脆弱断言/null防御+坏帧测试/Stop误报"中断"文案/MemoryStream释放/残帧竞态当值实例闸,均当轮 commit)。**全量 46 单测绿**(纯逻辑:JPEG编码2+会话2+切帧5,余为第一阶段);**control+operate Release 双编译 0 错**;codegraph 已同步。
+- **踩坑**:① Release 编译初次报 MSB3021——control pid10360(上轮 H-04 真机验证留的健康实例)锁着 Release DLL。用 `/shutdown`(token tl13579)优雅停机(关硬件+释放7COM口+退出),再编 0 错。② 僵尸 operate 20268 仍在(开机 6/21,提权杀不掉),但不锁输出 DLL、不挡编译,符合记忆"需重启清"。
+- **残=真机门控(归第三阶段 V-012,代码层不阻塞)**:① **预览出图前置**:第二阶段 CurrentSessionId 无 UI 赋值点(第三阶段 acquire 才赋),真机验证须先 curl `/debug/acquire` 拿 sid 赋 `vm.CurrentSessionId`,否则预览点不开(阶段边界非bug)。② 真机出图看画面。③ **画面方向**:旧 SaveBmpPic 有 RotateNoneFlipY(相机buffer可能bottom-up),推流不翻转可能倒置→倒置则在推流层(Task3抓帧后)补Y翻转,勿改纯逻辑 MjpegStreamWriter。④ **★重点压测(代码审查 I-1)**:预览中反复 release/超时回收同一舱——推流线程抓帧与会话回收Dispose相机有use-after-free窗口(底层 Camera.UnInit 只置IsInit=false不置IsStart=false,GetRgbData的IsStart护栏拦不住已Dispose相机),被全局相机锁串行+native HPCSE兜底降级到"大概率只丢一帧",但.NET6 HPCSE不保证可恢复,真机务必压确认不偶发崩溃;若崩,最小修=底层Camera.UnInit顺带置IsStart=false(单独评估)。⑤ 相机锁冲突实测:A舱预览+B舱采集拍照同时观察影响。⑥ 关预览画面无残帧(已代码根治当值实例闸,真机确认)。
+- **下一步**:本分支并 main(代码+文档已对齐,符合 CLAUDE.md 3.4 提交边界);或继续 D2-02 第三阶段(operate 完整接入 2 调试VM + 真机 V-012 电机走位),届时预览自然接通 MjpegStreamClient + 上述真机门控一并验,完成后解锁 D3-04 删 operate 死栈。

+ 8 - 1
项目文档/进度/工作计划表.md

@@ -26,7 +26,7 @@
 | 阶段 | 内容 | 状态 | 出口验收 |
 |------|------|------|----------|
 | **阶段1** | control 独立进程骨架 | 🟢 代码完成·真机闭环打通(待并 main) | control 独立 exe 能起✓、HTTP探活/读状态✓、续命✓、单实例✓、硬件获取✓、**真机自控环运行✓**;阻塞闭环的 D1-08 串口握手死锁已修复 |
-| **阶段2** | 监控补全 + 调试借串口 + 受护栏停止 | 🟢 监控/受护栏停止/借串口让路 已实现+真机验;**D2-02 调试页完整借串口:第一阶段 control 后端 批A+批B 已落地(2026-06-23,TDD 25单测绿),待批C(HTTP路由+装配)+真机冒烟** | 监控页跨进程 /status 显示完整✓;受护栏 /shutdown 安全停✓;/serial 让路✓;**D2-02(会话式借用+通用command分发+MJPEG预览+红线钳位+超时自动回收)spec+计划已出;第一阶段 control 后端:批A(单测工程/数据类 DebugSession+DebugCommandResult/红线钳位 MotorClamp/Fakes 测试替身)✓ + 批B(DebugSessionManager:acquire/release/heartbeat/幂等+超时自动回收+Execute 命令分发含电机红线钳位/EEPROM写)✓ 全 TDD 25 单测绿,8 commit 在 feature/d2-02-debug-command-proxy;剩 批C=Task8 ControlHttpServer /debug 路由+Task9 Program 装配 SweepExpired 看门狗,再 Task10 真机 curl 冒烟,待并 main** |
+| **阶段2** | 监控补全 + 调试借串口 + 受护栏停止 | 🟢 监控/受护栏停止/借串口让路 已实现+真机验;**D2-02 第一阶段(control 后端)✓代码+真机过;第二阶段(MJPEG 预览)✓代码完成+审查全过+Release双编译0错+46单测绿,待真机出图;第三阶段(operate 完整接入+V-012)待拆** | 监控页跨进程 /status 显示完整✓;受护栏 /shutdown 安全停✓;/serial 让路✓;**D2-02 第一阶段(会话式借用+command分发+红线钳位+超时回收)27单测+真机冒烟全过(2026-06-23);第二阶段 MJPEG 实时预览(control MjpegStreamWriter+推流端点专用线程/operate MjpegFrameParser切帧+MjpegStreamClient解码+调试页View接入)子代理驱动+两阶段审查全过,46单测绿,12 commit 在 feature/d2-02-mjpeg-preview,待真机出图(归V-012);第三阶段 operate 2VM完整接入+真机V-012待拆,完成解锁D3-04** |
 | **阶段3** | 清理老壳 + 装机收尾 | 🟢 退役删ControlTest+部署文档+开机自启 已做;**D1-10 control oplog审计埋点已迁移+真机验证**;**D3-05 control崩溃看门狗已实现+真机验证(2026-06-23)**;**HIL硬件在环回归套件已入库+真机验证(2026-06-23)**;**配置收敛 已完成+真机验证(2026-06-23)**;删operate死栈延后 | 退役删 ivf_tl_ControlTest✓;双进程部署指南✓;开机自启✓;**D1-10 oplog审计迁移到control活栈✓**;**D3-05 看门狗(崩溃重拉/DPAPI凭据/可暂停停止卸载)✓**;**HIL套件 IvfTl.Hardware.HilTests(守护M-05帧长/M-06按well焦点/M-01-03 EEPROM写,门控Skip+默认零写入)✓**;**配置收敛(operate↔control连接组7键单一数据源tl-shared.config经<appSettings file=>合并读+operate删12换气CCD死键,真机改一处对称生效)✓**;ComBin删operate死栈(D3-04,被D2-02阻塞)仍延后 |
 
 ---
@@ -63,6 +63,13 @@
 - 真机踩坑均已解(僵尸不挡 control / 登录真因=测试目录缺 tl-shared.config 致 BaseUrl 坏 / auth 库本零用户已插 admin/123456 可逆)。
 - 第二阶段(MJPEG 出图)、第三阶段(operate 接入 + V-012 电机真机走位)待拆。
 
+### D2-02 第二阶段(MJPEG 实时预览)= 🟢 代码完成 · ☑ 全过两阶段审查 + Release双编译0错 + 46单测绿 · ☐ 待真机出图
+- **7 编码任务全落地**(子代理驱动 + 每任务 spec合规审查 + 代码质量审查两阶段),12 commit 在 `feature/d2-02-mjpeg-preview`。spec/计划:`需求文档/specs/2026-06-24-D2-02-第二阶段-MJPEG实时预览-design.md` + `开发计划/2026-06-24-...-实现计划.md`。
+- **改面**:control 端 `MjpegStreamWriter`(RGB→JPEG+封帧纯逻辑)/`DebugSession.StreamBroken`+`DebugSessionManager.TryGet`(不动既有方法)/`ControlHttpServer` `/debug/preview/stream` 推流端点(专用后台线程,校验后 return 不阻塞 HttpListener,退出标记 StreamBroken 交心跳TTL回收);operate 端 `MjpegFrameParser`(切帧状态机5单测)/`MjpegStreamClient`(流式读+解码 BitmapImage Freeze+事件,不自动重连,一次性实例)/调试页 View 接入(OpenVideo/CloseVideo 改走 client+`<Image>`+断开提示,每次new实例+当值实例闸防残帧)/`ControlClient.BaseUrl` 提 public。
+- **验证**:control+operate Release 双编译 **0 错**;全量 **46 单测绿**(JPEG编码2+会话2+切帧5+第一阶段)。审查揪出的有价值问题均当轮修(判空/脆弱断言/null防御/Stop文案/MemoryStream/残帧竞态)。
+- **残=真机门控(归第三阶段V-012)**:预览出图需先 curl `/debug/acquire` 拿 sid 赋 vm.CurrentSessionId(第二阶段无UI赋值点)/真机出图/画面方向(倒置则推流层补Y翻转)/**★重点压测**预览中反复 release-回收同舱(use-after-free窗口,全局锁+HPCSE兜底)/关预览无残帧。详见交接卡 2026-06-24 段。
+- **可并 main**(代码+文档已对齐)。第三阶段(operate 2VM完整接入+真机V-012)待拆,届时预览接通+真机门控一并验,完成后解锁 D3-04。
+
 ### 新加固专项 · 舱室故障隔离 + 双端故障提示 = 🟢 第一阶段代码完成 · ☑ 真机核心验证过(残留 H-07 物理注入)
 - spec + 实现计划 + **方案修正**(复用现有 `reportAlarm` 报警闭环,弃用群消息 reportCloudAlarm)均已落盘;分支 `feature/house-fault-isolation`(代码+文档 commit)。
 - **结论**:运行期单舱坏已隔离 ✓ 且已接现有报警闭环;**启动期缺口已修** ✓(只排坏舱、好舱继续、零可跑才中止)。

+ 5 - 0
项目文档/进度/待验证清单.md

@@ -42,6 +42,11 @@
 |------|--------|------|------|
 | D2-01 | 监控页跨进程显示完整(/status 三块:活动/阀态/借用让路 + 心跳/磁盘/链路) | 运行 | ☑ 真机验证 |
 | D2-02 | 调试借串口:control 让路(暂停该舱采集)→ 恢复(原 V-012) | **真机** | ◑ 让路契约已验/调试页完整驱动=大改面待设计(D3-04 删死栈被其阻塞,见交接卡 2026-06-23 评估段) |
+| D2-04 | **[MJPEG]** 预览真机出图:先 curl /debug/acquire 拿 sid 赋 vm.CurrentSessionId → 开预览 → operate `<Image>` 看到该舱实时画面 | **真机** | ☐ 代码完成待验(归 V-012/第三阶段;第二阶段无 UI 赋 sid 点;**★第二阶段无心跳,开预览后须 curl 每5s打 /debug/heartbeat 续命,否则 TTL 10s 会话超时回收致预览自断——非bug,心跳归第三阶段**) |
+| D2-05 | **[MJPEG]** 画面方向:旧 SaveBmpPic 有 RotateNoneFlipY(buffer 或 bottom-up),确认预览不倒置;倒置则推流层(抓帧后)补 Y 翻转 | **真机** | ☐ 待验 |
+| D2-06 | **[MJPEG·★重点压测]** 预览中反复 release/超时回收同舱:推流抓帧 vs 回收 Dispose 相机 use-after-free 窗口(底层 UnInit 不置 IsStart=false,IsStart 护栏拦不住已 Dispose 相机),全局相机锁串行+native HPCSE 兜底,确认 control 不偶发崩溃 | **真机** | ☐ 待压测(崩则底层 Camera.UnInit 补置 IsStart=false) |
+| D2-07 | **[MJPEG]** 崩溃自愈+关预览无残帧:断预览/杀 operate→推流线程自停标记 StreamBroken→心跳TTL看门狗回收→该舱采集恢复(/status反映);关预览画面立即清空不闪残帧(已代码根治当值实例闸) | **真机** | ☐ 待验 |
+| D2-08 | **[MJPEG]** 相机锁冲突实测:A 舱预览 + B 舱采集拍照同时,观察拍照节拍/预览帧率实际影响(本轮不优化,真机看表现) | **真机** | ☐ 待验 |
 | D2-03 | 受护栏停止(二次确认+工程师口令)能安全停 control | 运行 | ☑ 真机验证 |
 
 > **2026-06-22 阶段2 实测说明(详见交接卡同日段)**:

+ 6 - 5
项目文档/进度/进度数据.js

@@ -1,10 +1,10 @@
 // 实时面板数据源(监控面板.html 读 window.PROGRESS_DATA)。每推进一步更新本文件。
 window.PROGRESS_DATA = {
   project: "operate/control 双进程拆分",
-  generatedAt: "2026-06-24 14:30",
-  phase: "三阶段主体完成;M区全闭合;D2-02 第一阶段【代码+真机全过】;舱室故障隔离 第一阶段【代码+真机核心验证】已并main;后续 H-08 operate'舱故障'区【代码完成+逻辑/契约验证】",
-  currentTask: "H-08 operate 监控页'舱故障'区(读 /status Faults)代码完成+逻辑/契约验证(改在 main 工作区,未提交)。改3文件纯operate侧加展示:新增 ServiceMonitorFaultMapper(8枚举名→中文/HouseText/AtText UTC→本地/IsolatedText)+ ServiceMonitorViewModel 加 Faults集合/汇总/Visibility显隐 + XAML 红色故障区(无故障显绿条/有故障逐条红底)。验证3层:operate Release 编译0错(XAML→BAML绑定全对)/curl control pid10360 /status 证 Faults字段透出(基线空·字段名匹配)/映射harness(临时文件/FaultMapperTest 链入真源码)14检查全过(8枚举+边界+UTC东八区+8+隔离)。残:WPF像素渲染待清僵尸真重启、有故障端到端=H-07物理拔相机门控。下一步:代码+文档一起提交。",
-  note: "H-08 拆两半:operate监控页舱故障区(本步,读/status Faults)+ front报警列表(H-05/06已通)。control端Faults第一阶段已透出(MonitorSnapshot.Faults/HouseFaultRow),本步纯operate加展示不碰control。FaultType透出英文枚举名(f.Type.ToString)→operate翻中文;At是UtcNow→展示转本地;HouseSn=-1=相机/串口级未定位。BoolToVisibilityConverter项目未注册,改用ViewModel直给Visibility。新control pid10360保留健康运行。",
+  generatedAt: "2026-06-24 18:00",
+  phase: "三阶段主体完成;M区全闭合;D2-02 第一阶段【代码+真机全过】+第二阶段 MJPEG 预览【代码完成+审查全过+Release双编译0错+46单测绿,待真机出图】;舱室故障隔离+H-08 已并main",
+  currentTask: "D2-02 第二阶段 MJPEG 实时预览代码完成(分支 feature/d2-02-mjpeg-preview,12 commit)。子代理驱动 7 编码任务 + 每任务 spec合规审查+代码质量审查两阶段全过。control端:MjpegStreamWriter(RGB→JPEG+multipart封帧纯逻辑)/DebugSession.StreamBroken+Manager.TryGet/ControlHttpServer /debug/preview/stream 推流端点(专用后台线程,校验后return不阻塞HttpListener,退出标记StreamBroken交心跳TTL回收)。operate端:MjpegFrameParser(切帧状态机5单测)/MjpegStreamClient(流式读+解码BitmapImage Freeze+FrameReceived/Stopped事件,不自动重连,一次性实例)/调试页View接入(OpenVideo/CloseVideo改走client+Image+断开提示,每次new+当值实例闸防残帧)/ControlClient.BaseUrl提public。control+operate Release双编译0错,全量46单测绿。下一步:本分支并main 或推进第三阶段(operate完整接入+真机V-012)。",
+  note: "关键决策(用户确认):JPEG编码放control端(压缩20-100倍)/专用后台线程推流(崩了不拖垮主服务)/相机锁全进程一把(SDK不改,A/B舱预览采集互等串行本轮不优化真机观察)/不自动重连(断了明确提示手动重开)/A舱调试时B舱可能正培养拍照不限空闲时段。残=真机门控(归V-012):预览出图需先curl /debug/acquire拿sid赋vm.CurrentSessionId(第二阶段无UI赋值点)/画面方向(倒置则推流层补Y翻转)/★重点压测预览中反复release-回收同舱(use-after-free窗口·全局锁+HPCSE兜底)/关预览无残帧。Release编译踩坑:control pid10360锁DLL,经/shutdown(token tl13579)优雅停机后编0错。",
   milestones: [
     { name: "阶段1 · control 独立进程骨架(完成)", tasks: [
       { id: "Task1-7", name: "全过+D1-08死锁修复+operate真外壳E2E+数据入库DB铁证", status: "☑" }
@@ -12,7 +12,8 @@ window.PROGRESS_DATA = {
     { name: "阶段2 · 监控补全+借串口+受护栏停止", tasks: [
       { id: "D2-01", name: "监控页跨进程/status三块(harness验)", status: "☑" },
       { id: "D2-03", name: "受护栏停止/shutdown(口令+安全停机释放硬件)", status: "☑" },
-      { id: "D2-02", name: "调试页命令代理:第一阶段control后端 批A+批B(单测工程/数据类/红线钳位MotorClamp/Fakes + DebugSessionManager 会话/超时回收/Execute分发含电机钳位,TDD 25绿);剩批C HTTP路由+装配+真机冒烟", status: "◑" }
+      { id: "D2-02一", name: "调试页命令代理第一阶段control后端(会话/超时回收/Execute分发含红线钳位,27单测+真机冒烟全过)", status: "☑" },
+      { id: "D2-02二", name: "MJPEG实时预览:control MjpegStreamWriter+推流端点专用线程/operate MjpegFrameParser切帧+MjpegStreamClient解码+调试页View接入。子代理驱动+两阶段审查全过,Release双编译0错,46单测绿,待真机出图(归V-012)", status: "🟢" }
     ]},
     { name: "阶段3 · 清理老壳+装机收尾", tasks: [
       { id: "D3-01", name: "退役删ivf_tl_ControlTest(两编译0错)", status: "☑" },

+ 7 - 6
项目文档/进度/进度状态.yaml

@@ -1,12 +1,13 @@
 # 续接断点状态(机器可解析)。换会话/换电脑后首先读它定位。
 # 状态取值: 未开始 / 进行中 / 完成 / 代码完成待验证
 # 纪律:本字段只存【当前断点】,历史细节进 交接卡.md(见 CLAUDE.md 第三节)。
-更新时间: 2026-06-24 完成业务流程图制作+可复用规范模板(commit dcf7a52)
+更新时间: 2026-06-24 D2-02 第二阶段 MJPEG 实时预览【代码完成·全过审查·待真机出图】(分支 feature/d2-02-mjpeg-preview,12 commit)
 当前任务: >
-  已完成"从入箱到结束"的完整业务流程图 HTML(15节点+22连线+三端联动+10板块详情),
-  以及可复用的流程图制作规范文档。三份文件已提交到 main。
-  · 交付物:桌面 时差培养箱-培养全流程详图.html(双击浏览器打开)、项目文档/流程图制作规范-可复用模板.md、项目文档/流程图交付清单.md。
-  · 下一步选项:a) 继续原任务(清僵尸真重启→复测 operate 监控页像素+H-07);b) D2-02 第二阶段;c) D2-02 第三阶段;d) 运行期按需补去抖。
+  【D2-02 第二阶段 MJPEG 实时预览 = 代码完成 + 子代理驱动逐任务两阶段审查全过 + Release 双编译0错 + 46单测绿】(分支 feature/d2-02-mjpeg-preview,未并 main)
+  · 7 编码任务全落地:control MjpegStreamWriter(RGB→JPEG+封帧)/DebugSession.StreamBroken+Manager.TryGet/ControlHttpServer 推流端点(专用后台线程,不阻塞 HttpListener)+ operate MjpegFrameParser(切帧状态机,5单测)/MjpegStreamClient(流式读+解码 BitmapImage Freeze+FrameReceived/Stopped 事件,不自动重连)/调试页 View 接入(OpenVideo/CloseVideo 改走 client+Image控件+断开提示,每次new实例)。
+  · 验证:control+operate Release 双编译 0 错;全量 46 单测绿(纯逻辑:JPEG编码2+切帧5+会话2);codegraph已同步。
+  · 残=真机门控(归第三阶段V-012):①预览出图需先 curl /debug/acquire 拿 sid 赋 vm.CurrentSessionId(第二阶段无UI赋值点);②真机出图看画面;③画面方向(旧SaveBmpPic有RotateNoneFlipY,倒置则推流层补Y翻转);④★重点压测:预览中反复release/超时回收同舱(use-after-free窗口,全局锁+HPCSE兜底,确认不崩);⑤关预览画面无残帧(已代码根治当值实例闸)。
+  · 下一步:本分支并 main(代码+文档已对齐);或继续 D2-02 第三阶段(operate 完整接入 2 VM + 真机 V-012),届时预览自然接通+上述真机门控一并验。
 说明: >
   告警闭环(核实):ReportAlarmController→/reportAlarm→报警责任链→aivfo_tl_setting.alarm 表→front报警列表+operate"系统异常(N)"+短信电话(AlarmSchedule每1min扫在报→getPersonList→aivfo-service阿里云短信)+muteAlarm静音+恢复stopAlarm消警。状态码0正常/1异常/-1跳过。阿里云凭据硬编码 AliConstant(signName=艾伟孚科技)。reportCloudAlarm 只发IM群消息不入闭环=已弃用。
   代码改面:SerialBin(+Faults6处)/StartupFaultPolicy(新)/HouseFault(新,control/ivf_tl_Entity/)/StartMain(InitTL剔除+InitHouse逐舱兜底+StartRun报完整清单)/AppData(StartupFaults+快照+ReportStartupFaults走ReportAlarmController)/MonitorSnapshot(+Faults)。分支从 feature/d2-02-debug-command-proxy 切(测试工程在该分支,main落后)。
@@ -32,4 +33,4 @@
     名称: 舱室故障隔离 + 双端故障提示(新专项)
     状态: 第一阶段已并main;后续 H-08 operate 区代码完成+逻辑/契约验证(残 WPF像素/H-07物理注入)
     备注: "第一阶段(Task1-6,37单测,真机基线+闭环+短信)已本地并 main。后续 H-08 operate 监控页'舱故障'区:新增 ServiceMonitorFaultMapper+ViewModel Faults集合+XAML红区,operate Release 0错+curl /status Faults契约+映射harness14检查全过;残 WPF像素渲染(僵尸阻塞)+有故障端到端(=H-07物理注入)。front半已通H-05/06。"
-下一步: 选择推进方向——a) 清僵尸真重启→复测 operate 监控页像素+H-07 物理拔相机看红区;b) D2-02 第二阶段(MJPEG 出图);c) D2-02 第三阶段(operate 接入+V-012 电机走位);d) 运行期按需补去抖。冗余分支 feature/d2-02-debug-command-proxy / feature/house-fault-isolation 已并 main 可删。
+下一步: D2-02 第二阶段 MJPEG 代码完成待并 main(分支 feature/d2-02-mjpeg-preview)。推进方向——a) 本分支并 main;b) D2-02 第三阶段(operate 完整接入 2 VM + 真机 V-012,预览随之接通+真机门控一并验);c) 清僵尸真重启→复测 operate 监控页像素+H-07。冗余分支 feature/d2-02-debug-command-proxy / feature/house-fault-isolation 已并 main 可删。

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff