Files

356 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using CycleGUI;
using Newtonsoft.Json;
using StandardScene.Utils;
namespace StandardScene.Signal.Ui
{
/// <summary>
/// 按 Model 的 <see cref="DisplayNameAttribute"/> 反射生成可增删改表格。
/// </summary>
/// <remarks>
/// 列顺序取属性 MetadataToken(源码声明顺序)。
/// 跳过不可写、索引器、<see cref="BrowsableAttribute"/> 为 false、<see cref="JsonIgnoreAttribute"/> 的属性。
/// 编辑区用独立字典暂存,点「添加」或「保存当前行」才反写对象并 <see cref="SignalConfigStore.Save"/>。
/// CycleGUI 控件 id 带 <c>_idPrefix</c>,三种表同时开窗时不会抢同一组控件状态。
/// </remarks>
public sealed class ModelTableEditor<T> where T : class, new()
{
private static PropertyInfo[] Props => _props ??= Discover();
private static PropertyInfo[] _props;
private List<T> _items = new List<T>();
/// <summary>-1 表示未选中,此时「保存/删除」会提示先选行,「添加」写入新行。</summary>
private int _selected = -1;
private string _status = "";
private string _path = "";
private string _idPrefix = typeof(T).Name;
private Func<T, string> _keySelector = _ => "";
/// <summary>字符串/数值编辑缓存,键为属性名。</summary>
private readonly Dictionary<string, string> _text = new Dictionary<string, string>();
private readonly Dictionary<string, bool> _bools = new Dictionary<string, bool>();
/// <summary>枚举下拉当前下标,写入时按 Enum.GetNames 转回枚举值。</summary>
private readonly Dictionary<string, int> _enumIdx = new Dictionary<string, int>();
public int Count => _items.Count;
public string Status => _status;
/// <summary>绑定 JSON 路径并立即 Reload。同一编辑器换文件时调用。</summary>
public void Bind(string path, Func<T, string> keySelector, string idPrefix)
{
_path = path ?? "";
_keySelector = keySelector ?? (_ => "");
_idPrefix = string.IsNullOrWhiteSpace(idPrefix) ? typeof(T).Name : idPrefix;
Reload();
}
/// <summary>从磁盘重读,丢弃未保存的编辑区内容。</summary>
public void Reload()
{
_items = SignalConfigStore.Load<T>(_path);
_selected = -1;
_status = $"已加载 {_items.Count} 条 · {_path}";
ClearEditor();
}
/// <summary>列表 + 编辑区 + 添加/保存/删除/清空/刷新。每帧由面板 Define 调用。</summary>
public void Render(PanelBuilder pb)
{
var headers = Props.Select(DisplayOf).Concat(new[] { "行操作" }).ToArray();
pb.SeparatorText($"列表({_items.Count}");
pb.Table($"dc-list-{_idPrefix}", headers, _items.Count, (row, i) =>
{
var item = _items[i];
foreach (var p in Props)
row.Label(FormatCell(p.GetValue(item)));
if (row.ButtonGroup(new[] { "选择" }, new[] { "选择该行" }) == 0)
{
_selected = i;
LoadEditor(item);
}
}, height: 10, enableSearch: true);
pb.SeparatorText(_selected >= 0 ? $"编辑第 {_selected + 1} 行" : "编辑(未选中则「添加」写入新行)");
DrawEditor(pb);
if (pb.Button("添加", distinct: $"dc-{_idPrefix}-add")) Add();
pb.SameLine(8);
if (pb.Button("保存当前行", distinct: $"dc-{_idPrefix}-save")) SaveRow();
pb.SameLine(8);
if (pb.Button("删除当前行", distinct: $"dc-{_idPrefix}-del")) ConfirmDelete();
pb.SameLine(8);
if (pb.Button("清空编辑区", distinct: $"dc-{_idPrefix}-clear"))
{
_selected = -1;
ClearEditor();
_status = "已清空编辑区,可添加新行";
}
pb.SameLine(8);
if (pb.Button("从文件刷新", distinct: $"dc-{_idPrefix}-reload"))
Reload();
if (!string.IsNullOrEmpty(_status))
{
pb.Separator();
pb.Label(_status);
}
}
/// <summary>发现可编辑列。Browsable=false 或 JsonIgnore 的运行态字段不会进表。</summary>
private static PropertyInfo[] Discover()
{
return typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
.Where(p => p.GetCustomAttribute<JsonIgnoreAttribute>() == null)
.OrderBy(p => p.MetadataToken)
.ToArray();
}
private static string DisplayOf(PropertyInfo p) =>
p.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? p.Name;
private static string FormatCell(object value)
{
if (value == null) return "";
if (value is bool b) return b ? "是" : "否";
return Convert.ToString(value) ?? "";
}
private void DrawEditor(PanelBuilder pb)
{
for (var i = 0; i < Props.Length; i++)
{
var p = Props[i];
var label = $"{_idPrefix}-{i + 1}. {DisplayOf(p)}";
var type = p.PropertyType;
if (type == typeof(bool))
{
if (!_bools.ContainsKey(p.Name)) _bools[p.Name] = false;
var v = _bools[p.Name];
pb.CheckBox(label, ref v);
_bools[p.Name] = v;
}
else if (type.IsEnum)
{
var names = Enum.GetNames(type);
if (names.Length == 0) continue;
if (!_enumIdx.ContainsKey(p.Name)) _enumIdx[p.Name] = 0;
var idx = _enumIdx[p.Name];
if (idx < 0 || idx >= names.Length) idx = 0;
pb.DropdownBox(label, names, ref idx);
_enumIdx[p.Name] = idx;
}
else
{
if (!_text.ContainsKey(p.Name)) _text[p.Name] = "";
var (text, _) = pb.TextInput(label, _text[p.Name], alwaysReturnString: true);
_text[p.Name] = text ?? "";
}
}
}
private void LoadEditor(T item)
{
foreach (var p in Props)
{
var value = p.GetValue(item);
if (p.PropertyType == typeof(bool))
_bools[p.Name] = value is true;
else if (p.PropertyType.IsEnum)
{
var names = Enum.GetNames(p.PropertyType);
var name = value?.ToString() ?? names.FirstOrDefault() ?? "";
var idx = Array.IndexOf(names, name);
_enumIdx[p.Name] = idx >= 0 ? idx : 0;
}
else
_text[p.Name] = value == null ? "" : Convert.ToString(value) ?? "";
}
}
private void ClearEditor()
{
_text.Clear();
_bools.Clear();
_enumIdx.Clear();
foreach (var p in Props)
{
if (p.PropertyType == typeof(bool))
_bools[p.Name] = false;
else if (p.PropertyType.IsEnum)
_enumIdx[p.Name] = 0;
else if (p.PropertyType == typeof(int) || p.PropertyType == typeof(byte) || p.PropertyType == typeof(ushort))
_text[p.Name] = "0";
else
_text[p.Name] = DefaultText(p);
}
}
private static string DefaultText(PropertyInfo p)
{
try
{
var fresh = new T();
var v = p.GetValue(fresh);
return v == null ? "" : Convert.ToString(v) ?? "";
}
catch
{
return "";
}
}
/// <summary>把编辑区打成对象。主键空、整数解析失败时返回 false 并给出中文 err。</summary>
private bool TryBuild(out T item, out string err)
{
item = new T();
err = "";
foreach (var p in Props)
{
try
{
object boxed;
if (p.PropertyType == typeof(bool))
boxed = _bools.TryGetValue(p.Name, out var b) && b;
else if (p.PropertyType.IsEnum)
{
var names = Enum.GetNames(p.PropertyType);
var idx = _enumIdx.TryGetValue(p.Name, out var e) ? e : 0;
if (idx < 0 || idx >= names.Length) idx = 0;
boxed = Enum.Parse(p.PropertyType, names[idx]);
}
else if (p.PropertyType == typeof(int))
{
var raw = _text.TryGetValue(p.Name, out var t) ? t : "0";
if (!int.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n))
{
err = $"{DisplayOf(p)} 必须是整数";
return false;
}
boxed = n;
}
else if (p.PropertyType == typeof(ushort))
{
var raw = _text.TryGetValue(p.Name, out var t) ? t : "0";
if (!ushort.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n))
{
err = $"{DisplayOf(p)} 必须是 0-65535";
return false;
}
boxed = n;
}
else if (p.PropertyType == typeof(byte))
{
var raw = _text.TryGetValue(p.Name, out var t) ? t : "0";
if (!byte.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n))
{
err = $"{DisplayOf(p)} 必须是 0-255";
return false;
}
boxed = n;
}
else
boxed = _text.TryGetValue(p.Name, out var s) ? s ?? "" : "";
p.SetValue(item, boxed);
}
catch (Exception ex)
{
err = $"{DisplayOf(p)} 写入失败:{ex.Message}";
return false;
}
}
var key = (_keySelector(item) ?? "").Trim();
if (string.IsNullOrEmpty(key))
{
err = "主键不能为空";
return false;
}
return true;
}
/// <summary>主键已存在则拒绝。成功后立刻写盘并把选中行指到新行。</summary>
private void Add()
{
if (!TryBuild(out var item, out var err))
{
CycleUiHelper.Alert("错误", err);
return;
}
var key = _keySelector(item);
if (_items.Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase)))
{
CycleUiHelper.Alert("错误", $"主键「{key}」已存在");
return;
}
_items.Add(item);
_selected = _items.Count - 1;
SignalConfigStore.Save(_path, _items);
_status = $"已添加 {key}";
}
/// <summary>覆盖当前选中行。主键与其它行冲突则拒绝。</summary>
private void SaveRow()
{
if (_selected < 0 || _selected >= _items.Count)
{
CycleUiHelper.Alert("提示", "请先选择要保存的行,或用「添加」写入新行");
return;
}
if (!TryBuild(out var item, out var err))
{
CycleUiHelper.Alert("错误", err);
return;
}
var key = _keySelector(item);
if (_items.Where((_, i) => i != _selected)
.Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase)))
{
CycleUiHelper.Alert("错误", $"主键「{key}」已存在");
return;
}
_items[_selected] = item;
SignalConfigStore.Save(_path, _items);
_status = $"已保存 {key}";
CycleUiHelper.Alert("提示", "保存成功");
}
/// <summary>确认框通过后删除选中行并写盘。</summary>
private void ConfirmDelete()
{
if (_selected < 0 || _selected >= _items.Count)
{
CycleUiHelper.Alert("提示", "请先选择要删除的行");
return;
}
var key = _keySelector(_items[_selected]);
CycleUiHelper.ConfirmThen($"删除「{key}」?", () =>
{
_items.RemoveAt(_selected);
_selected = -1;
ClearEditor();
SignalConfigStore.Save(_path, _items);
_status = $"已删除 {key}";
});
}
}
}