using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using FairyView; using Newtonsoft.Json; using SimpleCore.Library; using StandardScene.Model; using StandardScene.Utils; namespace LoopViewerApp { /// /// 环线/循环任务配置管理界面(CycleGUI 版,替代原 WinForms LoopViewer 窗体)。 /// /// 维护 tasklist.json of )的增 / 改 / 删;与 AbstractLoopMission 读取同一文件。 /// 单实例:再次打开则把已有面板置前。 /// 勾选多行后「删除选中」可批量删除(保留原 ListView 多选删除能力);每行「编辑」按钮打开编辑对话框。 /// 文件写入放后台线程,绝不阻塞渲染线程(避免界面卡死)。 /// /// 沿用 DeliveryViewer 的同套模式(单实例面板、pb.TableFairyUiHelper.ConfirmThen),不另造轮子。 /// 保留可实例化 + 以兼容既有调用 new LoopViewer().Show()。 /// public class LoopViewer { private const string TableId = "loop-task-list"; // 与 AbstractLoopMission 完全一致的读取路径,保证“写哪儿、它就读哪儿”。 private string JsonPath => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tasklist.json"); private readonly object SaveLock = new object(); // 直接取自枚举,自动与 TaskKind / TaskStartType 保持同步(含 Charge),无需手写列表。 private readonly string[] KindNames = Enum.GetNames(typeof(TaskKind)); private readonly string[] StartTypeNames = Enum.GetNames(typeof(TaskStartType)); private Panel _panel; private Panel _dialog; // 新增/编辑对话框,限单实例 private List _tasks = new List(); // 仅渲染线程读写 private readonly HashSet _selected = new HashSet(); // 仅渲染线程读写,存被勾选任务的 Id private volatile string _status = ""; /// 打开(或置前)任务管理面板。兼容原 new LoopViewer().Show() 调用方式。 public void Show() => Open(); /// 打开(或置前)任务管理面板。 public static void Open() => (_instance ??= new LoopViewer()).OpenCore(); private static LoopViewer _instance; private void OpenCore() { if (_panel != null) { try { _panel.BringToFront(); return; } catch { _panel = null; } } _selected.Clear(); LoadTasks(); var panel = GUI.DeclarePanel() .ShowTitle("任务列表管理器") .ForceFloat() .InitSize(1080, 620) .InitPos(false, 0, 0, 0.5f, 0.5f, 0.5f, 0.5f); _panel = panel; panel.IfTerminalQuit(() => _panel = null); panel.Define(pb => { if (pb.Closing()) { panel.Exit(); _panel = null; return; } if (pb.Button("新增任务", distinct: "loop-add")) OpenEditDialog(null); pb.SameLine(12); if (pb.Button("删除选中", distinct: "loop-del-selected")) ConfirmDeleteSelected(); pb.SameLine(16); pb.Label($"共 {_tasks.Count} 个任务,已选 {_selected.Count} 个"); pb.Table(TableId, new[] { "选择", "ID", "任务类别", "当前站点", "目标站点", "流量控制", "优先级", "途径点", "启动类型", "操作" }, _tasks.Count, (row, i) => { var t = _tasks[i]; var id = t.Id; var sel = _selected.Contains(id); if (row.Checkbox(ref sel, "勾选以批量删除")) { if (sel) _selected.Add(id); else _selected.Remove(id); } row.Label($"{t.Id}"); row.Label($"{t.Kind}"); row.Label($"{t.CurrentStationId}"); row.Label($"{t.TargetStationId}"); row.Label($"{t.TrafficControl}"); row.Label($"{t.Priority}"); row.Label(t.IsViaPoint ? "是" : "否"); row.Label($"{t.StartType}"); if (row.ButtonGroup(new[] { "编辑" }, new[] { "编辑该任务" }) == 0) OpenEditDialog(t); }, height: FairyUiHelper.TableHeightPx(18), enableSearch: true); if (!string.IsNullOrEmpty(_status)) { pb.Separator(); pb.Label(_status); } // 事件驱动为主,配合较慢的节流重绘即可保证后台保存结果/状态及时反映。 pb.Panel.Repaint(repaintTimeMs: 500); }); } /// 对选中项发起二次确认后删除(保留原多选删除的提示文案)。 private void ConfirmDeleteSelected() { if (_selected.Count == 0) { _status = "未选择任何任务"; _panel?.Repaint(); return; } string prompt; if (_selected.Count == 1) prompt = $"确认删除任务 ID={_selected.First()}?"; else prompt = $"确认删除所选 {_selected.Count} 个任务?"; // 删除仅做内存列表增删(极快,可在渲染线程执行);真正的文件写入在 SaveTasks 内部放后台线程。 FairyUiHelper.ConfirmThen(prompt, DeleteSelected); } private void DeleteSelected() { var removed = _tasks.RemoveAll(t => _selected.Contains(t.Id)); _selected.Clear(); SaveTasks(); _status = $"已删除 {removed} 个任务"; _panel?.Repaint(); } /// /// 打开「新增 / 编辑」对话框(置顶非模态、限单实例)。 为 null 表示新增,否则编辑该任务(保留其 Id)。 /// 每次打开都是全新面板:defaultText 能正确初始化,规避立即模式下文本框缓冲难以重置的问题。 /// private void OpenEditDialog(LoopTask existing) { if (_dialog != null) { try { _dialog.BringToFront(); return; } catch { _dialog = null; } } bool isAdd = existing == null; int kindIdx = isAdd ? 0 : Math.Max(0, Array.IndexOf(KindNames, existing.Kind.ToString())); int startIdx = Math.Max(0, Array.IndexOf(StartTypeNames, (isAdd ? TaskStartType.AutoLoop : existing.StartType).ToString())); string curText = (isAdd ? 0 : existing.CurrentStationId).ToString(); string tgtText = (isAdd ? 0 : existing.TargetStationId).ToString(); string trafficText = (isAdd ? 0 : Clamp(existing.TrafficControl, 0, 1000)).ToString(); string priText = (isAdd ? 1 : Clamp(existing.Priority, 0, 100)).ToString(); bool via = !isAdd && existing.IsViaPoint; string err = ""; // 不用 Modal:原生「模态弹窗 + 标题栏关闭X」的 EndPopup 配对 bug 会断言崩溃。 // 也不用 TopMost:置顶视口带 NoAutoMerge,会让 DropdownBox 的下拉弹窗落到独立非置顶视口里、被对话框挡在后面(看不到选项)。 // 故采用与 DeliveryViewer 相同的普通浮动面板(非模态、不停靠):Begin/End 路径,X 关闭干净,下拉弹窗 z 序正常。 var dlg = GUI.DeclarePanel() .ShowTitle(isAdd ? "新增任务" : $"编辑任务 ID: {existing.Id}") .ForceFloat() .InitSize(420, 380) .InitPos(false, 0, 0, 0.5f, 0.5f, 0.5f, 0.5f); _dialog = dlg; dlg.IfTerminalQuit(() => _dialog = null); dlg.Define(pb => { if (pb.Closing()) { dlg.Exit(); _dialog = null; return; } // 控件 id 由 ImHashStr(prompt) 经 Encoding.ASCII 计算:中文会被压成 '?',导致同字数纯中文标签 // (如“当前站点/目标站点”“任务类别/启动类型”)哈希相同而抛 Duplicated id。故各标签加唯一 ASCII 序号前缀以区分。 pb.DropdownBox("1. 任务类别", KindNames, ref kindIdx); var (c, _) = pb.TextInput("2. 当前站点", curText, alwaysReturnString: true); curText = c; var (tg, _) = pb.TextInput("3. 目标站点", tgtText, alwaysReturnString: true); tgtText = tg; var (tf, _) = pb.TextInput("4. 流量控制 (0~1000)", trafficText, alwaysReturnString: true); trafficText = tf; var (pr, _) = pb.TextInput("5. 优先级 (0~100)", priText, alwaysReturnString: true); priText = pr; pb.CheckBox("6. 途径点", ref via); pb.DropdownBox("7. 启动类型", StartTypeNames, ref startIdx); if (!string.IsNullOrEmpty(err)) { pb.Separator(); pb.Label(err); } pb.Separator(); if (pb.Button("保存", distinct: "loop-edit-save")) { if (!int.TryParse((curText ?? "").Trim(), out var cur)) { err = "当前站点需为整数"; return; } if (!int.TryParse((tgtText ?? "").Trim(), out var tgt)) { err = "目标站点需为整数"; return; } if (!TryParseClamp(trafficText, 0, 1000, out var traffic)) { err = "流量控制需为 0~1000 的整数"; return; } if (!TryParseClamp(priText, 0, 100, out var pri)) { err = "优先级需为 0~100 的整数"; return; } Enum.TryParse(KindNames[kindIdx], out var kind); Enum.TryParse(StartTypeNames[startIdx], out var st); if (isAdd) { _tasks.Add(new LoopTask { Id = GetNextTaskId(), Kind = kind, CurrentStationId = cur, TargetStationId = tgt, TrafficControl = traffic, Priority = pri, IsViaPoint = via, StartType = st }); _status = "已新增任务"; } else { existing.Kind = kind; existing.CurrentStationId = cur; existing.TargetStationId = tgt; existing.TrafficControl = traffic; existing.Priority = pri; existing.IsViaPoint = via; existing.StartType = st; _status = $"已保存任务 ID={existing.Id}"; } SaveTasks(); dlg.Exit(); _dialog = null; _panel?.Repaint(); } pb.SameLine(8); if (pb.Button("取消", distinct: "loop-edit-cancel")) { dlg.Exit(); _dialog = null; } }); } /// 下一个可用任务 Id(当前最大 Id + 1,空表则为 1)。 private int GetNextTaskId() => _tasks.Count == 0 ? 1 : _tasks.Max(t => t.Id) + 1; private void LoadTasks() { try { var path = JsonPath; if (!File.Exists(path)) File.WriteAllText(path, "[]"); var text = File.ReadAllText(path); _tasks = JsonConvert.DeserializeObject>(text) ?? new List(); } catch (Exception ex) { _tasks = new List(); _status = "加载 tasklist.json 失败,详见日志"; Diagnosis.Post($"LoopViewer 加载 tasklist.json 异常: {ExceptionFormatter.FormatEx(ex)}"); } } /// 序列化在渲染线程完成(极快),文件写入放后台线程,避免阻塞渲染线程。 private void SaveTasks() { string json; try { json = JsonConvert.SerializeObject(_tasks, Formatting.Indented); } catch (Exception ex) { _status = "保存失败,详见日志"; Diagnosis.Post($"LoopViewer 序列化 tasklist.json 异常: {ExceptionFormatter.FormatEx(ex)}"); return; } var path = JsonPath; Task.Run(() => { try { lock (SaveLock) File.WriteAllText(path, json); } catch (Exception ex) { _status = "保存失败,详见日志"; Diagnosis.Post($"LoopViewer 保存 tasklist.json 异常: {ExceptionFormatter.FormatEx(ex)}"); _panel?.Repaint(); } }); } private int Clamp(int v, int min, int max) => v < min ? min : (v > max ? max : v); private bool TryParseClamp(string s, int min, int max, out int value) { if (int.TryParse((s ?? "").Trim(), out value)) { value = Clamp(value, min, max); return true; } value = min; return false; } } }