344 lines
13 KiB
C#
344 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using System.Windows.Threading;
|
|
|
|
namespace JJServiceManager
|
|
{
|
|
public enum LogLevel { Info, Success, Warning, Error, Notice }
|
|
|
|
/// <summary>로그 한 줄</summary>
|
|
public class LogLine : ObservableObject
|
|
{
|
|
private bool _isChecked;
|
|
private double _fillerHeight;
|
|
|
|
public int No { get; init; }
|
|
public DateTime Time { get; init; }
|
|
public string Text { get; init; } = "";
|
|
public LogLevel Level { get; init; }
|
|
public bool ShowTime { get; init; }
|
|
public bool IsFiller { get; init; }
|
|
|
|
public bool IsChecked { get => _isChecked; set => Set(ref _isChecked, value); }
|
|
|
|
public double FillerHeight
|
|
{
|
|
get => _fillerHeight;
|
|
set => Set(ref _fillerHeight, Math.Max(0, value));
|
|
}
|
|
|
|
public string NoText => IsFiller ? "" : No.ToString();
|
|
public string Display => IsFiller ? "" : (ShowTime ? $"[{Time:HH:mm:ss}] {Text}" : Text);
|
|
public Visibility FillerVisibility => IsFiller ? Visibility.Visible : Visibility.Collapsed;
|
|
public Visibility LineVisibility => IsFiller ? Visibility.Collapsed : Visibility.Visible;
|
|
|
|
public Brush Foreground => Level switch
|
|
{
|
|
LogLevel.Success => Brushes2.Running,
|
|
LogLevel.Warning => Brushes2.Pending,
|
|
LogLevel.Error => Brushes2.Stopped,
|
|
LogLevel.Notice => LogPanel.NoticeBrush,
|
|
_ => LogPanel.NormalBrush
|
|
};
|
|
|
|
/// <summary>복사할 때의 텍스트</summary>
|
|
public string CopyText => ShowTime ? $"[{Time:yyyy-MM-dd HH:mm:ss}] {Text}" : Text;
|
|
}
|
|
|
|
/// <summary>앞쪽 여러 줄을 한 번에 지울 수 있는 컬렉션 (변경 알림 1회)</summary>
|
|
public sealed class LogCollection : ObservableCollection<LogLine>
|
|
{
|
|
public void RemoveFirst(int count)
|
|
{
|
|
if (count <= 0) return;
|
|
CheckReentrancy();
|
|
var list = (List<LogLine>)Items;
|
|
list.RemoveRange(0, Math.Min(count, list.Count));
|
|
OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("Count"));
|
|
OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("Item[]"));
|
|
OnCollectionChanged(new System.Collections.Specialized.NotifyCollectionChangedEventArgs(
|
|
System.Collections.Specialized.NotifyCollectionChangedAction.Reset));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 번호 + 체크박스가 있는 로그 목록.
|
|
/// - 화면클리어: 새 로그를 화면 맨 위부터 출력, 위로 스크롤하면 이전 로그가 보임, 번호는 이어짐
|
|
/// - 로그 클리어: 화면의 로그를 모두 지우고 번호를 1번부터 다시 시작
|
|
/// - 파일은 절대 지우지 않음
|
|
/// </summary>
|
|
public class LogPanel : ContentControl
|
|
{
|
|
public static readonly SolidColorBrush NormalBrush = Brushes2.Make("#212121");
|
|
public static readonly SolidColorBrush NoticeBrush = Brushes2.Make("#1565C0");
|
|
|
|
private const double LineHeight = 22;
|
|
|
|
private readonly LogCollection _lines = new LogCollection();
|
|
private readonly ListBox _list;
|
|
private readonly TextBlock _info;
|
|
private readonly DispatcherTimer _infoTimer;
|
|
private ScrollViewer _scroll;
|
|
private LogLine _filler;
|
|
private int _fillerNewLines;
|
|
private int _nextNo = 1;
|
|
private bool _scrollPending;
|
|
|
|
/// <summary>줄 앞에 시각 표시 여부 (작업 로그 = true, 파일 로그 = false)</summary>
|
|
public bool ShowTimestamp { get; set; } = true;
|
|
|
|
/// <summary>화면에 유지할 최대 줄 수 (오래된 줄부터 화면에서만 빠짐)</summary>
|
|
public int MaxLines { get; set; } = 20000;
|
|
|
|
public int Count => _lines.Count - (_filler != null ? 1 : 0);
|
|
|
|
public LogPanel()
|
|
{
|
|
var root = XamlLoader.LoadElement("LogPanel.xaml");
|
|
Content = root;
|
|
|
|
_list = root.Find<ListBox>("lstLog");
|
|
_info = root.Find<TextBlock>("txtInfo");
|
|
_list.ItemsSource = _lines;
|
|
|
|
root.Find<Button>("btnSelectAll").Click += (s, e) => SetAllChecked(true);
|
|
root.Find<Button>("btnSelectNone").Click += (s, e) => SetAllChecked(false);
|
|
root.Find<Button>("btnClearScreen").Click += (s, e) => ClearScreen();
|
|
root.Find<Button>("btnCopy").Click += (s, e) => CopyChecked();
|
|
root.Find<Button>("btnClearLog").Click += (s, e) => ClearLog();
|
|
|
|
_list.Loaded += (s, e) => _scroll ??= FindChild<ScrollViewer>(_list);
|
|
_list.SizeChanged += (s, e) => AdjustFiller();
|
|
_list.PreviewKeyDown += OnListKeyDown;
|
|
// 화면클리어용 빈 공간은 선택되지 않게 함
|
|
_list.SelectionChanged += (s, e) =>
|
|
{
|
|
if (_filler != null && e.AddedItems.Contains(_filler)) _list.SelectedItems.Remove(_filler);
|
|
};
|
|
|
|
_infoTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(4) };
|
|
_infoTimer.Tick += (s, e) => { _info.Text = ""; _infoTimer.Stop(); };
|
|
}
|
|
|
|
// ───────────────────────── 추가 ─────────────────────────
|
|
|
|
public void Add(string text, LogLevel level = LogLevel.Info)
|
|
=> AddMany(new[] { text }, level);
|
|
|
|
public void AddMany(IEnumerable<string> texts, LogLevel level = LogLevel.Info)
|
|
{
|
|
var list = texts as IList<string> ?? texts.ToList();
|
|
if (list.Count == 0) return;
|
|
|
|
if (!Dispatcher.CheckAccess())
|
|
{
|
|
Dispatcher.BeginInvoke(new Action(() => AddMany(list, level)));
|
|
return;
|
|
}
|
|
|
|
bool atBottom = IsAtBottom();
|
|
var now = DateTime.Now;
|
|
|
|
foreach (var t in list)
|
|
{
|
|
var line = new LogLine
|
|
{
|
|
No = _nextNo++,
|
|
Time = now,
|
|
Text = t ?? "",
|
|
Level = level,
|
|
ShowTime = ShowTimestamp
|
|
};
|
|
|
|
if (_filler != null)
|
|
{
|
|
_lines.Insert(_lines.Count - 1, line);
|
|
_fillerNewLines++;
|
|
_filler.FillerHeight -= LineHeight;
|
|
if (_filler.FillerHeight <= 0)
|
|
{
|
|
_lines.Remove(_filler);
|
|
_filler = null;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_lines.Add(line);
|
|
}
|
|
}
|
|
|
|
// 최대 줄 수를 여유분 이상 넘으면 화면에서만 오래된 줄을 한 번에 제거
|
|
int slack = Math.Max(500, MaxLines / 10);
|
|
if (Count > MaxLines + slack)
|
|
{
|
|
int remove = Count - MaxLines;
|
|
if (_filler != null) remove = Math.Min(remove, _lines.Count - 1);
|
|
_lines.RemoveFirst(remove);
|
|
atBottom = true;
|
|
}
|
|
|
|
if (atBottom) ScrollToEndDeferred();
|
|
}
|
|
|
|
// ───────────────────────── 버튼 동작 ─────────────────────────
|
|
|
|
/// <summary>화면클리어: 이전 로그는 위쪽에 그대로 두고, 새 로그가 화면 맨 위부터 보이도록 함</summary>
|
|
public void ClearScreen()
|
|
{
|
|
if (_filler != null)
|
|
{
|
|
_lines.Remove(_filler);
|
|
_filler = null;
|
|
}
|
|
if (_lines.Count == 0) return;
|
|
double vh = ViewportHeight();
|
|
if (vh <= 0) return; // 화면이 보이지 않는 상태
|
|
|
|
_filler = new LogLine { IsFiller = true, FillerHeight = vh };
|
|
_fillerNewLines = 0;
|
|
_lines.Add(_filler);
|
|
ScrollToEndDeferred();
|
|
ShowInfo("화면을 비웠습니다. 위로 스크롤하면 이전 로그를 볼 수 있습니다.");
|
|
}
|
|
|
|
/// <summary>로그 클리어: 화면의 로그를 모두 지우고 번호를 1번부터</summary>
|
|
public void ClearLog()
|
|
{
|
|
_lines.Clear();
|
|
_filler = null;
|
|
_fillerNewLines = 0;
|
|
_nextNo = 1;
|
|
ShowInfo("화면의 로그를 지웠습니다. 번호는 1번부터 시작합니다.");
|
|
}
|
|
|
|
public void SetAllChecked(bool value)
|
|
{
|
|
foreach (var l in _lines)
|
|
if (!l.IsFiller) l.IsChecked = value;
|
|
}
|
|
|
|
public void CopyChecked()
|
|
{
|
|
var picked = _lines.Where(l => !l.IsFiller && l.IsChecked).ToList();
|
|
if (picked.Count == 0)
|
|
{
|
|
ShowInfo("복사할 줄을 체크하세요.");
|
|
return;
|
|
}
|
|
CopyLines(picked);
|
|
}
|
|
|
|
private void CopyLines(IList<LogLine> lines)
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var l in lines) sb.AppendLine(l.CopyText);
|
|
|
|
for (int retry = 0; retry < 5; retry++)
|
|
{
|
|
try
|
|
{
|
|
Clipboard.SetText(sb.ToString());
|
|
ShowInfo($"{lines.Count}줄을 복사했습니다.");
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
System.Threading.Thread.Sleep(60);
|
|
}
|
|
}
|
|
ShowInfo("클립보드를 사용할 수 없습니다. 잠시 후 다시 시도하세요.");
|
|
}
|
|
|
|
private void OnListKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
// Ctrl+C: 마우스로 선택(파란 표시)한 줄 복사, 없으면 체크한 줄 복사
|
|
if (e.Key == Key.C && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
|
|
{
|
|
var picked = new HashSet<LogLine>(_list.SelectedItems.Cast<LogLine>());
|
|
var sel = _lines.Where(l => !l.IsFiller && picked.Contains(l)).ToList();
|
|
if (sel.Count > 0) CopyLines(sel); else CopyChecked();
|
|
e.Handled = true;
|
|
}
|
|
// Space: 선택한 줄의 체크 토글
|
|
else if (e.Key == Key.Space)
|
|
{
|
|
var sel = _list.SelectedItems.Cast<LogLine>().Where(l => !l.IsFiller).ToList();
|
|
if (sel.Count > 0)
|
|
{
|
|
bool v = !sel.All(l => l.IsChecked);
|
|
foreach (var l in sel) l.IsChecked = v;
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ───────────────────────── 스크롤 ─────────────────────────
|
|
|
|
private double ViewportHeight()
|
|
{
|
|
_scroll ??= FindChild<ScrollViewer>(_list);
|
|
if (_scroll != null && _scroll.ViewportHeight > 0) return _scroll.ViewportHeight;
|
|
return Math.Max(0, _list.ActualHeight - 4);
|
|
}
|
|
|
|
private void AdjustFiller()
|
|
{
|
|
if (_filler == null) return;
|
|
_filler.FillerHeight = ViewportHeight() - _fillerNewLines * LineHeight;
|
|
if (_filler.FillerHeight <= 0)
|
|
{
|
|
_lines.Remove(_filler);
|
|
_filler = null;
|
|
}
|
|
ScrollToEndDeferred();
|
|
}
|
|
|
|
private bool IsAtBottom()
|
|
{
|
|
_scroll ??= FindChild<ScrollViewer>(_list);
|
|
if (_scroll == null) return true;
|
|
return _scroll.VerticalOffset >= _scroll.ScrollableHeight - 2;
|
|
}
|
|
|
|
private void ScrollToEndDeferred()
|
|
{
|
|
if (_scrollPending) return;
|
|
_scrollPending = true;
|
|
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
|
|
{
|
|
_scrollPending = false;
|
|
_scroll ??= FindChild<ScrollViewer>(_list);
|
|
_scroll?.ScrollToBottom();
|
|
}));
|
|
}
|
|
|
|
private void ShowInfo(string msg)
|
|
{
|
|
_info.Text = msg;
|
|
_infoTimer.Stop();
|
|
_infoTimer.Start();
|
|
}
|
|
|
|
private static T FindChild<T>(DependencyObject parent) where T : DependencyObject
|
|
{
|
|
if (parent == null) return null;
|
|
int n = VisualTreeHelper.GetChildrenCount(parent);
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
var c = VisualTreeHelper.GetChild(parent, i);
|
|
if (c is T t) return t;
|
|
var r = FindChild<T>(c);
|
|
if (r != null) return r;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
}
|